更新权限
This commit is contained in:
parent
2275607bd2
commit
602694814f
@ -19,7 +19,7 @@
|
||||
## 3. 角色权限提示
|
||||
|
||||
- 任务接口权限:
|
||||
- `DOCTOR`:发布、取消
|
||||
- `DOCTOR/DIRECTOR/LEADER`:发布、取消(仅可取消自己创建的任务)
|
||||
- `ENGINEER`:接收、完成
|
||||
- 患者列表权限:
|
||||
- `SYSTEM_ADMIN` 查询时必须传 `hospitalId`
|
||||
@ -27,6 +27,36 @@
|
||||
- `SYSTEM_ADMIN/HOSPITAL_ADMIN` 可访问列表与创建
|
||||
- 删除和工程师绑定医院仅 `SYSTEM_ADMIN`
|
||||
|
||||
## 3.1 结构图页面交互调整
|
||||
|
||||
- 医院管理员视角下,右侧下级列表会优先显示“人员”节点,再显示组织节点。
|
||||
- 选中人员节点时,右侧展示人员详情(角色、手机号、所属医院/科室/小组),不再显示空白占位。
|
||||
|
||||
## 3.2 后台页面路由权限(与后端 RBAC 对齐)
|
||||
|
||||
- `organization/tree`、`organization/departments`、`organization/groups`、`users`
|
||||
- `organization/tree`、`organization/departments`、`organization/groups`:
|
||||
`SYSTEM_ADMIN`、`HOSPITAL_ADMIN`、`DIRECTOR`、`LEADER` 可访问
|
||||
- `users`:仅 `SYSTEM_ADMIN`、`HOSPITAL_ADMIN` 可访问
|
||||
- `organization/hospitals`
|
||||
- 仅 `SYSTEM_ADMIN` 可访问
|
||||
- `tasks`
|
||||
- 仅 `DOCTOR`、`DIRECTOR`、`LEADER`、`ENGINEER` 可访问
|
||||
- `patients`
|
||||
- 仅 `SYSTEM_ADMIN`、`HOSPITAL_ADMIN`、`DIRECTOR`、`LEADER` 可访问
|
||||
|
||||
前端已在路由守卫和侧边栏菜单同时做权限控制,无权限角色会被拦截并跳转到首页,避免进入页面后触发接口 `403`。
|
||||
|
||||
## 3.3 主任/组长组织管理范围
|
||||
|
||||
- `DIRECTOR`
|
||||
- 可查看组织架构、科室列表、小组列表(限定本科室范围)
|
||||
- 可编辑本科室名称、创建/编辑/删除本科室下小组
|
||||
- `LEADER`
|
||||
- 可查看组织架构、科室列表、小组列表(限定本科室/本小组范围)
|
||||
- 可编辑本科室名称与本小组名称
|
||||
- 负责人设置(设主任/设组长)与人员管理入口仍仅 `SYSTEM_ADMIN`、`HOSPITAL_ADMIN` 显示。
|
||||
|
||||
## 4. 本地运行
|
||||
|
||||
在 `tyt-admin` 目录执行:
|
||||
|
||||
@ -16,7 +16,7 @@
|
||||
## 2.1 B 端 CRUD
|
||||
|
||||
- `GET /b/patients`:按角色查询可见患者
|
||||
- `GET /b/patients/doctors`:查询当前角色可见的医生候选(用于患者表单)
|
||||
- `GET /b/patients/doctors`:查询当前角色可见的归属人员候选(医生/主任/组长,用于患者表单)
|
||||
- `POST /b/patients`:创建患者
|
||||
- `GET /b/patients/:id`:查询患者详情
|
||||
- `PATCH /b/patients/:id`:更新患者
|
||||
|
||||
@ -14,7 +14,7 @@
|
||||
|
||||
## 3. 角色权限
|
||||
|
||||
- 医生:发布任务、取消自己创建的任务
|
||||
- 医生/主任/组长:发布任务、取消自己创建的任务
|
||||
- 工程师:接收任务、完成自己接收的任务
|
||||
- 其他角色:默认拒绝
|
||||
|
||||
|
||||
@ -18,6 +18,7 @@
|
||||
|
||||
- 医院内数据按 `hospitalId` 强隔离。
|
||||
- 仅 `SYSTEM_ADMIN` 可执行工程师绑定医院。
|
||||
- `DIRECTOR/LEADER` 可读取用户列表,但仅返回当前科室可见用户。
|
||||
- 用户组织字段校验:
|
||||
- 院管/医生/工程师等需有医院归属;
|
||||
- 主任/组长需有科室/小组等必要归属;
|
||||
|
||||
@ -65,7 +65,7 @@ export const MESSAGES = {
|
||||
CANCEL_ONLY_PENDING_ACCEPTED: '仅待接收/已接收任务可取消',
|
||||
ENGINEER_ALREADY_ASSIGNED: '任务已被其他工程师接收',
|
||||
ENGINEER_ONLY_ASSIGNEE: '仅任务接收工程师可完成任务',
|
||||
CANCEL_ONLY_CREATOR: '仅任务创建医生可取消任务',
|
||||
CANCEL_ONLY_CREATOR: '仅任务创建者可取消任务',
|
||||
ACTOR_ROLE_FORBIDDEN: '当前角色无权限执行该任务操作',
|
||||
ACTOR_HOSPITAL_REQUIRED: '当前登录上下文缺少医院信息',
|
||||
},
|
||||
@ -75,9 +75,9 @@ export const MESSAGES = {
|
||||
ROLE_FORBIDDEN: '当前角色无权限查询患者列表',
|
||||
GROUP_REQUIRED: '组长查询需携带 groupId',
|
||||
DEPARTMENT_REQUIRED: '主任查询需携带 departmentId',
|
||||
DOCTOR_NOT_FOUND: '归属医生不存在',
|
||||
DOCTOR_ROLE_REQUIRED: '归属用户必须为医生角色',
|
||||
DOCTOR_SCOPE_FORBIDDEN: '仅可选择当前权限范围内医生',
|
||||
DOCTOR_NOT_FOUND: '归属人员不存在',
|
||||
DOCTOR_ROLE_REQUIRED: '归属用户必须为医生/主任/组长角色',
|
||||
DOCTOR_SCOPE_FORBIDDEN: '仅可选择当前权限范围内医生/主任/组长',
|
||||
DELETE_CONFLICT: '患者存在关联设备,无法删除',
|
||||
PHONE_IDCARD_REQUIRED: 'phone 与 idCardHash 均为必填',
|
||||
LIFE_CYCLE_NOT_FOUND: '未找到匹配的患者档案,请先确认手机号与身份证哈希',
|
||||
@ -90,6 +90,9 @@ export const MESSAGES = {
|
||||
DEPARTMENT_NOT_FOUND: '科室不存在',
|
||||
GROUP_NOT_FOUND: '小组不存在',
|
||||
HOSPITAL_ADMIN_SCOPE_INVALID: '院管仅可操作本院组织数据',
|
||||
ACTOR_HOSPITAL_REQUIRED: '当前登录上下文缺少医院信息',
|
||||
ACTOR_DEPARTMENT_REQUIRED: '当前登录上下文缺少科室信息',
|
||||
ACTOR_GROUP_REQUIRED: '当前登录上下文缺少小组信息',
|
||||
SYSTEM_ADMIN_ONLY_CREATE_HOSPITAL: '仅系统管理员可创建医院',
|
||||
SYSTEM_ADMIN_ONLY_DELETE_HOSPITAL: '仅系统管理员可删除医院',
|
||||
HOSPITAL_NAME_REQUIRED: '医院名称不能为空',
|
||||
|
||||
@ -55,7 +55,12 @@ export class DepartmentsController {
|
||||
* 查询科室列表。
|
||||
*/
|
||||
@Get()
|
||||
@Roles(Role.SYSTEM_ADMIN, Role.HOSPITAL_ADMIN)
|
||||
@Roles(
|
||||
Role.SYSTEM_ADMIN,
|
||||
Role.HOSPITAL_ADMIN,
|
||||
Role.DIRECTOR,
|
||||
Role.LEADER,
|
||||
)
|
||||
@ApiOperation({ summary: '查询科室列表' })
|
||||
@ApiQuery({ name: 'hospitalId', required: false, description: '医院 ID' })
|
||||
findAll(
|
||||
@ -69,7 +74,12 @@ export class DepartmentsController {
|
||||
* 查询科室详情。
|
||||
*/
|
||||
@Get(':id')
|
||||
@Roles(Role.SYSTEM_ADMIN, Role.HOSPITAL_ADMIN)
|
||||
@Roles(
|
||||
Role.SYSTEM_ADMIN,
|
||||
Role.HOSPITAL_ADMIN,
|
||||
Role.DIRECTOR,
|
||||
Role.LEADER,
|
||||
)
|
||||
@ApiOperation({ summary: '查询科室详情' })
|
||||
@ApiParam({ name: 'id', description: '科室 ID' })
|
||||
findOne(
|
||||
@ -83,7 +93,12 @@ export class DepartmentsController {
|
||||
* 更新科室。
|
||||
*/
|
||||
@Patch(':id')
|
||||
@Roles(Role.SYSTEM_ADMIN, Role.HOSPITAL_ADMIN)
|
||||
@Roles(
|
||||
Role.SYSTEM_ADMIN,
|
||||
Role.HOSPITAL_ADMIN,
|
||||
Role.DIRECTOR,
|
||||
Role.LEADER,
|
||||
)
|
||||
@ApiOperation({ summary: '更新科室' })
|
||||
update(
|
||||
@CurrentActor() actor: ActorContext,
|
||||
|
||||
@ -1,4 +1,9 @@
|
||||
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import {
|
||||
BadRequestException,
|
||||
ForbiddenException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { Prisma } from '../generated/prisma/client.js';
|
||||
import { Role } from '../generated/prisma/enums.js';
|
||||
import type { ActorContext } from '../common/actor-context.js';
|
||||
@ -37,10 +42,15 @@ export class DepartmentsService {
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询科室列表:院管默认限定本院。
|
||||
* 查询科室列表:院管限定本院;主任/组长限定本科室。
|
||||
*/
|
||||
async findAll(actor: ActorContext, query: OrganizationQueryDto) {
|
||||
this.access.assertRole(actor, [Role.SYSTEM_ADMIN, Role.HOSPITAL_ADMIN]);
|
||||
this.access.assertRole(actor, [
|
||||
Role.SYSTEM_ADMIN,
|
||||
Role.HOSPITAL_ADMIN,
|
||||
Role.DIRECTOR,
|
||||
Role.LEADER,
|
||||
]);
|
||||
const paging = this.access.resolvePaging(query);
|
||||
const where: Prisma.DepartmentWhereInput = {};
|
||||
|
||||
@ -48,13 +58,15 @@ export class DepartmentsService {
|
||||
where.name = { contains: query.keyword.trim(), mode: 'insensitive' };
|
||||
}
|
||||
|
||||
const targetHospitalId =
|
||||
actor.role === Role.HOSPITAL_ADMIN ? actor.hospitalId : query.hospitalId;
|
||||
if (targetHospitalId != null) {
|
||||
where.hospitalId = this.access.toInt(targetHospitalId, MESSAGES.ORG.HOSPITAL_ID_REQUIRED);
|
||||
}
|
||||
if (actor.role === Role.HOSPITAL_ADMIN && where.hospitalId == null) {
|
||||
throw new BadRequestException(MESSAGES.ORG.HOSPITAL_ID_REQUIRED);
|
||||
if (actor.role === Role.HOSPITAL_ADMIN) {
|
||||
where.hospitalId = this.access.requireActorHospitalId(actor);
|
||||
} else if (actor.role === Role.DIRECTOR || actor.role === Role.LEADER) {
|
||||
where.id = this.access.requireActorDepartmentId(actor);
|
||||
} else if (query.hospitalId != null) {
|
||||
where.hospitalId = this.access.toInt(
|
||||
query.hospitalId,
|
||||
MESSAGES.ORG.HOSPITAL_ID_REQUIRED,
|
||||
);
|
||||
}
|
||||
|
||||
const [total, list] = await this.prisma.$transaction([
|
||||
@ -72,10 +84,15 @@ export class DepartmentsService {
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询科室详情:院管仅可查看本院科室。
|
||||
* 查询科室详情:院管仅可查看本院;主任/组长仅可查看本科室。
|
||||
*/
|
||||
async findOne(actor: ActorContext, id: number) {
|
||||
this.access.assertRole(actor, [Role.SYSTEM_ADMIN, Role.HOSPITAL_ADMIN]);
|
||||
this.access.assertRole(actor, [
|
||||
Role.SYSTEM_ADMIN,
|
||||
Role.HOSPITAL_ADMIN,
|
||||
Role.DIRECTOR,
|
||||
Role.LEADER,
|
||||
]);
|
||||
const departmentId = this.access.toInt(id, MESSAGES.ORG.DEPARTMENT_ID_REQUIRED);
|
||||
const department = await this.prisma.department.findUnique({
|
||||
where: { id: departmentId },
|
||||
@ -87,12 +104,20 @@ export class DepartmentsService {
|
||||
if (!department) {
|
||||
throw new NotFoundException(MESSAGES.ORG.DEPARTMENT_NOT_FOUND);
|
||||
}
|
||||
if (actor.role === Role.HOSPITAL_ADMIN) {
|
||||
this.access.assertHospitalScope(actor, department.hospitalId);
|
||||
}
|
||||
if (actor.role === Role.DIRECTOR || actor.role === Role.LEADER) {
|
||||
const actorDepartmentId = this.access.requireActorDepartmentId(actor);
|
||||
if (department.id !== actorDepartmentId) {
|
||||
throw new ForbiddenException(MESSAGES.DEFAULT_FORBIDDEN);
|
||||
}
|
||||
}
|
||||
return department;
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新科室:院管仅可修改本院科室。
|
||||
* 更新科室:院管仅可修改本院;主任/组长仅可修改本科室。
|
||||
*/
|
||||
async update(actor: ActorContext, id: number, dto: UpdateDepartmentDto) {
|
||||
const current = await this.findOne(actor, id);
|
||||
@ -116,6 +141,7 @@ export class DepartmentsService {
|
||||
* 删除科室:院管仅可删本院科室。
|
||||
*/
|
||||
async remove(actor: ActorContext, id: number) {
|
||||
this.access.assertRole(actor, [Role.SYSTEM_ADMIN, Role.HOSPITAL_ADMIN]);
|
||||
const current = await this.findOne(actor, id);
|
||||
try {
|
||||
return await this.prisma.department.delete({ where: { id: current.id } });
|
||||
|
||||
@ -41,7 +41,7 @@ export class GroupsController {
|
||||
* 创建小组。
|
||||
*/
|
||||
@Post()
|
||||
@Roles(Role.SYSTEM_ADMIN, Role.HOSPITAL_ADMIN)
|
||||
@Roles(Role.SYSTEM_ADMIN, Role.HOSPITAL_ADMIN, Role.DIRECTOR)
|
||||
@ApiOperation({ summary: '创建小组' })
|
||||
create(
|
||||
@CurrentActor() actor: ActorContext,
|
||||
@ -54,7 +54,12 @@ export class GroupsController {
|
||||
* 查询小组列表。
|
||||
*/
|
||||
@Get()
|
||||
@Roles(Role.SYSTEM_ADMIN, Role.HOSPITAL_ADMIN)
|
||||
@Roles(
|
||||
Role.SYSTEM_ADMIN,
|
||||
Role.HOSPITAL_ADMIN,
|
||||
Role.DIRECTOR,
|
||||
Role.LEADER,
|
||||
)
|
||||
@ApiOperation({ summary: '查询小组列表' })
|
||||
findAll(
|
||||
@CurrentActor() actor: ActorContext,
|
||||
@ -67,7 +72,12 @@ export class GroupsController {
|
||||
* 查询小组详情。
|
||||
*/
|
||||
@Get(':id')
|
||||
@Roles(Role.SYSTEM_ADMIN, Role.HOSPITAL_ADMIN)
|
||||
@Roles(
|
||||
Role.SYSTEM_ADMIN,
|
||||
Role.HOSPITAL_ADMIN,
|
||||
Role.DIRECTOR,
|
||||
Role.LEADER,
|
||||
)
|
||||
@ApiOperation({ summary: '查询小组详情' })
|
||||
@ApiParam({ name: 'id', description: '小组 ID' })
|
||||
findOne(
|
||||
@ -81,7 +91,12 @@ export class GroupsController {
|
||||
* 更新小组。
|
||||
*/
|
||||
@Patch(':id')
|
||||
@Roles(Role.SYSTEM_ADMIN, Role.HOSPITAL_ADMIN)
|
||||
@Roles(
|
||||
Role.SYSTEM_ADMIN,
|
||||
Role.HOSPITAL_ADMIN,
|
||||
Role.DIRECTOR,
|
||||
Role.LEADER,
|
||||
)
|
||||
@ApiOperation({ summary: '更新小组' })
|
||||
update(
|
||||
@CurrentActor() actor: ActorContext,
|
||||
@ -95,7 +110,7 @@ export class GroupsController {
|
||||
* 删除小组。
|
||||
*/
|
||||
@Delete(':id')
|
||||
@Roles(Role.SYSTEM_ADMIN, Role.HOSPITAL_ADMIN)
|
||||
@Roles(Role.SYSTEM_ADMIN, Role.HOSPITAL_ADMIN, Role.DIRECTOR)
|
||||
@ApiOperation({ summary: '删除小组' })
|
||||
remove(
|
||||
@CurrentActor() actor: ActorContext,
|
||||
|
||||
@ -1,4 +1,9 @@
|
||||
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import {
|
||||
BadRequestException,
|
||||
ForbiddenException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { Prisma } from '../generated/prisma/client.js';
|
||||
import { Role } from '../generated/prisma/enums.js';
|
||||
import type { ActorContext } from '../common/actor-context.js';
|
||||
@ -20,13 +25,25 @@ export class GroupsService {
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 创建小组:系统管理员可跨院;院管仅可在本院科室下创建。
|
||||
* 创建小组:系统管理员可跨院;院管仅可在本院;主任仅可在本科室创建。
|
||||
*/
|
||||
async create(actor: ActorContext, dto: CreateGroupDto) {
|
||||
this.access.assertRole(actor, [Role.SYSTEM_ADMIN, Role.HOSPITAL_ADMIN]);
|
||||
this.access.assertRole(actor, [
|
||||
Role.SYSTEM_ADMIN,
|
||||
Role.HOSPITAL_ADMIN,
|
||||
Role.DIRECTOR,
|
||||
]);
|
||||
const departmentId = this.access.toInt(dto.departmentId, MESSAGES.ORG.DEPARTMENT_ID_REQUIRED);
|
||||
const department = await this.access.ensureDepartmentExists(departmentId);
|
||||
if (actor.role === Role.HOSPITAL_ADMIN) {
|
||||
this.access.assertHospitalScope(actor, department.hospitalId);
|
||||
}
|
||||
if (actor.role === Role.DIRECTOR) {
|
||||
const actorDepartmentId = this.access.requireActorDepartmentId(actor);
|
||||
if (actorDepartmentId !== department.id) {
|
||||
throw new ForbiddenException(MESSAGES.DEFAULT_FORBIDDEN);
|
||||
}
|
||||
}
|
||||
|
||||
return this.prisma.group.create({
|
||||
data: {
|
||||
@ -37,10 +54,15 @@ export class GroupsService {
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询小组列表:院管默认仅返回本院数据。
|
||||
* 查询小组列表:院管限定本院;主任限定本科室;组长限定本组。
|
||||
*/
|
||||
async findAll(actor: ActorContext, query: OrganizationQueryDto) {
|
||||
this.access.assertRole(actor, [Role.SYSTEM_ADMIN, Role.HOSPITAL_ADMIN]);
|
||||
this.access.assertRole(actor, [
|
||||
Role.SYSTEM_ADMIN,
|
||||
Role.HOSPITAL_ADMIN,
|
||||
Role.DIRECTOR,
|
||||
Role.LEADER,
|
||||
]);
|
||||
const paging = this.access.resolvePaging(query);
|
||||
const where: Prisma.GroupWhereInput = {};
|
||||
|
||||
@ -52,10 +74,11 @@ export class GroupsService {
|
||||
}
|
||||
|
||||
if (actor.role === Role.HOSPITAL_ADMIN) {
|
||||
if (!actor.hospitalId) {
|
||||
throw new BadRequestException(MESSAGES.ORG.HOSPITAL_ID_REQUIRED);
|
||||
}
|
||||
where.department = { hospitalId: actor.hospitalId };
|
||||
where.department = { hospitalId: this.access.requireActorHospitalId(actor) };
|
||||
} else if (actor.role === Role.DIRECTOR) {
|
||||
where.departmentId = this.access.requireActorDepartmentId(actor);
|
||||
} else if (actor.role === Role.LEADER) {
|
||||
where.id = this.access.requireActorGroupId(actor);
|
||||
} else if (query.hospitalId != null) {
|
||||
where.department = {
|
||||
hospitalId: this.access.toInt(query.hospitalId, MESSAGES.ORG.HOSPITAL_ID_REQUIRED),
|
||||
@ -80,10 +103,15 @@ export class GroupsService {
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询小组详情:院管仅可查看本院小组。
|
||||
* 查询小组详情:院管仅可查看本院;主任仅可查看本科室;组长仅可查看本组。
|
||||
*/
|
||||
async findOne(actor: ActorContext, id: number) {
|
||||
this.access.assertRole(actor, [Role.SYSTEM_ADMIN, Role.HOSPITAL_ADMIN]);
|
||||
this.access.assertRole(actor, [
|
||||
Role.SYSTEM_ADMIN,
|
||||
Role.HOSPITAL_ADMIN,
|
||||
Role.DIRECTOR,
|
||||
Role.LEADER,
|
||||
]);
|
||||
const groupId = this.access.toInt(id, MESSAGES.ORG.GROUP_ID_REQUIRED);
|
||||
const group = await this.prisma.group.findUnique({
|
||||
where: { id: groupId },
|
||||
@ -95,12 +123,26 @@ export class GroupsService {
|
||||
if (!group) {
|
||||
throw new NotFoundException(MESSAGES.ORG.GROUP_NOT_FOUND);
|
||||
}
|
||||
if (actor.role === Role.HOSPITAL_ADMIN) {
|
||||
this.access.assertHospitalScope(actor, group.department.hospital.id);
|
||||
}
|
||||
if (actor.role === Role.DIRECTOR) {
|
||||
const actorDepartmentId = this.access.requireActorDepartmentId(actor);
|
||||
if (group.department.id !== actorDepartmentId) {
|
||||
throw new ForbiddenException(MESSAGES.DEFAULT_FORBIDDEN);
|
||||
}
|
||||
}
|
||||
if (actor.role === Role.LEADER) {
|
||||
const actorGroupId = this.access.requireActorGroupId(actor);
|
||||
if (group.id !== actorGroupId) {
|
||||
throw new ForbiddenException(MESSAGES.DEFAULT_FORBIDDEN);
|
||||
}
|
||||
}
|
||||
return group;
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新小组:院管仅可修改本院小组。
|
||||
* 更新小组:院管仅可修改本院;主任仅可修改本科室;组长仅可修改本组。
|
||||
*/
|
||||
async update(actor: ActorContext, id: number, dto: UpdateGroupDto) {
|
||||
const current = await this.findOne(actor, id);
|
||||
@ -121,9 +163,14 @@ export class GroupsService {
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除小组:院管仅可删除本院小组。
|
||||
* 删除小组:院管仅可删除本院;主任仅可删除本科室小组。
|
||||
*/
|
||||
async remove(actor: ActorContext, id: number) {
|
||||
this.access.assertRole(actor, [
|
||||
Role.SYSTEM_ADMIN,
|
||||
Role.HOSPITAL_ADMIN,
|
||||
Role.DIRECTOR,
|
||||
]);
|
||||
const current = await this.findOne(actor, id);
|
||||
try {
|
||||
return await this.prisma.group.delete({ where: { id: current.id } });
|
||||
|
||||
@ -54,7 +54,12 @@ export class HospitalsController {
|
||||
* 查询医院列表(系统管理员全量,院管仅本院)。
|
||||
*/
|
||||
@Get()
|
||||
@Roles(Role.SYSTEM_ADMIN, Role.HOSPITAL_ADMIN)
|
||||
@Roles(
|
||||
Role.SYSTEM_ADMIN,
|
||||
Role.HOSPITAL_ADMIN,
|
||||
Role.DIRECTOR,
|
||||
Role.LEADER,
|
||||
)
|
||||
@ApiOperation({ summary: '查询医院列表' })
|
||||
findAll(
|
||||
@CurrentActor() actor: ActorContext,
|
||||
@ -67,7 +72,12 @@ export class HospitalsController {
|
||||
* 查询医院详情。
|
||||
*/
|
||||
@Get(':id')
|
||||
@Roles(Role.SYSTEM_ADMIN, Role.HOSPITAL_ADMIN)
|
||||
@Roles(
|
||||
Role.SYSTEM_ADMIN,
|
||||
Role.HOSPITAL_ADMIN,
|
||||
Role.DIRECTOR,
|
||||
Role.LEADER,
|
||||
)
|
||||
@ApiOperation({ summary: '查询医院详情' })
|
||||
@ApiParam({ name: 'id', description: '医院 ID' })
|
||||
findOne(
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { Prisma } from '../generated/prisma/client.js';
|
||||
import { Role } from '../generated/prisma/enums.js';
|
||||
import type { ActorContext } from '../common/actor-context.js';
|
||||
@ -32,21 +32,23 @@ export class HospitalsService {
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询医院列表:系统管理员可查全量;院管仅可查看本院。
|
||||
* 查询医院列表:系统管理员可查全量;院管/主任/组长仅可查看本院。
|
||||
*/
|
||||
async findAll(actor: ActorContext, query: OrganizationQueryDto) {
|
||||
this.access.assertRole(actor, [Role.SYSTEM_ADMIN, Role.HOSPITAL_ADMIN]);
|
||||
this.access.assertRole(actor, [
|
||||
Role.SYSTEM_ADMIN,
|
||||
Role.HOSPITAL_ADMIN,
|
||||
Role.DIRECTOR,
|
||||
Role.LEADER,
|
||||
]);
|
||||
const paging = this.access.resolvePaging(query);
|
||||
|
||||
const where: Prisma.HospitalWhereInput = {};
|
||||
if (query.keyword) {
|
||||
where.name = { contains: query.keyword.trim(), mode: 'insensitive' };
|
||||
}
|
||||
if (actor.role === Role.HOSPITAL_ADMIN) {
|
||||
if (!actor.hospitalId) {
|
||||
throw new BadRequestException(MESSAGES.ORG.HOSPITAL_ID_REQUIRED);
|
||||
}
|
||||
where.id = actor.hospitalId ?? undefined;
|
||||
if (actor.role !== Role.SYSTEM_ADMIN) {
|
||||
where.id = this.access.requireActorHospitalId(actor);
|
||||
}
|
||||
|
||||
const [total, list] = await this.prisma.$transaction([
|
||||
@ -63,10 +65,15 @@ export class HospitalsService {
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询医院详情:院管仅能查看本院。
|
||||
* 查询医院详情:院管/主任/组长仅能查看本院。
|
||||
*/
|
||||
async findOne(actor: ActorContext, id: number) {
|
||||
this.access.assertRole(actor, [Role.SYSTEM_ADMIN, Role.HOSPITAL_ADMIN]);
|
||||
this.access.assertRole(actor, [
|
||||
Role.SYSTEM_ADMIN,
|
||||
Role.HOSPITAL_ADMIN,
|
||||
Role.DIRECTOR,
|
||||
Role.LEADER,
|
||||
]);
|
||||
const hospitalId = this.access.toInt(id, MESSAGES.ORG.HOSPITAL_ID_REQUIRED);
|
||||
const hospital = await this.prisma.hospital.findUnique({
|
||||
where: { id: hospitalId },
|
||||
|
||||
@ -39,17 +39,70 @@ export class OrganizationAccessService {
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验院管的数据作用域限制。
|
||||
* 校验组织域医院级作用域限制。
|
||||
*/
|
||||
assertHospitalScope(actor: ActorContext, targetHospitalId: number) {
|
||||
if (actor.role !== Role.HOSPITAL_ADMIN) {
|
||||
if (actor.role === Role.SYSTEM_ADMIN) {
|
||||
return;
|
||||
}
|
||||
if (!actor.hospitalId || actor.hospitalId !== targetHospitalId) {
|
||||
|
||||
const hospitalScopedRoles: Role[] = [
|
||||
Role.HOSPITAL_ADMIN,
|
||||
Role.DIRECTOR,
|
||||
Role.LEADER,
|
||||
];
|
||||
if (!hospitalScopedRoles.includes(actor.role)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const actorHospitalId = this.requireActorHospitalId(actor);
|
||||
if (actorHospitalId !== targetHospitalId) {
|
||||
throw new ForbiddenException(MESSAGES.ORG.HOSPITAL_ADMIN_SCOPE_INVALID);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取并校验当前登录上下文的医院 ID。
|
||||
*/
|
||||
requireActorHospitalId(actor: ActorContext): number {
|
||||
if (
|
||||
typeof actor.hospitalId !== 'number'
|
||||
|| !Number.isInteger(actor.hospitalId)
|
||||
|| actor.hospitalId <= 0
|
||||
) {
|
||||
throw new BadRequestException(MESSAGES.ORG.ACTOR_HOSPITAL_REQUIRED);
|
||||
}
|
||||
return actor.hospitalId;
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取并校验当前登录上下文的科室 ID。
|
||||
*/
|
||||
requireActorDepartmentId(actor: ActorContext): number {
|
||||
if (
|
||||
typeof actor.departmentId !== 'number'
|
||||
|| !Number.isInteger(actor.departmentId)
|
||||
|| actor.departmentId <= 0
|
||||
) {
|
||||
throw new BadRequestException(MESSAGES.ORG.ACTOR_DEPARTMENT_REQUIRED);
|
||||
}
|
||||
return actor.departmentId;
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取并校验当前登录上下文的小组 ID。
|
||||
*/
|
||||
requireActorGroupId(actor: ActorContext): number {
|
||||
if (
|
||||
typeof actor.groupId !== 'number'
|
||||
|| !Number.isInteger(actor.groupId)
|
||||
|| actor.groupId <= 0
|
||||
) {
|
||||
throw new BadRequestException(MESSAGES.ORG.ACTOR_GROUP_REQUIRED);
|
||||
}
|
||||
return actor.groupId;
|
||||
}
|
||||
|
||||
/**
|
||||
* 分页参数标准化。
|
||||
*/
|
||||
|
||||
@ -38,7 +38,7 @@ export class BPatientsController {
|
||||
constructor(private readonly patientsService: BPatientsService) {}
|
||||
|
||||
/**
|
||||
* 查询当前角色可选择的医生列表(用于创建/编辑患者)。
|
||||
* 查询当前角色可选择的归属人员列表(医生/主任/组长)。
|
||||
*/
|
||||
@Get('doctors')
|
||||
@Roles(
|
||||
@ -48,7 +48,7 @@ export class BPatientsController {
|
||||
Role.LEADER,
|
||||
Role.DOCTOR,
|
||||
)
|
||||
@ApiOperation({ summary: '查询当前角色可见医生列表' })
|
||||
@ApiOperation({ summary: '查询当前角色可见归属人员列表' })
|
||||
@ApiQuery({
|
||||
name: 'hospitalId',
|
||||
required: false,
|
||||
|
||||
@ -13,6 +13,8 @@ import { MESSAGES } from '../../common/messages.js';
|
||||
import { CreatePatientDto } from '../dto/create-patient.dto.js';
|
||||
import { UpdatePatientDto } from '../dto/update-patient.dto.js';
|
||||
|
||||
const PATIENT_OWNER_ROLES: Role[] = [Role.DOCTOR, Role.DIRECTOR, Role.LEADER];
|
||||
|
||||
/**
|
||||
* B 端患者服务:承载院内可见性隔离与患者 CRUD。
|
||||
*/
|
||||
@ -39,12 +41,12 @@ export class BPatientsService {
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询当前角色可见医生列表,用于患者表单选择。
|
||||
* 查询当前角色可见归属人员列表(医生/主任/组长),用于患者表单选择。
|
||||
*/
|
||||
async findVisibleDoctors(actor: ActorContext, requestedHospitalId?: number) {
|
||||
const hospitalId = this.resolveHospitalId(actor, requestedHospitalId);
|
||||
const where: Prisma.UserWhereInput = {
|
||||
role: Role.DOCTOR,
|
||||
role: { in: PATIENT_OWNER_ROLES },
|
||||
hospitalId,
|
||||
};
|
||||
|
||||
@ -274,7 +276,7 @@ export class BPatientsService {
|
||||
if (!doctor) {
|
||||
throw new NotFoundException(MESSAGES.PATIENT.DOCTOR_NOT_FOUND);
|
||||
}
|
||||
if (doctor.role !== Role.DOCTOR) {
|
||||
if (!PATIENT_OWNER_ROLES.includes(doctor.role)) {
|
||||
throw new BadRequestException(MESSAGES.PATIENT.DOCTOR_ROLE_REQUIRED);
|
||||
}
|
||||
if (!doctor.hospitalId) {
|
||||
@ -324,7 +326,7 @@ export class BPatientsService {
|
||||
}
|
||||
where.doctor = {
|
||||
groupId: actor.groupId,
|
||||
role: Role.DOCTOR,
|
||||
role: { in: PATIENT_OWNER_ROLES },
|
||||
};
|
||||
break;
|
||||
case Role.DIRECTOR:
|
||||
@ -333,7 +335,7 @@ export class BPatientsService {
|
||||
}
|
||||
where.doctor = {
|
||||
departmentId: actor.departmentId,
|
||||
role: Role.DOCTOR,
|
||||
role: { in: PATIENT_OWNER_ROLES },
|
||||
};
|
||||
break;
|
||||
case Role.HOSPITAL_ADMIN:
|
||||
|
||||
@ -27,7 +27,7 @@ export class CreatePatientDto {
|
||||
@IsString({ message: 'idCardHash 必须是字符串' })
|
||||
idCardHash!: string;
|
||||
|
||||
@ApiProperty({ description: '归属医生 ID', example: 10001 })
|
||||
@ApiProperty({ description: '归属人员 ID(医生/主任/组长)', example: 10001 })
|
||||
@Type(() => Number)
|
||||
@IsInt({ message: 'doctorId 必须是整数' })
|
||||
@Min(1, { message: 'doctorId 必须大于 0' })
|
||||
|
||||
@ -23,11 +23,11 @@ export class BTasksController {
|
||||
constructor(private readonly taskService: TaskService) {}
|
||||
|
||||
/**
|
||||
* 医生发布调压任务。
|
||||
* 医生/主任/组长发布调压任务。
|
||||
*/
|
||||
@Post('publish')
|
||||
@Roles(Role.DOCTOR)
|
||||
@ApiOperation({ summary: '发布任务(DOCTOR)' })
|
||||
@Roles(Role.DOCTOR, Role.DIRECTOR, Role.LEADER)
|
||||
@ApiOperation({ summary: '发布任务(DOCTOR/DIRECTOR/LEADER)' })
|
||||
publish(@CurrentActor() actor: ActorContext, @Body() dto: PublishTaskDto) {
|
||||
return this.taskService.publishTask(actor, dto);
|
||||
}
|
||||
@ -53,11 +53,11 @@ export class BTasksController {
|
||||
}
|
||||
|
||||
/**
|
||||
* 医生取消调压任务。
|
||||
* 医生/主任/组长取消调压任务(仅任务创建者)。
|
||||
*/
|
||||
@Post('cancel')
|
||||
@Roles(Role.DOCTOR)
|
||||
@ApiOperation({ summary: '取消任务(DOCTOR)' })
|
||||
@Roles(Role.DOCTOR, Role.DIRECTOR, Role.LEADER)
|
||||
@ApiOperation({ summary: '取消任务(DOCTOR/DIRECTOR/LEADER)' })
|
||||
cancel(@CurrentActor() actor: ActorContext, @Body() dto: CancelTaskDto) {
|
||||
return this.taskService.cancelTask(actor, dto);
|
||||
}
|
||||
|
||||
@ -26,10 +26,10 @@ export class TaskService {
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 发布任务:医生创建主任务与明细,状态初始化为 PENDING。
|
||||
* 发布任务:医生/主任/组长创建主任务与明细,状态初始化为 PENDING。
|
||||
*/
|
||||
async publishTask(actor: ActorContext, dto: PublishTaskDto) {
|
||||
this.assertRole(actor, [Role.DOCTOR]);
|
||||
this.assertRole(actor, [Role.DOCTOR, Role.DIRECTOR, Role.LEADER]);
|
||||
const hospitalId = this.requireHospitalId(actor);
|
||||
|
||||
if (!Array.isArray(dto.items) || dto.items.length === 0) {
|
||||
@ -214,10 +214,10 @@ export class TaskService {
|
||||
}
|
||||
|
||||
/**
|
||||
* 取消任务:创建医生可将 PENDING/ACCEPTED 任务取消。
|
||||
* 取消任务:任务创建者可将 PENDING/ACCEPTED 任务取消。
|
||||
*/
|
||||
async cancelTask(actor: ActorContext, dto: CancelTaskDto) {
|
||||
this.assertRole(actor, [Role.DOCTOR]);
|
||||
this.assertRole(actor, [Role.DOCTOR, Role.DIRECTOR, Role.LEADER]);
|
||||
const hospitalId = this.requireHospitalId(actor);
|
||||
|
||||
const task = await this.prisma.task.findFirst({
|
||||
|
||||
@ -21,6 +21,8 @@ import { Role } from '../generated/prisma/enums.js';
|
||||
import { UsersService } from './users.service.js';
|
||||
import { CreateUserDto } from './dto/create-user.dto.js';
|
||||
import { UpdateUserDto } from './dto/update-user.dto.js';
|
||||
import { CurrentActor } from '../auth/current-actor.decorator.js';
|
||||
import type { ActorContext } from '../common/actor-context.js';
|
||||
|
||||
/**
|
||||
* 用户管理控制器:面向 B 端后台的用户 CRUD。
|
||||
@ -46,10 +48,15 @@ export class UsersController {
|
||||
* 查询用户列表。
|
||||
*/
|
||||
@Get()
|
||||
@Roles(Role.SYSTEM_ADMIN, Role.HOSPITAL_ADMIN)
|
||||
@Roles(
|
||||
Role.SYSTEM_ADMIN,
|
||||
Role.HOSPITAL_ADMIN,
|
||||
Role.DIRECTOR,
|
||||
Role.LEADER,
|
||||
)
|
||||
@ApiOperation({ summary: '查询用户列表' })
|
||||
findAll() {
|
||||
return this.usersService.findAll();
|
||||
findAll(@CurrentActor() actor: ActorContext) {
|
||||
return this.usersService.findAll(actor);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -177,10 +177,31 @@ export class UsersService {
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询用户列表。
|
||||
* 查询用户列表:按角色自动收敛可见范围。
|
||||
*/
|
||||
async findAll() {
|
||||
async findAll(actor: ActorContext) {
|
||||
const where: Prisma.UserWhereInput = {};
|
||||
|
||||
if (actor.role === Role.HOSPITAL_ADMIN) {
|
||||
where.hospitalId = this.requireActorScopeInt(
|
||||
actor.hospitalId,
|
||||
MESSAGES.ORG.ACTOR_HOSPITAL_REQUIRED,
|
||||
);
|
||||
} else if (actor.role === Role.DIRECTOR || actor.role === Role.LEADER) {
|
||||
where.hospitalId = this.requireActorScopeInt(
|
||||
actor.hospitalId,
|
||||
MESSAGES.ORG.ACTOR_HOSPITAL_REQUIRED,
|
||||
);
|
||||
where.departmentId = this.requireActorScopeInt(
|
||||
actor.departmentId,
|
||||
MESSAGES.ORG.ACTOR_DEPARTMENT_REQUIRED,
|
||||
);
|
||||
} else if (actor.role !== Role.SYSTEM_ADMIN) {
|
||||
throw new ForbiddenException(MESSAGES.DEFAULT_FORBIDDEN);
|
||||
}
|
||||
|
||||
return this.prisma.user.findMany({
|
||||
where,
|
||||
select: SAFE_USER_SELECT,
|
||||
orderBy: { id: 'desc' },
|
||||
});
|
||||
@ -520,6 +541,16 @@ export class UsersService {
|
||||
return parsed;
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 actor 上下文读取必填范围字段(医院/科室等)。
|
||||
*/
|
||||
private requireActorScopeInt(value: unknown, message: string): number {
|
||||
if (typeof value !== 'number' || !Number.isInteger(value) || value <= 0) {
|
||||
throw new BadRequestException(message);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* 可空整数标准化。
|
||||
*/
|
||||
|
||||
37
tyt-admin/src/constants/role-permissions.js
Normal file
37
tyt-admin/src/constants/role-permissions.js
Normal file
@ -0,0 +1,37 @@
|
||||
/**
|
||||
* 前端页面级权限矩阵:与后端 @Roles 约束保持一致,避免页面可见但接口被 403。
|
||||
*/
|
||||
const ADMIN_ROLES = Object.freeze(['SYSTEM_ADMIN', 'HOSPITAL_ADMIN']);
|
||||
const ORG_MANAGER_ROLES = Object.freeze([
|
||||
'SYSTEM_ADMIN',
|
||||
'HOSPITAL_ADMIN',
|
||||
'DIRECTOR',
|
||||
'LEADER',
|
||||
]);
|
||||
const TASK_ROLES = Object.freeze(['DOCTOR', 'DIRECTOR', 'LEADER', 'ENGINEER']);
|
||||
const PATIENT_ROLES = Object.freeze([
|
||||
'SYSTEM_ADMIN',
|
||||
'HOSPITAL_ADMIN',
|
||||
'DIRECTOR',
|
||||
'LEADER',
|
||||
]);
|
||||
|
||||
export const ROLE_PERMISSIONS = Object.freeze({
|
||||
ORG_TREE: ORG_MANAGER_ROLES,
|
||||
ORG_HOSPITALS: Object.freeze(['SYSTEM_ADMIN']),
|
||||
ORG_DEPARTMENTS: ORG_MANAGER_ROLES,
|
||||
ORG_GROUPS: ORG_MANAGER_ROLES,
|
||||
USERS: ADMIN_ROLES,
|
||||
TASKS: TASK_ROLES,
|
||||
PATIENTS: PATIENT_ROLES,
|
||||
});
|
||||
|
||||
/**
|
||||
* 判断当前角色是否拥有页面访问权限。
|
||||
*/
|
||||
export function hasRolePermission(role, allowedRoles) {
|
||||
if (!Array.isArray(allowedRoles) || allowedRoles.length === 0) {
|
||||
return true;
|
||||
}
|
||||
return Boolean(role) && allowedRoles.includes(role);
|
||||
}
|
||||
@ -27,7 +27,7 @@
|
||||
<el-menu-item index="/organization/groups">小组管理</el-menu-item>
|
||||
</el-sub-menu>
|
||||
</template>
|
||||
<template v-else>
|
||||
<template v-else-if="canAccessOrgTree">
|
||||
<el-menu-item index="/organization/tree">
|
||||
<el-icon><Share /></el-icon>
|
||||
<span>组织架构图</span>
|
||||
@ -42,17 +42,17 @@
|
||||
</el-menu-item>
|
||||
</template>
|
||||
|
||||
<el-menu-item index="/users">
|
||||
<el-menu-item v-if="canAccessUsers" index="/users">
|
||||
<el-icon><User /></el-icon>
|
||||
<span>用户管理</span>
|
||||
</el-menu-item>
|
||||
|
||||
<el-menu-item index="/tasks">
|
||||
<el-menu-item v-if="canAccessTasks" index="/tasks">
|
||||
<el-icon><List /></el-icon>
|
||||
<span>任务管理</span>
|
||||
</el-menu-item>
|
||||
|
||||
<el-menu-item index="/patients">
|
||||
<el-menu-item v-if="canAccessPatients" index="/patients">
|
||||
<el-icon><Avatar /></el-icon>
|
||||
<span>患者管理</span>
|
||||
</el-menu-item>
|
||||
@ -96,6 +96,10 @@
|
||||
import { computed } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { useUserStore } from '../store/user';
|
||||
import {
|
||||
ROLE_PERMISSIONS,
|
||||
hasRolePermission,
|
||||
} from '../constants/role-permissions';
|
||||
import { DataLine, OfficeBuilding, User, List, Avatar, ArrowDown, Connection, Share } from '@element-plus/icons-vue';
|
||||
|
||||
const route = useRoute();
|
||||
@ -106,6 +110,19 @@ const activeMenu = computed(() => {
|
||||
return route.path;
|
||||
});
|
||||
|
||||
const canAccessUsers = computed(() =>
|
||||
hasRolePermission(userStore.role, ROLE_PERMISSIONS.USERS),
|
||||
);
|
||||
const canAccessOrgTree = computed(() =>
|
||||
hasRolePermission(userStore.role, ROLE_PERMISSIONS.ORG_TREE),
|
||||
);
|
||||
const canAccessTasks = computed(() =>
|
||||
hasRolePermission(userStore.role, ROLE_PERMISSIONS.TASKS),
|
||||
);
|
||||
const canAccessPatients = computed(() =>
|
||||
hasRolePermission(userStore.role, ROLE_PERMISSIONS.PATIENTS),
|
||||
);
|
||||
|
||||
const handleCommand = (command) => {
|
||||
if (command === 'logout') {
|
||||
userStore.logout();
|
||||
|
||||
@ -1,7 +1,12 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router';
|
||||
import NProgress from 'nprogress';
|
||||
import 'nprogress/nprogress.css';
|
||||
import { ElMessage } from 'element-plus';
|
||||
import { useUserStore } from '../store/user';
|
||||
import {
|
||||
ROLE_PERMISSIONS,
|
||||
hasRolePermission,
|
||||
} from '../constants/role-permissions';
|
||||
|
||||
const routes = [
|
||||
{
|
||||
@ -26,43 +31,71 @@ const routes = [
|
||||
path: 'organization/tree',
|
||||
name: 'OrgTree',
|
||||
component: () => import('../views/organization/OrgTree.vue'),
|
||||
meta: { title: '组织架构图', requiresAuth: true },
|
||||
meta: {
|
||||
title: '组织架构图',
|
||||
requiresAuth: true,
|
||||
allowedRoles: ROLE_PERMISSIONS.ORG_TREE,
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'organization/hospitals',
|
||||
name: 'Hospitals',
|
||||
component: () => import('../views/organization/Hospitals.vue'),
|
||||
meta: { title: '医院管理', requiresAuth: true },
|
||||
meta: {
|
||||
title: '医院管理',
|
||||
requiresAuth: true,
|
||||
allowedRoles: ROLE_PERMISSIONS.ORG_HOSPITALS,
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'organization/departments',
|
||||
name: 'Departments',
|
||||
component: () => import('../views/organization/Departments.vue'),
|
||||
meta: { title: '科室管理', requiresAuth: true },
|
||||
meta: {
|
||||
title: '科室管理',
|
||||
requiresAuth: true,
|
||||
allowedRoles: ROLE_PERMISSIONS.ORG_DEPARTMENTS,
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'organization/groups',
|
||||
name: 'Groups',
|
||||
component: () => import('../views/organization/Groups.vue'),
|
||||
meta: { title: '小组管理', requiresAuth: true },
|
||||
meta: {
|
||||
title: '小组管理',
|
||||
requiresAuth: true,
|
||||
allowedRoles: ROLE_PERMISSIONS.ORG_GROUPS,
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'users',
|
||||
name: 'Users',
|
||||
component: () => import('../views/users/Users.vue'),
|
||||
meta: { title: '用户管理', requiresAuth: true },
|
||||
meta: {
|
||||
title: '用户管理',
|
||||
requiresAuth: true,
|
||||
allowedRoles: ROLE_PERMISSIONS.USERS,
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'tasks',
|
||||
name: 'Tasks',
|
||||
component: () => import('../views/tasks/Tasks.vue'),
|
||||
meta: { title: '任务管理', requiresAuth: true },
|
||||
meta: {
|
||||
title: '任务管理',
|
||||
requiresAuth: true,
|
||||
allowedRoles: ROLE_PERMISSIONS.TASKS,
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'patients',
|
||||
name: 'Patients',
|
||||
component: () => import('../views/patients/Patients.vue'),
|
||||
meta: { title: '患者管理', requiresAuth: true },
|
||||
meta: {
|
||||
title: '患者管理',
|
||||
requiresAuth: true,
|
||||
allowedRoles: ROLE_PERMISSIONS.PATIENTS,
|
||||
},
|
||||
}
|
||||
],
|
||||
},
|
||||
@ -87,21 +120,31 @@ router.beforeEach(async (to, from) => {
|
||||
|
||||
if (to.meta.requiresAuth && !isLoggedIn) {
|
||||
return `/login?redirect=${encodeURIComponent(to.fullPath)}`;
|
||||
} else if (to.path === '/login' && isLoggedIn) {
|
||||
}
|
||||
|
||||
if (to.path === '/login' && isLoggedIn) {
|
||||
return '/';
|
||||
} else {
|
||||
// Attempt to fetch user info if not present but token exists
|
||||
}
|
||||
|
||||
// token 存在但内存里无用户信息时,先补拉当前用户上下文。
|
||||
if (isLoggedIn && !userStore.userInfo) {
|
||||
try {
|
||||
await userStore.fetchUserInfo();
|
||||
return true;
|
||||
} catch (error) {
|
||||
return '/login';
|
||||
}
|
||||
} else {
|
||||
}
|
||||
|
||||
// 页面级权限校验:无权限时拦截并回到首页,避免进入页面后接口再报 403。
|
||||
if (to.meta.requiresAuth) {
|
||||
const allowedRoles = to.meta.allowedRoles;
|
||||
if (!hasRolePermission(userStore.role, allowedRoles)) {
|
||||
ElMessage.error('当前角色无权限访问该页面');
|
||||
return '/dashboard';
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
router.afterEach(() => {
|
||||
|
||||
@ -27,7 +27,7 @@
|
||||
<el-form-item>
|
||||
<el-button type="primary" @click="fetchData" icon="Search">查询</el-button>
|
||||
<el-button @click="resetSearch" icon="Refresh">重置</el-button>
|
||||
<el-button type="success" @click="openCreateDialog" icon="Plus">新增科室</el-button>
|
||||
<el-button v-if="canCreateDepartment" type="success" @click="openCreateDialog" icon="Plus">新增科室</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</div>
|
||||
@ -50,8 +50,8 @@
|
||||
<el-table-column label="操作" width="220" fixed="right" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-button size="small" @click="goToGroups(row)">管理小组</el-button>
|
||||
<el-button size="small" type="primary" @click="openEditDialog(row)">编辑</el-button>
|
||||
<el-button size="small" type="danger" @click="handleDelete(row)">删除</el-button>
|
||||
<el-button v-if="canEditDepartment" size="small" type="primary" @click="openEditDialog(row)">编辑</el-button>
|
||||
<el-button v-if="canDeleteDepartment" size="small" type="danger" @click="handleDelete(row)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
@ -148,6 +148,15 @@ const rules = computed(() => ({
|
||||
hospitalId: userStore.role === 'SYSTEM_ADMIN' ? [{ required: true, message: '请选择所属医院', trigger: 'change' }] : [],
|
||||
name: [{ required: true, message: '请输入科室名称', trigger: 'blur' }]
|
||||
}));
|
||||
const canCreateDepartment = computed(() =>
|
||||
['SYSTEM_ADMIN', 'HOSPITAL_ADMIN'].includes(userStore.role),
|
||||
);
|
||||
const canEditDepartment = computed(() =>
|
||||
['SYSTEM_ADMIN', 'HOSPITAL_ADMIN', 'DIRECTOR', 'LEADER'].includes(userStore.role),
|
||||
);
|
||||
const canDeleteDepartment = computed(() =>
|
||||
['SYSTEM_ADMIN', 'HOSPITAL_ADMIN'].includes(userStore.role),
|
||||
);
|
||||
|
||||
// --- Methods ---
|
||||
const fetchHospitals = async () => {
|
||||
|
||||
@ -37,7 +37,7 @@
|
||||
<el-form-item>
|
||||
<el-button type="primary" @click="fetchData" icon="Search">查询</el-button>
|
||||
<el-button @click="resetSearch" icon="Refresh">重置</el-button>
|
||||
<el-button type="success" @click="openCreateDialog" icon="Plus">新增小组</el-button>
|
||||
<el-button v-if="canCreateGroup" type="success" @click="openCreateDialog" icon="Plus">新增小组</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</div>
|
||||
@ -60,8 +60,8 @@
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="180" fixed="right" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-button size="small" type="primary" @click="openEditDialog(row)">编辑</el-button>
|
||||
<el-button size="small" type="danger" @click="handleDelete(row)">删除</el-button>
|
||||
<el-button v-if="canEditGroup" size="small" type="primary" @click="openEditDialog(row)">编辑</el-button>
|
||||
<el-button v-if="canDeleteGroup" size="small" type="danger" @click="handleDelete(row)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
@ -178,6 +178,15 @@ const rules = computed(() => ({
|
||||
departmentId: [{ required: true, message: '请选择所属科室', trigger: 'change' }],
|
||||
name: [{ required: true, message: '请输入小组名称', trigger: 'blur' }]
|
||||
}));
|
||||
const canCreateGroup = computed(() =>
|
||||
['SYSTEM_ADMIN', 'HOSPITAL_ADMIN', 'DIRECTOR'].includes(userStore.role),
|
||||
);
|
||||
const canEditGroup = computed(() =>
|
||||
['SYSTEM_ADMIN', 'HOSPITAL_ADMIN', 'DIRECTOR', 'LEADER'].includes(userStore.role),
|
||||
);
|
||||
const canDeleteGroup = computed(() =>
|
||||
['SYSTEM_ADMIN', 'HOSPITAL_ADMIN', 'DIRECTOR'].includes(userStore.role),
|
||||
);
|
||||
|
||||
// --- Methods ---
|
||||
const fetchHospitals = async () => {
|
||||
|
||||
@ -62,10 +62,10 @@
|
||||
>
|
||||
{{ data.type === 'department' ? '设主任' : '设组长' }}
|
||||
</el-button>
|
||||
<el-button type="info" link size="small" @click.stop="openEditDialog(data)" icon="EditPen">
|
||||
<el-button v-if="canEditNode(data)" type="info" link size="small" @click.stop="openEditDialog(data)" icon="EditPen">
|
||||
编辑
|
||||
</el-button>
|
||||
<el-button v-if="(data.type === 'hospital' && userStore.role === 'SYSTEM_ADMIN') || data.type !== 'hospital'" type="danger" link size="small" @click.stop="handleDelete(data)" icon="Delete">
|
||||
<el-button v-if="canDeleteNode(data)" type="danger" link size="small" @click.stop="handleDelete(data)" icon="Delete">
|
||||
删除
|
||||
</el-button>
|
||||
</div>
|
||||
@ -84,11 +84,11 @@
|
||||
<div class="card-header">
|
||||
<span class="header-title">
|
||||
<el-icon><Menu /></el-icon>
|
||||
{{ activeNode ? `下级列表 (${activeNode.name})` : '请在左侧选择节点' }}
|
||||
{{ activePanelTitle }}
|
||||
</span>
|
||||
<div v-if="activeNode && activeNode.type !== 'user'" class="header-actions">
|
||||
<el-button
|
||||
v-if="activeNode.type === 'hospital' && userStore.role === 'SYSTEM_ADMIN'"
|
||||
v-if="canCreateDepartment(activeNode)"
|
||||
type="primary"
|
||||
size="small"
|
||||
icon="Plus"
|
||||
@ -97,7 +97,7 @@
|
||||
新增科室
|
||||
</el-button>
|
||||
<el-button
|
||||
v-if="activeNode.type === 'department'"
|
||||
v-if="canCreateGroup(activeNode)"
|
||||
type="success"
|
||||
size="small"
|
||||
icon="Plus"
|
||||
@ -106,7 +106,7 @@
|
||||
新增小组
|
||||
</el-button>
|
||||
<el-button
|
||||
v-if="activeNode.type === 'department' || activeNode.type === 'group'"
|
||||
v-if="canAddUser(activeNode)"
|
||||
type="warning"
|
||||
size="small"
|
||||
icon="User"
|
||||
@ -134,7 +134,7 @@
|
||||
:closable="false"
|
||||
class="mb-12"
|
||||
/>
|
||||
<el-table :data="activeNode.children" border stripe style="width: 100%" max-height="600">
|
||||
<el-table :data="activeNodeChildren" border stripe style="width: 100%" max-height="600">
|
||||
<el-table-column prop="name" label="名称" />
|
||||
<el-table-column label="类型" width="100" align="center">
|
||||
<template #default="{ row }">
|
||||
@ -165,14 +165,29 @@
|
||||
>
|
||||
{{ row.type === 'department' ? '设主任' : '设组长' }}
|
||||
</el-button>
|
||||
<el-button type="primary" link size="small" @click="openEditDialog(row)">编辑</el-button>
|
||||
<el-button v-if="row.type !== 'hospital' || userStore.role === 'SYSTEM_ADMIN'" type="danger" link size="small" @click="handleDelete(row)">删除</el-button>
|
||||
<el-button v-if="canEditNode(row)" type="primary" link size="small" @click="openEditDialog(row)">编辑</el-button>
|
||||
<el-button v-if="canDeleteNode(row)" type="danger" link size="small" @click="handleDelete(row)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
|
||||
<el-empty v-else-if="activeNode && activeNode.type === 'user'" description="人员节点无下级列表" />
|
||||
<div v-else-if="selectedUserDetail" class="user-detail-panel">
|
||||
<el-descriptions :column="2" border>
|
||||
<el-descriptions-item label="姓名">{{ selectedUserDetail.name || '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="角色">{{ getRoleName(selectedUserDetail.role) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="手机号">{{ selectedUserDetail.phone || '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="医院">{{ selectedUserDetail.hospitalName || '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="科室">{{ selectedUserDetail.departmentName || '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="小组">{{ selectedUserDetail.groupName || '-' }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
<el-alert
|
||||
title="如需修改人员角色或组织归属,请前往“用户管理”页面操作。"
|
||||
type="info"
|
||||
:closable="false"
|
||||
class="mt-12"
|
||||
/>
|
||||
</div>
|
||||
<el-empty v-else description="点击左侧架构树查看下级列表" />
|
||||
</el-card>
|
||||
</el-col>
|
||||
@ -249,6 +264,9 @@ const loading = ref(false);
|
||||
const treeData = ref([]);
|
||||
const activeNode = ref(null);
|
||||
const allUsers = ref([]);
|
||||
const hospitalNameMap = ref({});
|
||||
const departmentNameMap = ref({});
|
||||
const groupNameMap = ref({});
|
||||
const ownerCandidates = ref([]);
|
||||
const ownerDialogVisible = ref(false);
|
||||
const ownerSubmitLoading = ref(false);
|
||||
@ -292,6 +310,115 @@ const getNodeTypeTag = (type) => {
|
||||
const canAssignOwner = computed(() =>
|
||||
['SYSTEM_ADMIN', 'HOSPITAL_ADMIN'].includes(userStore.role),
|
||||
);
|
||||
const isSystemAdmin = computed(() => userStore.role === 'SYSTEM_ADMIN');
|
||||
const isOrgAdmin = computed(() =>
|
||||
['SYSTEM_ADMIN', 'HOSPITAL_ADMIN'].includes(userStore.role),
|
||||
);
|
||||
const isDirector = computed(() => userStore.role === 'DIRECTOR');
|
||||
const isLeader = computed(() => userStore.role === 'LEADER');
|
||||
|
||||
const canCreateDepartment = (node) =>
|
||||
Boolean(node && node.type === 'hospital' && isOrgAdmin.value);
|
||||
|
||||
const canCreateGroup = (node) =>
|
||||
Boolean(
|
||||
node
|
||||
&& node.type === 'department'
|
||||
&& (isOrgAdmin.value || isDirector.value),
|
||||
);
|
||||
|
||||
const canAddUser = (node) =>
|
||||
Boolean(
|
||||
node
|
||||
&& (node.type === 'department' || node.type === 'group')
|
||||
&& isOrgAdmin.value,
|
||||
);
|
||||
|
||||
const canEditNode = (node) => {
|
||||
if (!node || node.type === 'user') {
|
||||
return false;
|
||||
}
|
||||
if (node.type === 'hospital') {
|
||||
return isOrgAdmin.value;
|
||||
}
|
||||
if (node.type === 'department') {
|
||||
return isOrgAdmin.value || isDirector.value || isLeader.value;
|
||||
}
|
||||
if (node.type === 'group') {
|
||||
return isOrgAdmin.value || isDirector.value || isLeader.value;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const canDeleteNode = (node) => {
|
||||
if (!node || node.type === 'user') {
|
||||
return false;
|
||||
}
|
||||
if (node.type === 'hospital') {
|
||||
return isSystemAdmin.value;
|
||||
}
|
||||
if (node.type === 'department') {
|
||||
return isOrgAdmin.value;
|
||||
}
|
||||
if (node.type === 'group') {
|
||||
return isOrgAdmin.value || isDirector.value;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const activePanelTitle = computed(() => {
|
||||
if (!activeNode.value) {
|
||||
return '请在左侧选择节点';
|
||||
}
|
||||
if (activeNode.value.type === 'user') {
|
||||
return `人员详情 (${activeNode.value.name})`;
|
||||
}
|
||||
return `下级列表 (${activeNode.value.name})`;
|
||||
});
|
||||
|
||||
const activeNodeChildren = computed(() => {
|
||||
if (
|
||||
!activeNode.value
|
||||
|| activeNode.value.type === 'user'
|
||||
|| !Array.isArray(activeNode.value.children)
|
||||
) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const list = [...activeNode.value.children];
|
||||
if (userStore.role !== 'HOSPITAL_ADMIN') {
|
||||
return list;
|
||||
}
|
||||
|
||||
// 医院管理员视角下,右侧列表优先显示人员,再显示组织节点。
|
||||
return list.sort((a, b) => {
|
||||
const aIsUser = a.type === 'user';
|
||||
const bIsUser = b.type === 'user';
|
||||
if (aIsUser !== bIsUser) {
|
||||
return aIsUser ? -1 : 1;
|
||||
}
|
||||
return `${a.name || ''}`.localeCompare(`${b.name || ''}`, 'zh-Hans-CN');
|
||||
});
|
||||
});
|
||||
|
||||
const selectedUserDetail = computed(() => {
|
||||
if (!activeNode.value || activeNode.value.type !== 'user') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const current = allUsers.value.find((user) => user.id === activeNode.value.id);
|
||||
const userData = current || activeNode.value;
|
||||
const hospitalId = userData.hospitalId || null;
|
||||
const departmentId = userData.departmentId || null;
|
||||
const groupId = userData.groupId || null;
|
||||
|
||||
return {
|
||||
...userData,
|
||||
hospitalName: hospitalId ? hospitalNameMap.value[hospitalId] : '',
|
||||
departmentName: departmentId ? departmentNameMap.value[departmentId] : '',
|
||||
groupName: groupId ? groupNameMap.value[groupId] : '',
|
||||
};
|
||||
});
|
||||
|
||||
const activeNodeMeta = computed(() => {
|
||||
if (!activeNode.value) {
|
||||
@ -339,6 +466,15 @@ const fetchTreeData = async () => {
|
||||
const groups = groupRes.list || [];
|
||||
const users = userRes.list || [];
|
||||
allUsers.value = users;
|
||||
hospitalNameMap.value = Object.fromEntries(
|
||||
hospitals.map((item) => [item.id, item.name]),
|
||||
);
|
||||
departmentNameMap.value = Object.fromEntries(
|
||||
departments.map((item) => [item.id, item.name]),
|
||||
);
|
||||
groupNameMap.value = Object.fromEntries(
|
||||
groups.map((item) => [item.id, item.name]),
|
||||
);
|
||||
|
||||
const directorNameMap = {};
|
||||
const leaderNameMap = {};
|
||||
@ -618,6 +754,10 @@ onMounted(() => { fetchTreeData(); });
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.user-detail-panel {
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.action-title {
|
||||
margin-top: 20px;
|
||||
margin-bottom: 15px;
|
||||
@ -717,6 +857,10 @@ onMounted(() => { fetchTreeData(); });
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.mt-12 {
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
/* Hover Actions */
|
||||
.node-actions {
|
||||
opacity: 0;
|
||||
|
||||
@ -62,7 +62,7 @@
|
||||
{{ row.hospital?.name || '-' }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="归属医生" min-width="140">
|
||||
<el-table-column label="归属人员" min-width="140">
|
||||
<template #default="{ row }">
|
||||
{{ row.doctor?.name || '-' }}
|
||||
</template>
|
||||
@ -122,18 +122,18 @@
|
||||
<el-form-item label="证件哈希" prop="idCardHash">
|
||||
<el-input v-model="form.idCardHash" placeholder="请输入证件哈希" />
|
||||
</el-form-item>
|
||||
<el-form-item label="归属医生" prop="doctorId">
|
||||
<el-form-item label="归属人员" prop="doctorId">
|
||||
<el-select
|
||||
v-model="form.doctorId"
|
||||
filterable
|
||||
placeholder="请选择归属医生"
|
||||
placeholder="请选择归属人员(医生/主任/组长)"
|
||||
style="width: 100%;"
|
||||
:disabled="userStore.role === 'DOCTOR'"
|
||||
>
|
||||
<el-option
|
||||
v-for="doctor in doctorOptions"
|
||||
:key="doctor.id"
|
||||
:label="`${doctor.name}(${doctor.phone})`"
|
||||
:label="`${doctor.name}(${getRoleName(doctor.role)} / ${doctor.phone})`"
|
||||
:value="doctor.id"
|
||||
/>
|
||||
</el-select>
|
||||
@ -215,6 +215,14 @@ import { useUserStore } from '../../store/user';
|
||||
|
||||
const userStore = useUserStore();
|
||||
|
||||
const roleMap = {
|
||||
DIRECTOR: '科室主任',
|
||||
LEADER: '医疗组长',
|
||||
DOCTOR: '医生',
|
||||
};
|
||||
|
||||
const getRoleName = (role) => roleMap[role] || role;
|
||||
|
||||
const loading = ref(false);
|
||||
const allPatients = ref([]);
|
||||
const tableData = ref([]);
|
||||
@ -250,7 +258,7 @@ const rules = {
|
||||
{ pattern: /^1\d{10}$/, message: '请输入正确的手机号', trigger: 'blur' },
|
||||
],
|
||||
idCardHash: [{ required: true, message: '请输入证件哈希', trigger: 'blur' }],
|
||||
doctorId: [{ required: true, message: '请选择归属医生', trigger: 'change' }],
|
||||
doctorId: [{ required: true, message: '请选择归属人员', trigger: 'change' }],
|
||||
};
|
||||
|
||||
const recordDialogVisible = ref(false);
|
||||
|
||||
@ -5,7 +5,7 @@
|
||||
<el-card v-if="canPublishOrCancel" shadow="never">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span>发布任务(DOCTOR)</span>
|
||||
<span>发布任务(DOCTOR/DIRECTOR/LEADER)</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@ -68,7 +68,7 @@
|
||||
|
||||
<el-card v-if="canPublishOrCancel" shadow="never" class="mt-16">
|
||||
<template #header>
|
||||
<span>取消任务(DOCTOR)</span>
|
||||
<span>取消任务(DOCTOR/DIRECTOR/LEADER)</span>
|
||||
</template>
|
||||
<el-form :model="cancelForm" label-width="100px">
|
||||
<el-form-item label="任务 ID">
|
||||
@ -175,7 +175,7 @@
|
||||
v-if="!canPublishOrCancel && !canAcceptOrComplete"
|
||||
type="warning"
|
||||
:closable="false"
|
||||
title="当前角色没有任务状态流转权限。仅医生可发布/取消,工程师可接收/完成。"
|
||||
title="当前角色没有任务状态流转权限。医生/主任/组长可发布/取消,工程师可接收/完成。"
|
||||
class="mt-16"
|
||||
/>
|
||||
</div>
|
||||
@ -194,7 +194,8 @@ import {
|
||||
|
||||
const userStore = useUserStore();
|
||||
|
||||
const canPublishOrCancel = computed(() => userStore.role === 'DOCTOR');
|
||||
const PUBLISH_ROLES = ['DOCTOR', 'DIRECTOR', 'LEADER'];
|
||||
const canPublishOrCancel = computed(() => PUBLISH_ROLES.includes(userStore.role));
|
||||
const canAcceptOrComplete = computed(() => userStore.role === 'ENGINEER');
|
||||
|
||||
const loading = reactive({
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user