新增主任范围校验:仅可操作同医院同科室的 DOCTOR 账号
限制主任变更:禁止将医生改为其他角色,禁止跨科室调整归属 新增 DIRECTOR_SCOPE_FORBIDDEN 统一错误文案 前端权限同步:主任可进入用户页,页面文案调整为“医生管理” 前端交互同步:主任创建/编辑时角色固定为医生,医院与科室范围锁定 仪表盘统计按角色收敛,主任视角展示本科室医生相关统计 补充 e2e 场景覆盖与接口文档说明"
This commit is contained in:
parent
6ec2d0b0e0
commit
64d1ad7896
@ -29,8 +29,10 @@
|
||||
- 患者列表权限:
|
||||
- `SYSTEM_ADMIN` 查询时必须传 `hospitalId`
|
||||
- 用户管理接口:
|
||||
- `SYSTEM_ADMIN/HOSPITAL_ADMIN` 可访问列表与创建
|
||||
- 删除和工程师绑定医院仅 `SYSTEM_ADMIN`
|
||||
- `SYSTEM_ADMIN/HOSPITAL_ADMIN/DIRECTOR` 可访问列表与创建
|
||||
- `DIRECTOR` 页面语义调整为“医生管理”,仅管理本科室医生
|
||||
- 工程师绑定医院仅 `SYSTEM_ADMIN`
|
||||
- 删除:`SYSTEM_ADMIN` 可删除任意无关联用户;`DIRECTOR` 可删除本科室无关联医生
|
||||
|
||||
## 3.1 结构图页面交互调整
|
||||
|
||||
@ -40,9 +42,11 @@
|
||||
## 3.2 后台页面路由权限(与后端 RBAC 对齐)
|
||||
|
||||
- `organization/tree`、`organization/departments`、`organization/groups`、`users`
|
||||
- `organization/tree`、`organization/departments`、`organization/groups`:
|
||||
- `organization/tree`、`organization/groups`:
|
||||
`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` 可访问
|
||||
- `organization/hospitals`
|
||||
- 仅 `SYSTEM_ADMIN` 可访问
|
||||
@ -56,12 +60,14 @@
|
||||
## 3.3 主任/组长组织管理范围
|
||||
|
||||
- `DIRECTOR`
|
||||
- 可查看组织架构、科室列表、小组列表(限定本科室范围)
|
||||
- 可编辑本科室名称、创建/编辑/删除本科室下小组
|
||||
- 可查看组织架构、小组列表(限定本科室范围)
|
||||
- 可创建/编辑/删除本科室下小组
|
||||
- 可进入“医生管理”页,创建/维护本科室医生
|
||||
- `LEADER`
|
||||
- 可查看组织架构、科室列表、小组列表(限定本科室/本小组范围)
|
||||
- 可编辑本科室名称与本小组名称
|
||||
- 负责人设置(设主任/设组长)与人员管理入口仍仅 `SYSTEM_ADMIN`、`HOSPITAL_ADMIN` 显示。
|
||||
- 可查看组织架构、小组列表(限定本科室/本小组范围)
|
||||
- 可编辑本小组名称
|
||||
- 主任/组长不再显示独立“科室管理”页面。
|
||||
- 负责人设置(设主任/设组长)入口仍仅 `SYSTEM_ADMIN`、`HOSPITAL_ADMIN` 显示。
|
||||
|
||||
## 4. 本地运行
|
||||
|
||||
|
||||
@ -19,6 +19,7 @@
|
||||
- 医院内数据按 `hospitalId` 强隔离。
|
||||
- 仅 `SYSTEM_ADMIN` 可执行工程师绑定医院。
|
||||
- `DIRECTOR/LEADER` 可读取用户列表,但仅返回当前科室可见用户。
|
||||
- `DIRECTOR` 可创建、查看、编辑、删除本科室医生,但不能跨科室操作,也不能把医生改成其他角色。
|
||||
- 用户组织字段校验:
|
||||
- 院管/医生/工程师等需有医院归属;
|
||||
- 主任/组长需有科室/小组等必要归属;
|
||||
@ -31,6 +32,13 @@
|
||||
- `GET /users`、`GET /users/:id`、`PATCH /users/:id`、`DELETE /users/:id`
|
||||
- `POST /b/users/:id/assign-engineer-hospital`
|
||||
|
||||
其中主任侧的常用链路为:
|
||||
|
||||
- `POST /users`:创建本科室医生
|
||||
- `GET /users/:id`:查看本科室医生详情
|
||||
- `PATCH /users/:id`:修改本科室医生信息
|
||||
- `DELETE /users/:id`:删除无关联数据的本科室医生
|
||||
|
||||
## 5. 开发改造建议
|
||||
|
||||
- 若增加角色,请同步修改:
|
||||
|
||||
@ -58,6 +58,7 @@ export const MESSAGES = {
|
||||
'检测到多个同手机号账号,请传 hospitalId 指定登录医院',
|
||||
CREATE_FORBIDDEN: '当前角色无权限创建该用户',
|
||||
HOSPITAL_ADMIN_SCOPE_FORBIDDEN: '医院管理员仅可操作本院非管理员账号',
|
||||
DIRECTOR_SCOPE_FORBIDDEN: '科室主任仅可操作本科室医生账号',
|
||||
},
|
||||
|
||||
TASK: {
|
||||
|
||||
@ -38,7 +38,7 @@ export class UsersController {
|
||||
* 创建用户。
|
||||
*/
|
||||
@Post()
|
||||
@Roles(Role.SYSTEM_ADMIN, Role.HOSPITAL_ADMIN)
|
||||
@Roles(Role.SYSTEM_ADMIN, Role.HOSPITAL_ADMIN, Role.DIRECTOR)
|
||||
@ApiOperation({ summary: '创建用户' })
|
||||
create(
|
||||
@CurrentActor() actor: ActorContext,
|
||||
@ -61,7 +61,7 @@ export class UsersController {
|
||||
* 查询用户详情。
|
||||
*/
|
||||
@Get(':id')
|
||||
@Roles(Role.SYSTEM_ADMIN, Role.HOSPITAL_ADMIN)
|
||||
@Roles(Role.SYSTEM_ADMIN, Role.HOSPITAL_ADMIN, Role.DIRECTOR)
|
||||
@ApiOperation({ summary: '查询用户详情' })
|
||||
@ApiParam({ name: 'id', description: '用户 ID' })
|
||||
findOne(@CurrentActor() actor: ActorContext, @Param('id') id: string) {
|
||||
@ -72,7 +72,7 @@ export class UsersController {
|
||||
* 更新用户。
|
||||
*/
|
||||
@Patch(':id')
|
||||
@Roles(Role.SYSTEM_ADMIN, Role.HOSPITAL_ADMIN)
|
||||
@Roles(Role.SYSTEM_ADMIN, Role.HOSPITAL_ADMIN, Role.DIRECTOR)
|
||||
@ApiOperation({ summary: '更新用户' })
|
||||
@ApiParam({ name: 'id', description: '用户 ID' })
|
||||
update(
|
||||
@ -87,7 +87,7 @@ export class UsersController {
|
||||
* 删除用户。
|
||||
*/
|
||||
@Delete(':id')
|
||||
@Roles(Role.SYSTEM_ADMIN)
|
||||
@Roles(Role.SYSTEM_ADMIN, Role.DIRECTOR)
|
||||
@ApiOperation({ summary: '删除用户' })
|
||||
@ApiParam({ name: 'id', description: '用户 ID' })
|
||||
remove(@CurrentActor() actor: ActorContext, @Param('id') id: string) {
|
||||
|
||||
@ -278,6 +278,7 @@ export class UsersService {
|
||||
|
||||
this.assertUpdateTargetRoleAllowed(actor, nextRole);
|
||||
this.assertUpdateHospitalScopeAllowed(actor, nextHospitalId);
|
||||
this.assertUpdateDepartmentScopeAllowed(actor, nextDepartmentId);
|
||||
|
||||
const assigningDepartmentOrGroup =
|
||||
(updateUserDto.departmentId !== undefined && nextDepartmentId != null) ||
|
||||
@ -681,7 +682,36 @@ export class UsersService {
|
||||
}
|
||||
|
||||
if (actor.role !== Role.HOSPITAL_ADMIN) {
|
||||
throw new ForbiddenException(MESSAGES.USER.CREATE_FORBIDDEN);
|
||||
if (actor.role !== Role.DIRECTOR) {
|
||||
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 (
|
||||
@ -710,7 +740,7 @@ export class UsersService {
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取权限:系统管理员可读全量;其余仅可读自己与本院(医院管理员)。
|
||||
* 读取权限:系统管理员可读全量;院管可读本院;主任可读本科室医生。
|
||||
*/
|
||||
private assertUserReadable(
|
||||
actor: ActorContext,
|
||||
@ -718,6 +748,7 @@ export class UsersService {
|
||||
id: number;
|
||||
role: Role;
|
||||
hospitalId: number | null;
|
||||
departmentId: number | null;
|
||||
},
|
||||
) {
|
||||
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);
|
||||
}
|
||||
|
||||
/**
|
||||
* 写权限:医院管理员仅可写本院非管理员账号。
|
||||
* 写权限:院管可写本院非管理员账号;主任仅可写本科室医生。
|
||||
*/
|
||||
private assertUserWritable(
|
||||
actor: ActorContext,
|
||||
@ -749,12 +800,32 @@ export class UsersService {
|
||||
id: number;
|
||||
role: Role;
|
||||
hospitalId: number | null;
|
||||
departmentId: number | null;
|
||||
},
|
||||
) {
|
||||
if (actor.role === Role.SYSTEM_ADMIN) {
|
||||
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) {
|
||||
throw new ForbiddenException(MESSAGES.DEFAULT_FORBIDDEN);
|
||||
}
|
||||
@ -786,6 +857,10 @@ export class UsersService {
|
||||
return;
|
||||
}
|
||||
|
||||
if (actor.role === Role.DIRECTOR && nextRole !== Role.DOCTOR) {
|
||||
throw new ForbiddenException(MESSAGES.USER.DIRECTOR_SCOPE_FORBIDDEN);
|
||||
}
|
||||
|
||||
if (
|
||||
actor.role === Role.HOSPITAL_ADMIN &&
|
||||
(nextRole === Role.SYSTEM_ADMIN || nextRole === Role.HOSPITAL_ADMIN)
|
||||
@ -803,6 +878,17 @@ export class UsersService {
|
||||
actor: ActorContext,
|
||||
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) {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 签发访问令牌。
|
||||
*/
|
||||
|
||||
@ -65,6 +65,10 @@ describe('UsersController + BUsersController (e2e)', () => {
|
||||
await createDoctorUser(ctx.tokens[Role.SYSTEM_ADMIN]);
|
||||
});
|
||||
|
||||
it('成功:DIRECTOR 可创建本科室医生', async () => {
|
||||
await createDoctorUser(ctx.tokens[Role.DIRECTOR]);
|
||||
});
|
||||
|
||||
it('失败:参数校验失败返回 400', async () => {
|
||||
const response = await request(ctx.app.getHttpServer())
|
||||
.post('/users')
|
||||
@ -82,14 +86,31 @@ describe('UsersController + BUsersController (e2e)', () => {
|
||||
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({
|
||||
name: 'POST /users role matrix',
|
||||
tokens: ctx.tokens,
|
||||
expectedStatusByRole: {
|
||||
[Role.SYSTEM_ADMIN]: 400,
|
||||
[Role.HOSPITAL_ADMIN]: 400,
|
||||
[Role.DIRECTOR]: 403,
|
||||
[Role.DIRECTOR]: 400,
|
||||
[Role.LEADER]: 403,
|
||||
[Role.DOCTOR]: 403,
|
||||
[Role.ENGINEER]: 403,
|
||||
@ -152,6 +173,15 @@ describe('UsersController + BUsersController (e2e)', () => {
|
||||
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 () => {
|
||||
const response = await request(ctx.app.getHttpServer())
|
||||
.get('/users/99999999')
|
||||
@ -160,14 +190,22 @@ describe('UsersController + BUsersController (e2e)', () => {
|
||||
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({
|
||||
name: 'GET /users/:id role matrix',
|
||||
tokens: ctx.tokens,
|
||||
expectedStatusByRole: {
|
||||
[Role.SYSTEM_ADMIN]: 200,
|
||||
[Role.HOSPITAL_ADMIN]: 200,
|
||||
[Role.DIRECTOR]: 403,
|
||||
[Role.DIRECTOR]: 200,
|
||||
[Role.LEADER]: 403,
|
||||
[Role.DOCTOR]: 403,
|
||||
[Role.ENGINEER]: 403,
|
||||
@ -198,6 +236,19 @@ describe('UsersController + BUsersController (e2e)', () => {
|
||||
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 () => {
|
||||
const response = await request(ctx.app.getHttpServer())
|
||||
.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({
|
||||
name: 'PATCH /users/:id role matrix',
|
||||
tokens: ctx.tokens,
|
||||
expectedStatusByRole: {
|
||||
[Role.SYSTEM_ADMIN]: 404,
|
||||
[Role.HOSPITAL_ADMIN]: 404,
|
||||
[Role.DIRECTOR]: 403,
|
||||
[Role.DIRECTOR]: 404,
|
||||
[Role.LEADER]: 403,
|
||||
[Role.DOCTOR]: 403,
|
||||
[Role.ENGINEER]: 403,
|
||||
@ -250,6 +319,16 @@ describe('UsersController + BUsersController (e2e)', () => {
|
||||
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 () => {
|
||||
const response = await request(ctx.app.getHttpServer())
|
||||
.delete(`/users/${ctx.fixtures.users.doctorAId}`)
|
||||
@ -258,6 +337,14 @@ describe('UsersController + BUsersController (e2e)', () => {
|
||||
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 () => {
|
||||
const response = await request(ctx.app.getHttpServer())
|
||||
.delete(`/users/${ctx.fixtures.users.doctorAId}`)
|
||||
@ -266,14 +353,14 @@ describe('UsersController + BUsersController (e2e)', () => {
|
||||
expectErrorEnvelope(response, 403, '无权限执行当前操作');
|
||||
});
|
||||
|
||||
it('角色矩阵:仅 SYSTEM_ADMIN 可进入业务,其他角色 403,未登录 401', async () => {
|
||||
it('角色矩阵:SYSTEM_ADMIN/DIRECTOR 可进入业务,其他角色 403,未登录 401', async () => {
|
||||
await assertRoleMatrix({
|
||||
name: 'DELETE /users/:id role matrix',
|
||||
tokens: ctx.tokens,
|
||||
expectedStatusByRole: {
|
||||
[Role.SYSTEM_ADMIN]: 404,
|
||||
[Role.HOSPITAL_ADMIN]: 403,
|
||||
[Role.DIRECTOR]: 403,
|
||||
[Role.DIRECTOR]: 404,
|
||||
[Role.LEADER]: 403,
|
||||
[Role.DOCTOR]: 403,
|
||||
[Role.ENGINEER]: 403,
|
||||
|
||||
@ -8,6 +8,11 @@ const ORG_MANAGER_ROLES = Object.freeze([
|
||||
'DIRECTOR',
|
||||
'LEADER',
|
||||
]);
|
||||
const USER_MANAGER_ROLES = Object.freeze([
|
||||
'SYSTEM_ADMIN',
|
||||
'HOSPITAL_ADMIN',
|
||||
'DIRECTOR',
|
||||
]);
|
||||
const TASK_ROLES = Object.freeze(['DOCTOR', 'DIRECTOR', 'LEADER', 'ENGINEER']);
|
||||
const PATIENT_ROLES = Object.freeze([
|
||||
'SYSTEM_ADMIN',
|
||||
@ -21,9 +26,10 @@ const PATIENT_ROLES = Object.freeze([
|
||||
export const ROLE_PERMISSIONS = Object.freeze({
|
||||
ORG_TREE: ORG_MANAGER_ROLES,
|
||||
ORG_HOSPITALS: Object.freeze(['SYSTEM_ADMIN']),
|
||||
ORG_DEPARTMENTS: ORG_MANAGER_ROLES,
|
||||
// 主任/组长仍可通过接口读取科室信息,但不再开放独立“科室管理”页面。
|
||||
ORG_DEPARTMENTS: ADMIN_ROLES,
|
||||
ORG_GROUPS: ORG_MANAGER_ROLES,
|
||||
USERS: ADMIN_ROLES,
|
||||
USERS: USER_MANAGER_ROLES,
|
||||
DEVICES: ADMIN_ROLES,
|
||||
TASKS: TASK_ROLES,
|
||||
PATIENTS: PATIENT_ROLES,
|
||||
|
||||
@ -36,7 +36,7 @@
|
||||
<el-icon><Share /></el-icon>
|
||||
<span>组织架构图</span>
|
||||
</el-menu-item>
|
||||
<el-menu-item index="/organization/departments">
|
||||
<el-menu-item v-if="canAccessDepartments" index="/organization/departments">
|
||||
<el-icon><OfficeBuilding /></el-icon>
|
||||
<span>科室管理</span>
|
||||
</el-menu-item>
|
||||
@ -48,7 +48,7 @@
|
||||
|
||||
<el-menu-item v-if="canAccessUsers" index="/users">
|
||||
<el-icon><User /></el-icon>
|
||||
<span>用户管理</span>
|
||||
<span>{{ usersMenuLabel }}</span>
|
||||
</el-menu-item>
|
||||
|
||||
<el-menu-item v-if="canAccessDevices" index="/devices">
|
||||
@ -129,6 +129,7 @@ const activeMenu = computed(() => {
|
||||
return route.path;
|
||||
});
|
||||
|
||||
const isDirector = computed(() => userStore.role === 'DIRECTOR');
|
||||
const canAccessUsers = computed(() =>
|
||||
hasRolePermission(userStore.role, ROLE_PERMISSIONS.USERS),
|
||||
);
|
||||
@ -138,12 +139,18 @@ const canAccessDevices = computed(() =>
|
||||
const canAccessOrgTree = computed(() =>
|
||||
hasRolePermission(userStore.role, ROLE_PERMISSIONS.ORG_TREE),
|
||||
);
|
||||
const canAccessDepartments = computed(() =>
|
||||
hasRolePermission(userStore.role, ROLE_PERMISSIONS.ORG_DEPARTMENTS),
|
||||
);
|
||||
const canAccessTasks = computed(() =>
|
||||
hasRolePermission(userStore.role, ROLE_PERMISSIONS.TASKS),
|
||||
);
|
||||
const canAccessPatients = computed(() =>
|
||||
hasRolePermission(userStore.role, ROLE_PERMISSIONS.PATIENTS),
|
||||
);
|
||||
const usersMenuLabel = computed(() =>
|
||||
isDirector.value ? '医生管理' : '用户管理',
|
||||
);
|
||||
|
||||
const handleCommand = (command) => {
|
||||
if (command === 'logout') {
|
||||
|
||||
@ -5,17 +5,13 @@
|
||||
<p>当前角色:{{ userStore.role || '未登录' }}</p>
|
||||
</el-card>
|
||||
|
||||
<el-card
|
||||
v-if="isSystemAdmin"
|
||||
shadow="never"
|
||||
class="filter-card"
|
||||
>
|
||||
<el-card v-if="isSystemAdmin" shadow="never" class="filter-card">
|
||||
<el-form inline>
|
||||
<el-form-item label="患者统计医院">
|
||||
<el-select
|
||||
v-model="selectedHospitalId"
|
||||
placeholder="请选择医院"
|
||||
style="width: 280px;"
|
||||
style="width: 280px"
|
||||
@change="fetchDashboardData"
|
||||
>
|
||||
<el-option
|
||||
@ -30,7 +26,13 @@
|
||||
</el-card>
|
||||
|
||||
<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">
|
||||
<div class="stat-title">{{ item.title }}</div>
|
||||
<div class="stat-value">{{ item.value }}</div>
|
||||
@ -75,19 +77,44 @@ const isSystemAdmin = computed(() => userStore.role === 'SYSTEM_ADMIN');
|
||||
const canViewOrg = computed(() =>
|
||||
['SYSTEM_ADMIN', 'HOSPITAL_ADMIN'].includes(userStore.role),
|
||||
);
|
||||
const canViewUsers = computed(() =>
|
||||
['SYSTEM_ADMIN', 'HOSPITAL_ADMIN', 'DIRECTOR'].includes(userStore.role),
|
||||
);
|
||||
const canViewPatients = computed(() =>
|
||||
['SYSTEM_ADMIN', 'HOSPITAL_ADMIN', 'DIRECTOR', 'LEADER', 'DOCTOR'].includes(
|
||||
userStore.role,
|
||||
),
|
||||
);
|
||||
|
||||
const statCards = computed(() => [
|
||||
{ key: 'hospitals', title: '医院总数', value: stats.value.hospitals },
|
||||
{ key: 'departments', title: '科室总数', value: stats.value.departments },
|
||||
{ key: 'groups', title: '小组总数', value: stats.value.groups },
|
||||
{ key: 'users', title: '用户总数', value: stats.value.users },
|
||||
{ key: 'patients', title: '可见患者数', value: stats.value.patients },
|
||||
]);
|
||||
const statCards = computed(() => {
|
||||
const cards = [];
|
||||
|
||||
if (canViewOrg.value) {
|
||||
cards.push(
|
||||
{ key: 'hospitals', title: '医院总数', value: stats.value.hospitals },
|
||||
{ key: 'departments', title: '科室总数', value: stats.value.departments },
|
||||
{ key: 'groups', title: '小组总数', value: stats.value.groups },
|
||||
);
|
||||
}
|
||||
|
||||
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 () => {
|
||||
if (!isSystemAdmin.value) {
|
||||
@ -104,16 +131,23 @@ const fetchDashboardData = async () => {
|
||||
loading.value = true;
|
||||
try {
|
||||
if (canViewOrg.value) {
|
||||
const [hospitalRes, departmentRes, groupRes, usersRes] = await Promise.all([
|
||||
const [hospitalRes, departmentRes, groupRes] = await Promise.all([
|
||||
getHospitals({ page: 1, pageSize: 1 }),
|
||||
getDepartments({ page: 1, pageSize: 1 }),
|
||||
getGroups({ page: 1, pageSize: 1 }),
|
||||
getUsers({ page: 1, pageSize: 1 }),
|
||||
]);
|
||||
|
||||
stats.value.hospitals = hospitalRes.total ?? 0;
|
||||
stats.value.departments = departmentRes.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;
|
||||
}
|
||||
|
||||
@ -126,7 +160,9 @@ const fetchDashboardData = async () => {
|
||||
stats.value.patients = 0;
|
||||
} else {
|
||||
const patientRes = await getPatients(params);
|
||||
stats.value.patients = Array.isArray(patientRes) ? patientRes.length : 0;
|
||||
stats.value.patients = Array.isArray(patientRes)
|
||||
? patientRes.length
|
||||
: 0;
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
|
||||
@ -10,18 +10,18 @@
|
||||
clearable
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="角色">
|
||||
<el-form-item v-if="!isDirector" label="角色">
|
||||
<el-select
|
||||
v-model="searchForm.role"
|
||||
placeholder="请选择角色"
|
||||
clearable
|
||||
>
|
||||
<el-option label="系统管理员" value="SYSTEM_ADMIN" />
|
||||
<el-option label="医院管理员" value="HOSPITAL_ADMIN" />
|
||||
<el-option label="科室主任" value="DIRECTOR" />
|
||||
<el-option label="小组组长" value="LEADER" />
|
||||
<el-option label="医生" value="DOCTOR" />
|
||||
<el-option label="工程师" value="ENGINEER" />
|
||||
<el-option
|
||||
v-for="option in roleOptions"
|
||||
:key="option.value"
|
||||
:label="option.label"
|
||||
:value="option.value"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
@ -30,7 +30,7 @@
|
||||
</el-button>
|
||||
<el-button @click="resetSearch" icon="Refresh">重置</el-button>
|
||||
<el-button type="success" @click="openCreateDialog" icon="Plus">
|
||||
新增用户
|
||||
{{ createButtonText }}
|
||||
</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
@ -84,7 +84,7 @@
|
||||
编辑
|
||||
</el-button>
|
||||
<el-button
|
||||
v-if="userStore.role === 'SYSTEM_ADMIN'"
|
||||
v-if="canDeleteUser(row)"
|
||||
size="small"
|
||||
type="danger"
|
||||
@click="handleDelete(row)"
|
||||
@ -110,7 +110,7 @@
|
||||
</el-card>
|
||||
|
||||
<el-dialog
|
||||
:title="isEdit ? '编辑用户' : '新增用户'"
|
||||
:title="dialogTitle"
|
||||
v-model="dialogVisible"
|
||||
width="620px"
|
||||
@close="resetForm"
|
||||
@ -131,17 +131,23 @@
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="角色" prop="role">
|
||||
<el-input
|
||||
v-if="isDirector"
|
||||
:model-value="getRoleName('DOCTOR')"
|
||||
disabled
|
||||
/>
|
||||
<el-select
|
||||
v-else
|
||||
v-model="form.role"
|
||||
placeholder="请选择角色"
|
||||
style="width: 100%"
|
||||
>
|
||||
<el-option label="系统管理员" value="SYSTEM_ADMIN" />
|
||||
<el-option label="医院管理员" value="HOSPITAL_ADMIN" />
|
||||
<el-option label="科室主任" value="DIRECTOR" />
|
||||
<el-option label="小组组长" value="LEADER" />
|
||||
<el-option label="医生" value="DOCTOR" />
|
||||
<el-option label="工程师" value="ENGINEER" />
|
||||
<el-option
|
||||
v-for="option in roleOptions"
|
||||
:key="option.value"
|
||||
:label="option.label"
|
||||
:value="option.value"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
|
||||
@ -150,6 +156,7 @@
|
||||
v-model="form.hospitalId"
|
||||
placeholder="请选择医院"
|
||||
style="width: 100%"
|
||||
:disabled="lockHospital"
|
||||
>
|
||||
<el-option
|
||||
v-for="hospital in hospitals"
|
||||
@ -169,6 +176,7 @@
|
||||
v-model="form.departmentId"
|
||||
placeholder="请选择科室"
|
||||
style="width: 100%"
|
||||
:disabled="lockDepartment"
|
||||
>
|
||||
<el-option
|
||||
v-for="department in formDepartments"
|
||||
@ -266,6 +274,15 @@ import {
|
||||
} from '../../api/organization';
|
||||
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 userStore = useUserStore();
|
||||
|
||||
@ -279,9 +296,10 @@ const hospitals = ref([]);
|
||||
const departments = ref([]);
|
||||
const groups = ref([]);
|
||||
|
||||
const isDirector = computed(() => userStore.role === 'DIRECTOR');
|
||||
const searchForm = reactive({
|
||||
keyword: '',
|
||||
role: '',
|
||||
role: isDirector.value ? 'DOCTOR' : '',
|
||||
});
|
||||
|
||||
const dialogVisible = ref(false);
|
||||
@ -306,11 +324,24 @@ const assignDialogVisible = ref(false);
|
||||
const currentAssignUser = 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 needDepartment = computed(() =>
|
||||
['DIRECTOR', '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(() => ({
|
||||
name: [{ required: true, message: '请输入姓名', trigger: 'blur' }],
|
||||
@ -379,6 +410,16 @@ const resolveGroupName = (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 [hospitalRes, departmentRes, groupRes] = await Promise.all([
|
||||
getHospitals({ page: 1, pageSize: 100 }),
|
||||
@ -427,7 +468,7 @@ const fetchData = async () => {
|
||||
const res = await getUsers({
|
||||
page: page.value,
|
||||
pageSize: pageSize.value,
|
||||
role: searchForm.role || undefined,
|
||||
role: (isDirector.value ? 'DOCTOR' : searchForm.role) || undefined,
|
||||
keyword: searchForm.keyword || undefined,
|
||||
});
|
||||
tableData.value = res.list || [];
|
||||
@ -444,15 +485,29 @@ const handleSearch = () => {
|
||||
|
||||
const resetSearch = () => {
|
||||
searchForm.keyword = '';
|
||||
searchForm.role = '';
|
||||
searchForm.role = isDirector.value ? 'DOCTOR' : '';
|
||||
page.value = 1;
|
||||
fetchData();
|
||||
};
|
||||
|
||||
const openCreateDialog = async () => {
|
||||
resetForm();
|
||||
isEdit.value = false;
|
||||
currentId.value = null;
|
||||
dialogVisible.value = true;
|
||||
|
||||
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;
|
||||
return;
|
||||
}
|
||||
|
||||
if (userStore.role === 'HOSPITAL_ADMIN') {
|
||||
form.hospitalId = userStore.userInfo?.hospitalId || null;
|
||||
@ -460,9 +515,16 @@ const openCreateDialog = async () => {
|
||||
await fetchDepartmentsForForm(form.hospitalId);
|
||||
}
|
||||
}
|
||||
|
||||
dialogVisible.value = true;
|
||||
};
|
||||
|
||||
const openEditDialog = async (row) => {
|
||||
if (isDirector.value && row.role !== 'DOCTOR') {
|
||||
ElMessage.warning('主任仅可编辑本科室医生');
|
||||
return;
|
||||
}
|
||||
|
||||
isEdit.value = true;
|
||||
currentId.value = row.id;
|
||||
|
||||
@ -505,6 +567,31 @@ const resetForm = () => {
|
||||
};
|
||||
|
||||
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 = {
|
||||
name: form.name,
|
||||
phone: form.phone,
|
||||
@ -575,6 +662,9 @@ const handleSubmit = async () => {
|
||||
submitLoading.value = true;
|
||||
try {
|
||||
const payload = buildSubmitPayload();
|
||||
if (!payload) {
|
||||
return;
|
||||
}
|
||||
if (isEdit.value) {
|
||||
await updateUser(currentId.value, payload);
|
||||
ElMessage.success('更新成功');
|
||||
@ -592,6 +682,11 @@ const handleSubmit = async () => {
|
||||
};
|
||||
|
||||
const handleDelete = (row) => {
|
||||
if (isDirector.value && row.role !== 'DOCTOR') {
|
||||
ElMessage.warning('主任仅可删除本科室医生');
|
||||
return;
|
||||
}
|
||||
|
||||
ElMessageBox.confirm(`确定要删除用户 "${row.name}" 吗?`, '警告', {
|
||||
confirmButtonText: '确定',
|
||||
cancelButtonText: '取消',
|
||||
@ -698,16 +793,24 @@ onMounted(async () => {
|
||||
|
||||
if (route.query.action === 'create') {
|
||||
await openCreateDialog();
|
||||
if (route.query.hospitalId) {
|
||||
if (!isDirector.value && route.query.hospitalId) {
|
||||
form.hospitalId = Number(route.query.hospitalId);
|
||||
await fetchDepartmentsForForm(form.hospitalId);
|
||||
}
|
||||
if (route.query.departmentId) {
|
||||
if (!isDirector.value && route.query.departmentId) {
|
||||
form.departmentId = Number(route.query.departmentId);
|
||||
await fetchGroupsForForm(form.departmentId);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const canDeleteUser = (row) => {
|
||||
if (userStore.role === 'SYSTEM_ADMIN') {
|
||||
return true;
|
||||
}
|
||||
|
||||
return isDirector.value && row.role === 'DOCTOR';
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user