更新权限

This commit is contained in:
EL 2026-03-13 13:23:59 +08:00
parent 2275607bd2
commit 602694814f
27 changed files with 645 additions and 130 deletions

View File

@ -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` 目录执行:

View File

@ -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`:更新患者

View File

@ -14,7 +14,7 @@
## 3. 角色权限
- 医生:发布任务、取消自己创建的任务
- 医生/主任/组长:发布任务、取消自己创建的任务
- 工程师:接收任务、完成自己接收的任务
- 其他角色:默认拒绝

View File

@ -18,6 +18,7 @@
- 医院内数据按 `hospitalId` 强隔离。
- 仅 `SYSTEM_ADMIN` 可执行工程师绑定医院。
- `DIRECTOR/LEADER` 可读取用户列表,但仅返回当前科室可见用户。
- 用户组织字段校验:
- 院管/医生/工程师等需有医院归属;
- 主任/组长需有科室/小组等必要归属;

View File

@ -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: '医院名称不能为空',

View File

@ -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,

View File

@ -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);
}
this.access.assertHospitalScope(actor, department.hospitalId);
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 } });

View File

@ -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,

View File

@ -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);
this.access.assertHospitalScope(actor, department.hospitalId);
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);
}
this.access.assertHospitalScope(actor, group.department.hospital.id);
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 } });

View File

@ -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(

View File

@ -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 },

View File

@ -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;
}
/**
*
*/

View File

@ -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,

View File

@ -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:

View File

@ -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' })

View File

@ -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);
}

View File

@ -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({

View File

@ -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);
}
/**

View File

@ -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;
}
/**
*
*/

View 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);
}

View File

@ -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();

View File

@ -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
if (isLoggedIn && !userStore.userInfo) {
try {
await userStore.fetchUserInfo();
return true;
} catch (error) {
return '/login';
}
} else {
return true;
}
// token 存在但内存里无用户信息时,先补拉当前用户上下文。
if (isLoggedIn && !userStore.userInfo) {
try {
await userStore.fetchUserInfo();
} catch (error) {
return '/login';
}
}
// 页面级权限校验:无权限时拦截并回到首页,避免进入页面后接口再报 403。
if (to.meta.requiresAuth) {
const allowedRoles = to.meta.allowedRoles;
if (!hasRolePermission(userStore.role, allowedRoles)) {
ElMessage.error('当前角色无权限访问该页面');
return '/dashboard';
}
}
return true;
});
router.afterEach(() => {

View File

@ -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 () => {

View File

@ -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 () => {

View File

@ -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;

View File

@ -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);

View File

@ -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({