新增主任范围校验:仅可操作同医院同科室的 DOCTOR 账号

限制主任变更:禁止将医生改为其他角色,禁止跨科室调整归属
新增 DIRECTOR_SCOPE_FORBIDDEN 统一错误文案
前端权限同步:主任可进入用户页,页面文案调整为“医生管理”
前端交互同步:主任创建/编辑时角色固定为医生,医院与科室范围锁定
仪表盘统计按角色收敛,主任视角展示本科室医生相关统计
补充 e2e 场景覆盖与接口文档说明"
This commit is contained in:
EL 2026-03-19 11:08:36 +08:00
parent 6ec2d0b0e0
commit 64d1ad7896
10 changed files with 427 additions and 67 deletions

View File

@ -29,8 +29,10 @@
- 患者列表权限: - 患者列表权限:
- `SYSTEM_ADMIN` 查询时必须传 `hospitalId` - `SYSTEM_ADMIN` 查询时必须传 `hospitalId`
- 用户管理接口: - 用户管理接口:
- `SYSTEM_ADMIN/HOSPITAL_ADMIN` 可访问列表与创建 - `SYSTEM_ADMIN/HOSPITAL_ADMIN/DIRECTOR` 可访问列表与创建
- 删除和工程师绑定医院仅 `SYSTEM_ADMIN` - `DIRECTOR` 页面语义调整为“医生管理”,仅管理本科室医生
- 工程师绑定医院仅 `SYSTEM_ADMIN`
- 删除:`SYSTEM_ADMIN` 可删除任意无关联用户;`DIRECTOR` 可删除本科室无关联医生
## 3.1 结构图页面交互调整 ## 3.1 结构图页面交互调整
@ -40,9 +42,11 @@
## 3.2 后台页面路由权限(与后端 RBAC 对齐) ## 3.2 后台页面路由权限(与后端 RBAC 对齐)
- `organization/tree``organization/departments``organization/groups``users` - `organization/tree``organization/departments``organization/groups``users`
- `organization/tree``organization/departments`、`organization/groups` - `organization/tree``organization/groups`
`SYSTEM_ADMIN``HOSPITAL_ADMIN``DIRECTOR``LEADER` 可访问 `SYSTEM_ADMIN``HOSPITAL_ADMIN``DIRECTOR``LEADER` 可访问
- `users`:仅 `SYSTEM_ADMIN``HOSPITAL_ADMIN` 可访问 - `organization/departments`
`SYSTEM_ADMIN``HOSPITAL_ADMIN` 可访问
- `users``SYSTEM_ADMIN``HOSPITAL_ADMIN``DIRECTOR` 可访问
- `devices`:仅 `SYSTEM_ADMIN``HOSPITAL_ADMIN` 可访问 - `devices`:仅 `SYSTEM_ADMIN``HOSPITAL_ADMIN` 可访问
- `organization/hospitals` - `organization/hospitals`
- 仅 `SYSTEM_ADMIN` 可访问 - 仅 `SYSTEM_ADMIN` 可访问
@ -56,12 +60,14 @@
## 3.3 主任/组长组织管理范围 ## 3.3 主任/组长组织管理范围
- `DIRECTOR` - `DIRECTOR`
- 可查看组织架构、科室列表、小组列表(限定本科室范围) - 可查看组织架构、小组列表(限定本科室范围)
- 可编辑本科室名称、创建/编辑/删除本科室下小组 - 可创建/编辑/删除本科室下小组
- 可进入“医生管理”页,创建/维护本科室医生
- `LEADER` - `LEADER`
- 可查看组织架构、科室列表、小组列表(限定本科室/本小组范围) - 可查看组织架构、小组列表(限定本科室/本小组范围)
- 可编辑本科室名称与本小组名称 - 可编辑本小组名称
- 负责人设置(设主任/设组长)与人员管理入口仍仅 `SYSTEM_ADMIN``HOSPITAL_ADMIN` 显示。 - 主任/组长不再显示独立“科室管理”页面。
- 负责人设置(设主任/设组长)入口仍仅 `SYSTEM_ADMIN``HOSPITAL_ADMIN` 显示。
## 4. 本地运行 ## 4. 本地运行

View File

@ -19,6 +19,7 @@
- 医院内数据按 `hospitalId` 强隔离。 - 医院内数据按 `hospitalId` 强隔离。
- 仅 `SYSTEM_ADMIN` 可执行工程师绑定医院。 - 仅 `SYSTEM_ADMIN` 可执行工程师绑定医院。
- `DIRECTOR/LEADER` 可读取用户列表,但仅返回当前科室可见用户。 - `DIRECTOR/LEADER` 可读取用户列表,但仅返回当前科室可见用户。
- `DIRECTOR` 可创建、查看、编辑、删除本科室医生,但不能跨科室操作,也不能把医生改成其他角色。
- 用户组织字段校验: - 用户组织字段校验:
- 院管/医生/工程师等需有医院归属; - 院管/医生/工程师等需有医院归属;
- 主任/组长需有科室/小组等必要归属; - 主任/组长需有科室/小组等必要归属;
@ -31,6 +32,13 @@
- `GET /users``GET /users/:id``PATCH /users/:id``DELETE /users/:id` - `GET /users``GET /users/:id``PATCH /users/:id``DELETE /users/:id`
- `POST /b/users/:id/assign-engineer-hospital` - `POST /b/users/:id/assign-engineer-hospital`
其中主任侧的常用链路为:
- `POST /users`:创建本科室医生
- `GET /users/:id`:查看本科室医生详情
- `PATCH /users/:id`:修改本科室医生信息
- `DELETE /users/:id`:删除无关联数据的本科室医生
## 5. 开发改造建议 ## 5. 开发改造建议
- 若增加角色,请同步修改: - 若增加角色,请同步修改:

View File

