From 602694814f020ef463def20f04de5f2162745e9e Mon Sep 17 00:00:00 2001 From: EL <1175065040@qq.com> Date: Fri, 13 Mar 2026 13:23:59 +0800 Subject: [PATCH] =?UTF-8?q?=E6=9B=B4=E6=96=B0=E6=9D=83=E9=99=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/frontend-api-integration.md | 32 +++- docs/patients.md | 2 +- docs/tasks.md | 2 +- docs/users.md | 1 + src/common/messages.ts | 11 +- src/departments/departments.controller.ts | 21 ++- src/departments/departments.service.ts | 54 ++++-- src/groups/groups.controller.ts | 25 ++- src/groups/groups.service.ts | 77 ++++++-- src/hospitals/hospitals.controller.ts | 14 +- src/hospitals/hospitals.service.ts | 27 +-- .../organization-access.service.ts | 59 ++++++- .../b-patients/b-patients.controller.ts | 4 +- src/patients/b-patients/b-patients.service.ts | 12 +- src/patients/dto/create-patient.dto.ts | 2 +- src/tasks/b-tasks/b-tasks.controller.ts | 12 +- src/tasks/task.service.ts | 8 +- src/users/users.controller.ts | 13 +- src/users/users.service.ts | 35 +++- tyt-admin/src/constants/role-permissions.js | 37 ++++ tyt-admin/src/layouts/AdminLayout.vue | 25 ++- tyt-admin/src/router/index.js | 81 +++++++-- .../src/views/organization/Departments.vue | 15 +- tyt-admin/src/views/organization/Groups.vue | 15 +- tyt-admin/src/views/organization/OrgTree.vue | 164 ++++++++++++++++-- tyt-admin/src/views/patients/Patients.vue | 18 +- tyt-admin/src/views/tasks/Tasks.vue | 9 +- 27 files changed, 645 insertions(+), 130 deletions(-) create mode 100644 tyt-admin/src/constants/role-permissions.js diff --git a/docs/frontend-api-integration.md b/docs/frontend-api-integration.md index 14481e4..0626cc5 100644 --- a/docs/frontend-api-integration.md +++ b/docs/frontend-api-integration.md @@ -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` 目录执行: diff --git a/docs/patients.md b/docs/patients.md index 6371770..bfdc3a9 100644 --- a/docs/patients.md +++ b/docs/patients.md @@ -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`:更新患者 diff --git a/docs/tasks.md b/docs/tasks.md index 7139888..3079186 100644 --- a/docs/tasks.md +++ b/docs/tasks.md @@ -14,7 +14,7 @@ ## 3. 角色权限 -- 医生:发布任务、取消自己创建的任务 +- 医生/主任/组长:发布任务、取消自己创建的任务 - 工程师:接收任务、完成自己接收的任务 - 其他角色:默认拒绝 diff --git a/docs/users.md b/docs/users.md index 09b343e..dd331ee 100644 --- a/docs/users.md +++ b/docs/users.md @@ -18,6 +18,7 @@ - 医院内数据按 `hospitalId` 强隔离。 - 仅 `SYSTEM_ADMIN` 可执行工程师绑定医院。 +- `DIRECTOR/LEADER` 可读取用户列表,但仅返回当前科室可见用户。 - 用户组织字段校验: - 院管/医生/工程师等需有医院归属; - 主任/组长需有科室/小组等必要归属; diff --git a/src/common/messages.ts b/src/common/messages.ts index 7e5af34..de81d44 100644 --- a/src/common/messages.ts +++ b/src/common/messages.ts @@ -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: '医院名称不能为空', diff --git a/src/departments/departments.controller.ts b/src/departments/departments.controller.ts index cbde73a..4e202e7 100644 --- a/src/departments/departments.controller.ts +++ b/src/departments/departments.controller.ts @@ -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, diff --git a/src/departments/departments.service.ts b/src/departments/departments.service.ts index 10dcb05..eb6d40f 100644 --- a/src/departments/departments.service.ts +++ b/src/departments/departments.service.ts @@ -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 } }); diff --git a/src/groups/groups.controller.ts b/src/groups/groups.controller.ts index 70b6f72..551ef7b 100644 --- a/src/groups/groups.controller.ts +++ b/src/groups/groups.controller.ts @@ -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, diff --git a/src/groups/groups.service.ts b/src/groups/groups.service.ts index 7175600..314e3f1 100644 --- a/src/groups/groups.service.ts +++ b/src/groups/groups.service.ts @@ -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 } }); diff --git a/src/hospitals/hospitals.controller.ts b/src/hospitals/hospitals.controller.ts index 892190c..7ba503d 100644 --- a/src/hospitals/hospitals.controller.ts +++ b/src/hospitals/hospitals.controller.ts @@ -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( diff --git a/src/hospitals/hospitals.service.ts b/src/hospitals/hospitals.service.ts index a499db3..7344e1c 100644 --- a/src/hospitals/hospitals.service.ts +++ b/src/hospitals/hospitals.service.ts @@ -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 }, diff --git a/src/organization-common/organization-access.service.ts b/src/organization-common/organization-access.service.ts index fb584f9..d6b517b 100644 --- a/src/organization-common/organization-access.service.ts +++ b/src/organization-common/organization-access.service.ts @@ -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; + } + /** * 分页参数标准化。 */ diff --git a/src/patients/b-patients/b-patients.controller.ts b/src/patients/b-patients/b-patients.controller.ts index 2723cdf..346788e 100644 --- a/src/patients/b-patients/b-patients.controller.ts +++ b/src/patients/b-patients/b-patients.controller.ts @@ -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, diff --git a/src/patients/b-patients/b-patients.service.ts b/src/patients/b-patients/b-patients.service.ts index b2e0f7a..26ae325 100644 --- a/src/patients/b-patients/b-patients.service.ts +++ b/src/patients/b-patients/b-patients.service.ts @@ -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: diff --git a/src/patients/dto/create-patient.dto.ts b/src/patients/dto/create-patient.dto.ts index f926ffd..70a26ef 100644 --- a/src/patients/dto/create-patient.dto.ts +++ b/src/patients/dto/create-patient.dto.ts @@ -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' }) diff --git a/src/tasks/b-tasks/b-tasks.controller.ts b/src/tasks/b-tasks/b-tasks.controller.ts index 82f61ab..0e7f8a8 100644 --- a/src/tasks/b-tasks/b-tasks.controller.ts +++ b/src/tasks/b-tasks/b-tasks.controller.ts @@ -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); } diff --git a/src/tasks/task.service.ts b/src/tasks/task.service.ts index 6e8d551..c7aa1b3 100644 --- a/src/tasks/task.service.ts +++ b/src/tasks/task.service.ts @@ -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({ diff --git a/src/users/users.controller.ts b/src/users/users.controller.ts index d80a13f..834b5ac 100644 --- a/src/users/users.controller.ts +++ b/src/users/users.controller.ts @@ -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); } /** diff --git a/src/users/users.service.ts b/src/users/users.service.ts index 1e9b268..bf46a71 100644 --- a/src/users/users.service.ts +++ b/src/users/users.service.ts @@ -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; + } + /** * 可空整数标准化。 */ diff --git a/tyt-admin/src/constants/role-permissions.js b/tyt-admin/src/constants/role-permissions.js new file mode 100644 index 0000000..d2a6cae --- /dev/null +++ b/tyt-admin/src/constants/role-permissions.js @@ -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); +} diff --git a/tyt-admin/src/layouts/AdminLayout.vue b/tyt-admin/src/layouts/AdminLayout.vue index 20e1539..920748d 100644 --- a/tyt-admin/src/layouts/AdminLayout.vue +++ b/tyt-admin/src/layouts/AdminLayout.vue @@ -27,7 +27,7 @@ 小组管理 - - + @@ -122,18 +122,18 @@ - + @@ -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); diff --git a/tyt-admin/src/views/tasks/Tasks.vue b/tyt-admin/src/views/tasks/Tasks.vue index c509313..eb2a4d2 100644 --- a/tyt-admin/src/views/tasks/Tasks.vue +++ b/tyt-admin/src/views/tasks/Tasks.vue @@ -5,7 +5,7 @@ @@ -68,7 +68,7 @@ @@ -175,7 +175,7 @@ v-if="!canPublishOrCancel && !canAcceptOrComplete" type="warning" :closable="false" - title="当前角色没有任务状态流转权限。仅医生可发布/取消,工程师可接收/完成。" + title="当前角色没有任务状态流转权限。医生/主任/组长可发布/取消,工程师可接收/完成。" class="mt-16" /> @@ -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({