@ -58,6 +58,7 @@ export const MESSAGES = {
'检测到多个同手机号账号,请传 hospitalId 指定登录医院', '检测到多个同手机号账号,请传 hospitalId 指定登录医院',
CREATE_FORBIDDEN: '当前角色无权限创建该用户', CREATE_FORBIDDEN: '当前角色无权限创建该用户',
HOSPITAL_ADMIN_SCOPE_FORBIDDEN: '医院管理员仅可操作本院非管理员账号', HOSPITAL_ADMIN_SCOPE_FORBIDDEN: '医院管理员仅可操作本院非管理员账号',
DIRECTOR_SCOPE_FORBIDDEN: '科室主任仅可操作本科室医生账号',
}, },
TASK: { TASK: {

View File

@ -38,7 +38,7 @@ export class UsersController {
* *
*/ */
@Post() @Post()
@Roles(Role.SYSTEM_ADMIN, Role.HOSPITAL_ADMIN) @Roles(Role.SYSTEM_ADMIN, Role.HOSPITAL_ADMIN, Role.DIRECTOR)
@ApiOperation({ summary: '创建用户' }) @ApiOperation({ summary: '创建用户' })
create( create(
@CurrentActor() actor: ActorContext, @CurrentActor() actor: ActorContext,
@ -61,7 +61,7 @@ export class UsersController {
* *
*/ */
@Get(':id') @Get(':id')
@Roles(Role.SYSTEM_ADMIN, Role.HOSPITAL_ADMIN) @Roles(Role.SYSTEM_ADMIN, Role.HOSPITAL_ADMIN, Role.DIRECTOR)
@ApiOperation({ summary: '查询用户详情' }) @ApiOperation({ summary: '查询用户详情' })
@ApiParam({ name: 'id', description: '用户 ID' }) @ApiParam({ name: 'id', description: '用户 ID' })
findOne(@CurrentActor() actor: ActorContext, @Param('id') id: string) { findOne(@CurrentActor() actor: ActorContext, @Param('id') id: string) {
@ -72,7 +72,7 @@ export class UsersController {
* *
*/ */
@Patch(':id') @Patch(':id')
@Roles(Role.SYSTEM_ADMIN, Role.HOSPITAL_ADMIN) @Roles(Role.SYSTEM_ADMIN, Role.HOSPITAL_ADMIN, Role.DIRECTOR)
@ApiOperation({ summary: '更新用户' }) @ApiOperation({ summary: '更新用户' })
@ApiParam({ name: 'id', description: '用户 ID' }) @ApiParam({ name: 'id', description: '用户 ID' })
update( update(
@ -87,7 +87,7 @@ export class UsersController {
* *
*/ */
@Delete(':id') @Delete(':id')
@Roles(Role.SYSTEM_ADMIN) @Roles(Role.SYSTEM_ADMIN, Role.DIRECTOR)
@ApiOperation({ summary: '删除用户' }) @ApiOperation({ summary: '删除用户' })
@ApiParam({ name: 'id', description: '用户 ID' }) @ApiParam({ name: 'id', description: '用户 ID' })
remove(@CurrentActor() actor: ActorContext, @Param('id') id: string) { remove(@CurrentActor() actor: ActorContext, @Param('id') id: string) {

View File

@ -278,6 +278,7 @@ export class UsersService {
this.assertUpdateTargetRoleAllowed(actor, nextRole); this.assertUpdateTargetRoleAllowed(actor, nextRole);
this.assertUpdateHospitalScopeAllowed(actor, nextHospitalId); this.assertUpdateHospitalScopeAllowed(actor, nextHospitalId);
this.assertUpdateDepartmentScopeAllowed(actor, nextDepartmentId);
const assigningDepartmentOrGroup = const assigningDepartmentOrGroup =
(updateUserDto.departmentId !== undefined && nextDepartmentId != null) || (updateUserDto.departmentId !== undefined && nextDepartmentId != null) ||
@ -681,9 +682,38 @@ export class UsersService {
} }
if (actor.role !== Role.HOSPITAL_ADMIN) { if (actor.role !== Role.HOSPITAL_ADMIN) {
if (actor.role !== Role.DIRECTOR) {
throw new ForbiddenException(MESSAGES.USER.CREATE_FORBIDDEN); throw new ForbiddenException(MESSAGES.USER.CREATE_FORBIDDEN);
} }
// 科室主任仅允许创建本科室医生。
if (targetRole !== Role.DOCTOR) {
throw new ForbiddenException(MESSAGES.USER.CREATE_FORBIDDEN);
}
const actorHospitalId = this.requireActorScopeInt(
actor.hospitalId,
MESSAGES.ORG.ACTOR_HOSPITAL_REQUIRED,
);
const actorDepartmentId = this.requireActorScopeInt(
actor.departmentId,
MESSAGES.ORG.ACTOR_DEPARTMENT_REQUIRED,
);
if (hospitalId != null && hospitalId !== actorHospitalId) {
throw new ForbiddenException(MESSAGES.USER.CREATE_FORBIDDEN);
}
if (departmentId != null && departmentId !== actorDepartmentId) {
throw new ForbiddenException(MESSAGES.USER.CREATE_FORBIDDEN);
}
return {
hospitalId: actorHospitalId,
departmentId: actorDepartmentId,
groupId,
};
}
if ( if (
targetRole === Role.SYSTEM_ADMIN || targetRole === Role.SYSTEM_ADMIN ||
targetRole === Role.HOSPITAL_ADMIN targetRole === Role.HOSPITAL_ADMIN
@ -710,7 +740,7 @@ export class UsersService {
} }
/** /**
* *
*/ */
private assertUserReadable( private assertUserReadable(
actor: ActorContext, actor: ActorContext,
@ -718,6 +748,7 @@ export class UsersService {
id: number; id: number;
role: Role; role: Role;
hospitalId: number | null; hospitalId: number | null;
departmentId: number | null;
}, },
) { ) {
if (actor.role === Role.SYSTEM_ADMIN) { if (actor.role === Role.SYSTEM_ADMIN) {
@ -737,11 +768,31 @@ export class UsersService {
} }
} }
if (actor.role === Role.DIRECTOR) {
const actorHospitalId = this.requireActorScopeInt(
actor.hospitalId,
MESSAGES.ORG.ACTOR_HOSPITAL_REQUIRED,
);
const actorDepartmentId = this.requireActorScopeInt(
actor.departmentId,
MESSAGES.ORG.ACTOR_DEPARTMENT_REQUIRED,
);
if (
target.role === Role.DOCTOR &&
target.hospitalId === actorHospitalId &&
target.departmentId === actorDepartmentId
) {
return;
}
throw new ForbiddenException(MESSAGES.USER.DIRECTOR_SCOPE_FORBIDDEN);
}
throw new ForbiddenException(MESSAGES.DEFAULT_FORBIDDEN); throw new ForbiddenException(MESSAGES.DEFAULT_FORBIDDEN);
} }
/** /**
* *
*/ */
private assertUserWritable( private assertUserWritable(
actor: ActorContext, actor: ActorContext,
@ -749,12 +800,32 @@ export class UsersService {
id: number; id: number;
role: Role; role: Role;
hospitalId: number | null; hospitalId: number | null;
departmentId: number | null;
}, },
) { ) {
if (actor.role === Role.SYSTEM_ADMIN) { if (actor.role === Role.SYSTEM_ADMIN) {
return; return;
} }
if (actor.role === Role.DIRECTOR) {
const actorHospitalId = this.requireActorScopeInt(
actor.hospitalId,
MESSAGES.ORG.ACTOR_HOSPITAL_REQUIRED,
);
const actorDepartmentId = this.requireActorScopeInt(
actor.departmentId,
MESSAGES.ORG.ACTOR_DEPARTMENT_REQUIRED,
);
if (
target.role !== Role.DOCTOR ||
target.hospitalId !== actorHospitalId ||
target.departmentId !== actorDepartmentId
) {
throw new ForbiddenException(MESSAGES.USER.DIRECTOR_SCOPE_FORBIDDEN);
}
return;
}
if (actor.role !== Role.HOSPITAL_ADMIN) { if (actor.role !== Role.HOSPITAL_ADMIN) {
throw new ForbiddenException(MESSAGES.DEFAULT_FORBIDDEN); throw new ForbiddenException(MESSAGES.DEFAULT_FORBIDDEN);
} }
@ -786,6 +857,10 @@ export class UsersService {
return; return;
} }
if (actor.role === Role.DIRECTOR && nextRole !== Role.DOCTOR) {
throw new ForbiddenException(MESSAGES.USER.DIRECTOR_SCOPE_FORBIDDEN);
}
if ( if (
actor.role === Role.HOSPITAL_ADMIN && actor.role === Role.HOSPITAL_ADMIN &&
(nextRole === Role.SYSTEM_ADMIN || nextRole === Role.HOSPITAL_ADMIN) (nextRole === Role.SYSTEM_ADMIN || nextRole === Role.HOSPITAL_ADMIN)
@ -803,6 +878,17 @@ export class UsersService {
actor: ActorContext, actor: ActorContext,
hospitalId: number | null, hospitalId: number | null,
) { ) {
if (actor.role === Role.DIRECTOR) {
const actorHospitalId = this.requireActorScopeInt(
actor.hospitalId,
MESSAGES.ORG.ACTOR_HOSPITAL_REQUIRED,
);
if (hospitalId !== actorHospitalId) {
throw new ForbiddenException(MESSAGES.USER.DIRECTOR_SCOPE_FORBIDDEN);
}
return;
}
if (actor.role !== Role.HOSPITAL_ADMIN) { if (actor.role !== Role.HOSPITAL_ADMIN) {
return; return;
} }
@ -818,6 +904,26 @@ export class UsersService {
} }
} }
/**
*
*/
private assertUpdateDepartmentScopeAllowed(
actor: ActorContext,
departmentId: number | null,
) {
if (actor.role !== Role.DIRECTOR) {
return;
}
const actorDepartmentId = this.requireActorScopeInt(
actor.departmentId,
MESSAGES.ORG.ACTOR_DEPARTMENT_REQUIRED,
);
if (departmentId !== actorDepartmentId) {
throw new ForbiddenException(MESSAGES.USER.DIRECTOR_SCOPE_FORBIDDEN);
}
}
/** /**
* 访 * 访
*/ */

View File

@ -65,6 +65,10 @@ describe('UsersController + BUsersController (e2e)', () => {
await createDoctorUser(ctx.tokens[Role.SYSTEM_ADMIN]); await createDoctorUser(ctx.tokens[Role.SYSTEM_ADMIN]);
}); });
it('成功DIRECTOR 可创建本科室医生', async () => {
await createDoctorUser(ctx.tokens[Role.DIRECTOR]);
});
it('失败:参数校验失败返回 400', async () => { it('失败:参数校验失败返回 400', async () => {
const response = await request(ctx.app.getHttpServer()) const response = await request(ctx.app.getHttpServer())
.post('/users') .post('/users')
@ -82,14 +86,31 @@ describe('UsersController + BUsersController (e2e)', () => {
expectErrorEnvelope(response, 400, 'phone 必须是合法手机号'); expectErrorEnvelope(response, 400, 'phone 必须是合法手机号');
}); });
it('角色矩阵SYSTEM_ADMIN/HOSPITAL_ADMIN 可进入业务,其他角色 403未登录 401', async () => { it('失败DIRECTOR 创建非医生角色返回 403', async () => {
const response = await request(ctx.app.getHttpServer())
.post('/users')
.set('Authorization', `Bearer ${ctx.tokens[Role.DIRECTOR]}`)
.send({
name: uniqueSeedValue('主任创建组长'),
phone: uniquePhone(),
password: 'Seed@1234',
role: Role.LEADER,
hospitalId: ctx.fixtures.hospitalAId,
departmentId: ctx.fixtures.departmentA1Id,
groupId: ctx.fixtures.groupA1Id,
});
expectErrorEnvelope(response, 403, '当前角色无权限创建该用户');
});
it('角色矩阵SYSTEM_ADMIN/HOSPITAL_ADMIN/DIRECTOR 可进入业务,其他角色 403未登录 401', async () => {
await assertRoleMatrix({ await assertRoleMatrix({
name: 'POST /users role matrix', name: 'POST /users role matrix',
tokens: ctx.tokens, tokens: ctx.tokens,
expectedStatusByRole: { expectedStatusByRole: {
[Role.SYSTEM_ADMIN]: 400, [Role.SYSTEM_ADMIN]: 400,
[Role.HOSPITAL_ADMIN]: 400, [Role.HOSPITAL_ADMIN]: 400,
[Role.DIRECTOR]: 403, [Role.DIRECTOR]: 400,
[Role.LEADER]: 403, [Role.LEADER]: 403,
[Role.DOCTOR]: 403, [Role.DOCTOR]: 403,
[Role.ENGINEER]: 403, [Role.ENGINEER]: 403,
@ -152,6 +173,15 @@ describe('UsersController + BUsersController (e2e)', () => {
expect(response.body.data.id).toBe(ctx.fixtures.users.doctorAId); expect(response.body.data.id).toBe(ctx.fixtures.users.doctorAId);
}); });
it('成功DIRECTOR 可查询本科室医生详情', async () => {
const response = await request(ctx.app.getHttpServer())
.get(`/users/${ctx.fixtures.users.doctorAId}`)
.set('Authorization', `Bearer ${ctx.tokens[Role.DIRECTOR]}`);
expectSuccessEnvelope(response, 200);
expect(response.body.data.id).toBe(ctx.fixtures.users.doctorAId);
});
it('失败:查询不存在用户返回 404', async () => { it('失败:查询不存在用户返回 404', async () => {
const response = await request(ctx.app.getHttpServer()) const response = await request(ctx.app.getHttpServer())
.get('/users/99999999') .get('/users/99999999')
@ -160,14 +190,22 @@ describe('UsersController + BUsersController (e2e)', () => {
expectErrorEnvelope(response, 404, '用户不存在'); expectErrorEnvelope(response, 404, '用户不存在');
}); });
it('角色矩阵SYSTEM_ADMIN/HOSPITAL_ADMIN 可访问,其他角色 403未登录 401', async () => { it('失败DIRECTOR 查询非本科室医生返回 403', async () => {
const response = await request(ctx.app.getHttpServer())
.get(`/users/${ctx.fixtures.users.doctorA3Id}`)
.set('Authorization', `Bearer ${ctx.tokens[Role.DIRECTOR]}`);
expectErrorEnvelope(response, 403, '科室主任仅可操作本科室医生账号');
});
it('角色矩阵SYSTEM_ADMIN/HOSPITAL_ADMIN/DIRECTOR 可访问,其他角色 403未登录 401', async () => {
await assertRoleMatrix({ await assertRoleMatrix({
name: 'GET /users/:id role matrix', name: 'GET /users/:id role matrix',
tokens: ctx.tokens, tokens: ctx.tokens,
expectedStatusByRole: { expectedStatusByRole: {
[Role.SYSTEM_ADMIN]: 200, [Role.SYSTEM_ADMIN]: 200,
[Role.HOSPITAL_ADMIN]: 200, [Role.HOSPITAL_ADMIN]: 200,
[Role.DIRECTOR]: 403, [Role.DIRECTOR]: 200,
[Role.LEADER]: 403, [Role.LEADER]: 403,
[Role.DOCTOR]: 403, [Role.DOCTOR]: 403,
[Role.ENGINEER]: 403, [Role.ENGINEER]: 403,
@ -198,6 +236,19 @@ describe('UsersController + BUsersController (e2e)', () => {
expect(response.body.data.name).toBe(nextName); expect(response.body.data.name).toBe(nextName);
}); });
it('成功DIRECTOR 可更新本科室医生姓名', async () => {
const created = await createDoctorUser(ctx.tokens[Role.DIRECTOR]);
const nextName = uniqueSeedValue('主任更新医生名');
const response = await request(ctx.app.getHttpServer())
.patch(`/users/${created.id}`)
.set('Authorization', `Bearer ${ctx.tokens[Role.DIRECTOR]}`)
.send({ name: nextName });
expectSuccessEnvelope(response, 200);
expect(response.body.data.name).toBe(nextName);
});
it('失败:非医生调整科室/小组返回 400', async () => { it('失败:非医生调整科室/小组返回 400', async () => {
const response = await request(ctx.app.getHttpServer()) const response = await request(ctx.app.getHttpServer())
.patch(`/users/${ctx.fixtures.users.engineerAId}`) .patch(`/users/${ctx.fixtures.users.engineerAId}`)
@ -214,14 +265,32 @@ describe('UsersController + BUsersController (e2e)', () => {
); );
}); });
it('角色矩阵SYSTEM_ADMIN/HOSPITAL_ADMIN 可进入业务,其他角色 403未登录 401', async () => { it('失败DIRECTOR 不能把医生改成其他角色', async () => {
const response = await request(ctx.app.getHttpServer())
.patch(`/users/${ctx.fixtures.users.doctorAId}`)
.set('Authorization', `Bearer ${ctx.tokens[Role.DIRECTOR]}`)
.send({ role: Role.LEADER });
expectErrorEnvelope(response, 403, '科室主任仅可操作本科室医生账号');
});
it('失败DIRECTOR 不能把医生调整到其他科室', async () => {
const response = await request(ctx.app.getHttpServer())
.patch(`/users/${ctx.fixtures.users.doctorAId}`)
.set('Authorization', `Bearer ${ctx.tokens[Role.DIRECTOR]}`)
.send({ departmentId: ctx.fixtures.departmentA2Id });
expectErrorEnvelope(response, 403, '科室主任仅可操作本科室医生账号');
});
it('角色矩阵SYSTEM_ADMIN/HOSPITAL_ADMIN/DIRECTOR 可进入业务,其他角色 403未登录 401', async () => {
await assertRoleMatrix({ await assertRoleMatrix({
name: 'PATCH /users/:id role matrix', name: 'PATCH /users/:id role matrix',
tokens: ctx.tokens, tokens: ctx.tokens,
expectedStatusByRole: { expectedStatusByRole: {
[Role.SYSTEM_ADMIN]: 404, [Role.SYSTEM_ADMIN]: 404,
[Role.HOSPITAL_ADMIN]: 404, [Role.HOSPITAL_ADMIN]: 404,
[Role.DIRECTOR]: 403, [Role.DIRECTOR]: 404,
[Role.LEADER]: 403, [Role.LEADER]: 403,
[Role.DOCTOR]: 403, [Role.DOCTOR]: 403,
[Role.ENGINEER]: 403, [Role.ENGINEER]: 403,
@ -250,6 +319,16 @@ describe('UsersController + BUsersController (e2e)', () => {
expect(response.body.data.id).toBe(created.id); expect(response.body.data.id).toBe(created.id);
}); });
it('成功DIRECTOR 可删除本科室医生', async () => {
const created = await createDoctorUser(ctx.tokens[Role.DIRECTOR]);
const response = await request(ctx.app.getHttpServer())
.delete(`/users/${created.id}`)
.set('Authorization', `Bearer ${ctx.tokens[Role.DIRECTOR]}`);
expectSuccessEnvelope(response, 200);
expect(response.body.data.id).toBe(created.id);
});
it('失败:存在关联患者/任务时返回 409', async () => { it('失败:存在关联患者/任务时返回 409', async () => {
const response = await request(ctx.app.getHttpServer()) const response = await request(ctx.app.getHttpServer())
.delete(`/users/${ctx.fixtures.users.doctorAId}`) .delete(`/users/${ctx.fixtures.users.doctorAId}`)
@ -258,6 +337,14 @@ describe('UsersController + BUsersController (e2e)', () => {
expectErrorEnvelope(response, 409, '用户存在关联患者或任务,无法删除'); expectErrorEnvelope(response, 409, '用户存在关联患者或任务,无法删除');
}); });
it('失败DIRECTOR 删除非本科室医生返回 403', async () => {
const response = await request(ctx.app.getHttpServer())
.delete(`/users/${ctx.fixtures.users.doctorA3Id}`)
.set('Authorization', `Bearer ${ctx.tokens[Role.DIRECTOR]}`);
expectErrorEnvelope(response, 403, '科室主任仅可操作本科室医生账号');
});
it('失败HOSPITAL_ADMIN 无法删除返回 403', async () => { it('失败HOSPITAL_ADMIN 无法删除返回 403', async () => {
const response = await request(ctx.app.getHttpServer()) const response = await request(ctx.app.getHttpServer())
.delete(`/users/${ctx.fixtures.users.doctorAId}`) .delete(`/users/${ctx.fixtures.users.doctorAId}`)
@ -266,14 +353,14 @@ describe('UsersController + BUsersController (e2e)', () => {
expectErrorEnvelope(response, 403, '无权限执行当前操作'); expectErrorEnvelope(response, 403, '无权限执行当前操作');
}); });
it('角色矩阵:SYSTEM_ADMIN 可进入业务,其他角色 403未登录 401', async () => { it('角色矩阵:SYSTEM_ADMIN/DIRECTOR 可进入业务,其他角色 403未登录 401', async () => {
await assertRoleMatrix({ await assertRoleMatrix({
name: 'DELETE /users/:id role matrix', name: 'DELETE /users/:id role matrix',
tokens: ctx.tokens, tokens: ctx.tokens,
expectedStatusByRole: { expectedStatusByRole: {
[Role.SYSTEM_ADMIN]: 404, [Role.SYSTEM_ADMIN]: 404,
[Role.HOSPITAL_ADMIN]: 403, [Role.HOSPITAL_ADMIN]: 403,
[Role.DIRECTOR]: 403, [Role.DIRECTOR]: 404,
[Role.LEADER]: 403, [Role.LEADER]: 403,
[Role.DOCTOR]: 403, [Role.DOCTOR]: 403,
[Role.ENGINEER]: 403, [Role.ENGINEER]: 403,

View File

@ -8,6 +8,11 @@ const ORG_MANAGER_ROLES = Object.freeze([
'DIRECTOR', 'DIRECTOR',
'LEADER', 'LEADER',
]); ]);
const USER_MANAGER_ROLES = Object.freeze([
'SYSTEM_ADMIN',
'HOSPITAL_ADMIN',
'DIRECTOR',
]);
const TASK_ROLES = Object.freeze(['DOCTOR', 'DIRECTOR', 'LEADER', 'ENGINEER']); const TASK_ROLES = Object.freeze(['DOCTOR', 'DIRECTOR', 'LEADER', 'ENGINEER']);
const PATIENT_ROLES = Object.freeze([ const PATIENT_ROLES = Object.freeze([
'SYSTEM_ADMIN', 'SYSTEM_ADMIN',
@ -21,9 +26,10 @@ const PATIENT_ROLES = Object.freeze([
export const ROLE_PERMISSIONS = Object.freeze({ export const ROLE_PERMISSIONS = Object.freeze({
ORG_TREE: ORG_MANAGER_ROLES, ORG_TREE: ORG_MANAGER_ROLES,
ORG_HOSPITALS: Object.freeze(['SYSTEM_ADMIN']), ORG_HOSPITALS: Object.freeze(['SYSTEM_ADMIN']),
ORG_DEPARTMENTS: ORG_MANAGER_ROLES, // 主任/组长仍可通过接口读取科室信息,但不再开放独立“科室管理”页面。
ORG_DEPARTMENTS: ADMIN_ROLES,
ORG_GROUPS: ORG_MANAGER_ROLES, ORG_GROUPS: ORG_MANAGER_ROLES,
USERS: ADMIN_ROLES, USERS: USER_MANAGER_ROLES,
DEVICES: ADMIN_ROLES, DEVICES: ADMIN_ROLES,
TASKS: TASK_ROLES, TASKS: TASK_ROLES,
PATIENTS: PATIENT_ROLES, PATIENTS: PATIENT_ROLES,

View File

@ -36,7 +36,7 @@
<el-icon><Share /></el-icon> <el-icon><Share /></el-icon>
<span>组织架构图</span> <span>组织架构图</span>
</el-menu-item> </el-menu-item>
<el-menu-item index="/organization/departments"> <el-menu-item v-if="canAccessDepartments" index="/organization/departments">
<el-icon><OfficeBuilding /></el-icon> <el-icon><OfficeBuilding /></el-icon>
<span>科室管理</span> <span>科室管理</span>
</el-menu-item> </el-menu-item>
@ -48,7 +48,7 @@
<el-menu-item v-if="canAccessUsers" index="/users"> <el-menu-item v-if="canAccessUsers" index="/users">
<el-icon><User /></el-icon> <el-icon><User /></el-icon>
<span>用户管理</span> <span>{{ usersMenuLabel }}</span>
</el-menu-item> </el-menu-item>
<el-menu-item v-if="canAccessDevices" index="/devices"> <el-menu-item v-if="canAccessDevices" index="/devices">
@ -129,6 +129,7 @@ const activeMenu = computed(() => {
return route.path; return route.path;
}); });
const isDirector = computed(() => userStore.role === 'DIRECTOR');
const canAccessUsers = computed(() => const canAccessUsers = computed(() =>
hasRolePermission(userStore.role, ROLE_PERMISSIONS.USERS), hasRolePermission(userStore.role, ROLE_PERMISSIONS.USERS),
); );
@ -138,12 +139,18 @@ const canAccessDevices = computed(() =>
const canAccessOrgTree = computed(() => const canAccessOrgTree = computed(() =>
hasRolePermission(userStore.role, ROLE_PERMISSIONS.ORG_TREE), hasRolePermission(userStore.role, ROLE_PERMISSIONS.ORG_TREE),
); );
const canAccessDepartments = computed(() =>
hasRolePermission(userStore.role, ROLE_PERMISSIONS.ORG_DEPARTMENTS),
);
const canAccessTasks = computed(() => const canAccessTasks = computed(() =>
hasRolePermission(userStore.role, ROLE_PERMISSIONS.TASKS), hasRolePermission(userStore.role, ROLE_PERMISSIONS.TASKS),
); );
const canAccessPatients = computed(() => const canAccessPatients = computed(() =>
hasRolePermission(userStore.role, ROLE_PERMISSIONS.PATIENTS), hasRolePermission(userStore.role, ROLE_PERMISSIONS.PATIENTS),
); );
const usersMenuLabel = computed(() =>
isDirector.value ? '医生管理' : '用户管理',
);
const handleCommand = (command) => { const handleCommand = (command) => {
if (command === 'logout') { if (command === 'logout') {

View File

@ -5,17 +5,13 @@
<p>当前角色{{ userStore.role || '未登录' }}</p> <p>当前角色{{ userStore.role || '未登录' }}</p>
</el-card> </el-card>
<el-card <el-card v-if="isSystemAdmin" shadow="never" class="filter-card">
v-if="isSystemAdmin"
shadow="never"
class="filter-card"
>
<el-form inline> <el-form inline>
<el-form-item label="患者统计医院"> <el-form-item label="患者统计医院">
<el-select <el-select
v-model="selectedHospitalId" v-model="selectedHospitalId"
placeholder="请选择医院" placeholder="请选择医院"
style="width: 280px;" style="width: 280px"
@change="fetchDashboardData" @change="fetchDashboardData"
> >
<el-option <el-option
@ -30,7 +26,13 @@
</el-card> </el-card>
<el-row :gutter="16" v-loading="loading"> <el-row :gutter="16" v-loading="loading">
<el-col :xs="24" :sm="12" :lg="6" v-for="item in statCards" :key="item.key"> <el-col
:xs="24"
:sm="12"
:lg="6"
v-for="item in statCards"
:key="item.key"
>
<el-card shadow="hover" class="stat-card"> <el-card shadow="hover" class="stat-card">
<div class="stat-title">{{ item.title }}</div> <div class="stat-title">{{ item.title }}</div>
<div class="stat-value">{{ item.value }}</div> <div class="stat-value">{{ item.value }}</div>
@ -75,19 +77,44 @@ const isSystemAdmin = computed(() => userStore.role === 'SYSTEM_ADMIN');
const canViewOrg = computed(() => const canViewOrg = computed(() =>
['SYSTEM_ADMIN', 'HOSPITAL_ADMIN'].includes(userStore.role), ['SYSTEM_ADMIN', 'HOSPITAL_ADMIN'].includes(userStore.role),
); );
const canViewUsers = computed(() =>
['SYSTEM_ADMIN', 'HOSPITAL_ADMIN', 'DIRECTOR'].includes(userStore.role),
);
const canViewPatients = computed(() => const canViewPatients = computed(() =>
['SYSTEM_ADMIN', 'HOSPITAL_ADMIN', 'DIRECTOR', 'LEADER', 'DOCTOR'].includes( ['SYSTEM_ADMIN', 'HOSPITAL_ADMIN', 'DIRECTOR', 'LEADER', 'DOCTOR'].includes(
userStore.role, userStore.role,
), ),
); );
const statCards = computed(() => [ const statCards = computed(() => {
const cards = [];
if (canViewOrg.value) {
cards.push(
{ key: 'hospitals', title: '医院总数', value: stats.value.hospitals }, { key: 'hospitals', title: '医院总数', value: stats.value.hospitals },
{ key: 'departments', title: '科室总数', value: stats.value.departments }, { key: 'departments', title: '科室总数', value: stats.value.departments },
{ key: 'groups', title: '小组总数', value: stats.value.groups }, { key: 'groups', title: '小组总数', value: stats.value.groups },
{ key: 'users', title: '用户总数', value: stats.value.users }, );
{ key: 'patients', title: '可见患者数', value: stats.value.patients }, }
]);
if (canViewUsers.value) {
cards.push({
key: 'users',
title: userStore.role === 'DIRECTOR' ? '本科室医生数' : '用户总数',
value: stats.value.users,
});
}
if (canViewPatients.value) {
cards.push({
key: 'patients',
title: '可见患者数',
value: stats.value.patients,
});
}
return cards;
});
const fetchHospitalsForFilter = async () => { const fetchHospitalsForFilter = async () => {
if (!isSystemAdmin.value) { if (!isSystemAdmin.value) {
@ -104,16 +131,23 @@ const fetchDashboardData = async () => {
loading.value = true; loading.value = true;
try { try {
if (canViewOrg.value) { if (canViewOrg.value) {
const [hospitalRes, departmentRes, groupRes, usersRes] = await Promise.all([ const [hospitalRes, departmentRes, groupRes] = await Promise.all([
getHospitals({ page: 1, pageSize: 1 }), getHospitals({ page: 1, pageSize: 1 }),
getDepartments({ page: 1, pageSize: 1 }), getDepartments({ page: 1, pageSize: 1 }),
getGroups({ page: 1, pageSize: 1 }), getGroups({ page: 1, pageSize: 1 }),
getUsers({ page: 1, pageSize: 1 }),
]); ]);
stats.value.hospitals = hospitalRes.total ?? 0; stats.value.hospitals = hospitalRes.total ?? 0;
stats.value.departments = departmentRes.total ?? 0; stats.value.departments = departmentRes.total ?? 0;
stats.value.groups = groupRes.total ?? 0; stats.value.groups = groupRes.total ?? 0;
}
if (canViewUsers.value) {
const usersRes = await getUsers({
page: 1,
pageSize: 1,
role: userStore.role === 'DIRECTOR' ? 'DOCTOR' : undefined,
});
stats.value.users = usersRes.total ?? 0; stats.value.users = usersRes.total ?? 0;
} }
@ -126,7 +160,9 @@ const fetchDashboardData = async () => {
stats.value.patients = 0; stats.value.patients = 0;
} else { } else {
const patientRes = await getPatients(params); const patientRes = await getPatients(params);
stats.value.patients = Array.isArray(patientRes) ? patientRes.length : 0; stats.value.patients = Array.isArray(patientRes)
? patientRes.length
: 0;
} }
} }
} finally { } finally {

View File

@ -10,18 +10,18 @@
clearable clearable
/> />
</el-form-item> </el-form-item>
<el-form-item label="角色"> <el-form-item v-if="!isDirector" label="角色">
<el-select <el-select
v-model="searchForm.role" v-model="searchForm.role"
placeholder="请选择角色" placeholder="请选择角色"
clearable clearable
> >
<el-option label="系统管理员" value="SYSTEM_ADMIN" /> <el-option
<el-option label="医院管理员" value="HOSPITAL_ADMIN" /> v-for="option in roleOptions"
<el-option label="科室主任" value="DIRECTOR" /> :key="option.value"
<el-option label="小组组长" value="LEADER" /> :label="option.label"
<el-option label="医生" value="DOCTOR" /> :value="option.value"
<el-option label="工程师" value="ENGINEER" /> />
</el-select> </el-select>
</el-form-item> </el-form-item>
<el-form-item> <el-form-item>
@ -30,7 +30,7 @@
</el-button> </el-button>
<el-button @click="resetSearch" icon="Refresh">重置</el-button> <el-button @click="resetSearch" icon="Refresh">重置</el-button>
<el-button type="success" @click="openCreateDialog" icon="Plus"> <el-button type="success" @click="openCreateDialog" icon="Plus">
新增用户 {{ createButtonText }}
</el-button> </el-button>
</el-form-item> </el-form-item>
</el-form> </el-form>
@ -84,7 +84,7 @@
编辑 编辑
</el-button> </el-button>
<el-button <el-button
v-if="userStore.role === 'SYSTEM_ADMIN'" v-if="canDeleteUser(row)"
size="small" size="small"
type="danger" type="danger"
@click="handleDelete(row)" @click="handleDelete(row)"
@ -110,7 +110,7 @@
</el-card> </el-card>
<el-dialog <el-dialog
:title="isEdit ? '编辑用户' : '新增用户'" :title="dialogTitle"
v-model="dialogVisible" v-model="dialogVisible"
width="620px" width="620px"
@close="resetForm" @close="resetForm"
@ -131,17 +131,23 @@
/> />
</el-form-item> </el-form-item>
<el-form-item label="角色" prop="role"> <el-form-item label="角色" prop="role">
<el-input
v-if="isDirector"
:model-value="getRoleName('DOCTOR')"
disabled
/>
<el-select <el-select
v-else
v-model="form.role" v-model="form.role"
placeholder="请选择角色" placeholder="请选择角色"
style="width: 100%" style="width: 100%"
> >
<el-option label="系统管理员" value="SYSTEM_ADMIN" /> <el-option
<el-option label="医院管理员" value="HOSPITAL_ADMIN" /> v-for="option in roleOptions"
<el-option label="科室主任" value="DIRECTOR" /> :key="option.value"
<el-option label="小组组长" value="LEADER" /> :label="option.label"
<el-option label="医生" value="DOCTOR" /> :value="option.value"
<el-option label="工程师" value="ENGINEER" /> />
</el-select> </el-select>
</el-form-item> </el-form-item>
@ -150,6 +156,7 @@
v-model="form.hospitalId" v-model="form.hospitalId"
placeholder="请选择医院" placeholder="请选择医院"
style="width: 100%" style="width: 100%"
:disabled="lockHospital"
> >
<el-option <el-option
v-for="hospital in hospitals" v-for="hospital in hospitals"
@ -169,6 +176,7 @@
v-model="form.departmentId" v-model="form.departmentId"
placeholder="请选择科室" placeholder="请选择科室"
style="width: 100%" style="width: 100%"
:disabled="lockDepartment"
> >
<el-option <el-option
v-for="department in formDepartments" v-for="department in formDepartments"
@ -266,6 +274,15 @@ import {
} from '../../api/organization'; } from '../../api/organization';
import { useUserStore } from '../../store/user'; import { useUserStore } from '../../store/user';
const roleOptions = [
{ label: '系统管理员', value: 'SYSTEM_ADMIN' },
{ label: '医院管理员', value: 'HOSPITAL_ADMIN' },
{ label: '科室主任', value: 'DIRECTOR' },
{ label: '小组组长', value: 'LEADER' },
{ label: '医生', value: 'DOCTOR' },
{ label: '工程师', value: 'ENGINEER' },
];
const route = useRoute(); const route = useRoute();
const userStore = useUserStore(); const userStore = useUserStore();
@ -279,9 +296,10 @@ const hospitals = ref([]);
const departments = ref([]); const departments = ref([]);
const groups = ref([]); const groups = ref([]);
const isDirector = computed(() => userStore.role === 'DIRECTOR');
const searchForm = reactive({ const searchForm = reactive({
keyword: '', keyword: '',
role: '', role: isDirector.value ? 'DOCTOR' : '',
}); });
const dialogVisible = ref(false); const dialogVisible = ref(false);
@ -306,11 +324,24 @@ const assignDialogVisible = ref(false);
const currentAssignUser = ref(null); const currentAssignUser = ref(null);
const assignHospitalId = ref(null); const assignHospitalId = ref(null);
const createButtonText = computed(() =>
isDirector.value ? '新增医生' : '新增用户',
);
const dialogTitle = computed(() => {
if (isDirector.value) {
return isEdit.value ? '编辑医生' : '新增医生';
}
return isEdit.value ? '编辑用户' : '新增用户';
});
const needHospital = computed(() => form.role && form.role !== 'SYSTEM_ADMIN'); const needHospital = computed(() => form.role && form.role !== 'SYSTEM_ADMIN');
const needDepartment = computed(() => const needDepartment = computed(() =>
['DIRECTOR', 'LEADER', 'DOCTOR'].includes(form.role), ['DIRECTOR', 'LEADER', 'DOCTOR'].includes(form.role),
); );
const needGroup = computed(() => ['LEADER', 'DOCTOR'].includes(form.role)); const needGroup = computed(() => ['LEADER', 'DOCTOR'].includes(form.role));
const lockHospital = computed(() =>
['HOSPITAL_ADMIN', 'DIRECTOR'].includes(userStore.role),
);
const lockDepartment = computed(() => isDirector.value);
const rules = computed(() => ({ const rules = computed(() => ({
name: [{ required: true, message: '请输入姓名', trigger: 'blur' }], name: [{ required: true, message: '请输入姓名', trigger: 'blur' }],
@ -379,6 +410,16 @@ const resolveGroupName = (id) => {
return groupMap.value.get(id) || `#${id}`; return groupMap.value.get(id) || `#${id}`;
}; };
const resolveDirectorScope = () => {
const hospitalId = userStore.userInfo?.hospitalId || null;
const departmentId = userStore.userInfo?.departmentId || null;
if (!hospitalId || !departmentId) {
ElMessage.error('当前主任账号缺少医院或科室归属');
return null;
}
return { hospitalId, departmentId };
};
const fetchCommonData = async () => { const fetchCommonData = async () => {
const [hospitalRes, departmentRes, groupRes] = await Promise.all([ const [hospitalRes, departmentRes, groupRes] = await Promise.all([
getHospitals({ page: 1, pageSize: 100 }), getHospitals({ page: 1, pageSize: 100 }),
@ -427,7 +468,7 @@ const fetchData = async () => {
const res = await getUsers({ const res = await getUsers({
page: page.value, page: page.value,
pageSize: pageSize.value, pageSize: pageSize.value,
role: searchForm.role || undefined, role: (isDirector.value ? 'DOCTOR' : searchForm.role) || undefined,
keyword: searchForm.keyword || undefined, keyword: searchForm.keyword || undefined,
}); });
tableData.value = res.list || []; tableData.value = res.list || [];
@ -444,15 +485,29 @@ const handleSearch = () => {
const resetSearch = () => { const resetSearch = () => {
searchForm.keyword = ''; searchForm.keyword = '';
searchForm.role = ''; searchForm.role = isDirector.value ? 'DOCTOR' : '';
page.value = 1; page.value = 1;
fetchData(); fetchData();
}; };
const openCreateDialog = async () => { const openCreateDialog = async () => {
resetForm();
isEdit.value = false; isEdit.value = false;
currentId.value = null; currentId.value = null;
if (isDirector.value) {
const directorScope = resolveDirectorScope();
if (!directorScope) {
return;
}
form.role = 'DOCTOR';
form.hospitalId = directorScope.hospitalId;
await fetchDepartmentsForForm(form.hospitalId);
form.departmentId = directorScope.departmentId;
await fetchGroupsForForm(form.departmentId);
dialogVisible.value = true; dialogVisible.value = true;
return;
}
if (userStore.role === 'HOSPITAL_ADMIN') { if (userStore.role === 'HOSPITAL_ADMIN') {
form.hospitalId = userStore.userInfo?.hospitalId || null; form.hospitalId = userStore.userInfo?.hospitalId || null;
@ -460,9 +515,16 @@ const openCreateDialog = async () => {
await fetchDepartmentsForForm(form.hospitalId); await fetchDepartmentsForForm(form.hospitalId);
} }
} }
dialogVisible.value = true;
}; };
const openEditDialog = async (row) => { const openEditDialog = async (row) => {
if (isDirector.value && row.role !== 'DOCTOR') {
ElMessage.warning('主任仅可编辑本科室医生');
return;
}
isEdit.value = true; isEdit.value = true;
currentId.value = row.id; currentId.value = row.id;
@ -505,6 +567,31 @@ const resetForm = () => {
}; };
const buildSubmitPayload = () => { const buildSubmitPayload = () => {
if (isDirector.value) {
const directorScope = resolveDirectorScope();
if (!directorScope) {
return null;
}
const payload = {
name: form.name,
phone: form.phone,
role: 'DOCTOR',
hospitalId: directorScope.hospitalId,
departmentId: directorScope.departmentId,
groupId: form.groupId,
};
if (!isEdit.value) {
return {
...payload,
password: form.password,
};
}
return payload;
}
const payload = { const payload = {
name: form.name, name: form.name,
phone: form.phone, phone: form.phone,
@ -575,6 +662,9 @@ const handleSubmit = async () => {
submitLoading.value = true; submitLoading.value = true;
try { try {
const payload = buildSubmitPayload(); const payload = buildSubmitPayload();
if (!payload) {
return;
}
if (isEdit.value) { if (isEdit.value) {
await updateUser(currentId.value, payload); await updateUser(currentId.value, payload);
ElMessage.success('更新成功'); ElMessage.success('更新成功');
@ -592,6 +682,11 @@ const handleSubmit = async () => {
}; };
const handleDelete = (row) => { const handleDelete = (row) => {
if (isDirector.value && row.role !== 'DOCTOR') {
ElMessage.warning('主任仅可删除本科室医生');
return;
}
ElMessageBox.confirm(`确定要删除用户 "${row.name}" 吗?`, '警告', { ElMessageBox.confirm(`确定要删除用户 "${row.name}" 吗?`, '警告', {
confirmButtonText: '确定', confirmButtonText: '确定',
cancelButtonText: '取消', cancelButtonText: '取消',
@ -698,16 +793,24 @@ onMounted(async () => {
if (route.query.action === 'create') { if (route.query.action === 'create') {
await openCreateDialog(); await openCreateDialog();
if (route.query.hospitalId) { if (!isDirector.value && route.query.hospitalId) {
form.hospitalId = Number(route.query.hospitalId); form.hospitalId = Number(route.query.hospitalId);
await fetchDepartmentsForForm(form.hospitalId); await fetchDepartmentsForForm(form.hospitalId);
} }
if (route.query.departmentId) { if (!isDirector.value && route.query.departmentId) {
form.departmentId = Number(route.query.departmentId); form.departmentId = Number(route.query.departmentId);
await fetchGroupsForForm(form.departmentId); await fetchGroupsForForm(form.departmentId);
} }
} }
}); });
const canDeleteUser = (row) => {
if (userStore.role === 'SYSTEM_ADMIN') {
return true;
}
return isDirector.value && row.role === 'DOCTOR';
};
</script> </script>
<style scoped> <style scoped>