feat(auth-org): 强化用户权限边界并完善组织负责人配置展示

feat(admin-ui): 医院管理显示医院管理员并限制候选角色
feat(security): 关闭注册入口,新增 system-admin 创建链路与数据脱敏
This commit is contained in:
EL 2026-03-18 17:05:36 +08:00
parent 602694814f
commit b527256874
22 changed files with 1084 additions and 261 deletions

View File

@ -40,11 +40,17 @@ docs/
```env
DATABASE_URL="postgresql://user:password@127.0.0.1:5432/tyt?schema=public"
JWT_SECRET="请替换为强随机密钥"
AUTH_TOKEN_SECRET="请替换为强随机密钥"
JWT_EXPIRES_IN="7d"
SYSTEM_ADMIN_BOOTSTRAP_KEY="初始化系统管理员用密钥"
```
管理员创建链路:
- 可通过 `POST /auth/system-admin` 创建系统管理员(需引导密钥)。
- 系统管理员负责创建医院、系统管理员与医院管理员。
- 医院管理员负责创建本院下级角色(主任/组长/医生/工程师)。
## 4. 启动流程
```bash

View File

@ -2,12 +2,12 @@
## 1. 目标
- 提供注册、登录、`/me` 身份查询。
- 提供系统管理员创建、登录、`/me` 身份查询。
- 使用 JWT 做认证Guard 做鉴权RolesGuard 做 RBAC。
## 2. 核心接口
- `POST /auth/register`:注册账号(支持医生/工程师/院管等角色约束
- `POST /auth/system-admin`:创建系统管理员(需引导密钥
- `POST /auth/login`:手机号 + 角色 + 密码登录(支持同手机号多院场景)
- `GET /auth/me`:返回当前登录用户上下文
@ -15,13 +15,13 @@
1. `AccessTokenGuard``Authorization` 读取 Bearer Token。
2. 校验 JWT 签名与载荷字段。
3. 载荷映射为 `ActorContext` 注入 `request.user`。
3. 载荷映射为 `ActorContext` 注入 `request.actor`。
4. `RolesGuard` 根据 `@Roles(...)` 判断角色是否允许访问。
## 4. Token 约定
- Header`Authorization: Bearer <token>`
- 载荷关键字段:`sub`、`role``hospitalId``departmentId``groupId`
- 载荷关键字段:`id`、`role``hospitalId``departmentId``groupId`
## 5. 错误码与中文消息

View File

@ -71,23 +71,24 @@ model Group {
// 用户表:支持后台密码登录与小程序 openId。
model User {
id Int @id @default(autoincrement())
name String
phone String
id Int @id @default(autoincrement())
name String
phone String
// 后台登录密码哈希bcrypt
passwordHash String?
openId String? @unique
role Role
hospitalId Int?
departmentId Int?
groupId Int?
hospital Hospital? @relation(fields: [hospitalId], references: [id])
department Department? @relation(fields: [departmentId], references: [id])
group Group? @relation(fields: [groupId], references: [id])
doctorPatients Patient[] @relation("DoctorPatients")
createdTasks Task[] @relation("TaskCreator")
acceptedTasks Task[] @relation("TaskEngineer")
passwordHash String?
openId String? @unique
role Role
hospitalId Int?
departmentId Int?
groupId Int?
hospital Hospital? @relation(fields: [hospitalId], references: [id])
department Department? @relation(fields: [departmentId], references: [id])
group Group? @relation(fields: [groupId], references: [id])
doctorPatients Patient[] @relation("DoctorPatients")
createdTasks Task[] @relation("TaskCreator")
acceptedTasks Task[] @relation("TaskEngineer")
@@unique([phone, role, hospitalId])
@@index([phone])
@@index([hospitalId, role])
@@index([departmentId, role])

View File

@ -1,18 +1,14 @@
import { Body, Controller, Get, Post, UseGuards } from '@nestjs/common';
import {
ApiBearerAuth,
ApiOperation,
ApiTags,
} from '@nestjs/swagger';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { AuthService } from './auth.service.js';
import { RegisterUserDto } from '../users/dto/register-user.dto.js';
import { LoginDto } from '../users/dto/login.dto.js';
import { AccessTokenGuard } from './access-token.guard.js';
import { CurrentActor } from './current-actor.decorator.js';
import type { ActorContext } from '../common/actor-context.js';
import { CreateSystemAdminDto } from './dto/create-system-admin.dto.js';
/**
*
*
*/
@ApiTags('认证')
@Controller('auth')
@ -20,12 +16,12 @@ export class AuthController {
constructor(private readonly authService: AuthService) {}
/**
*
*
*/
@Post('register')
@ApiOperation({ summary: '注册账号' })
register(@Body() dto: RegisterUserDto) {
return this.authService.register(dto);
@Post('system-admin')
@ApiOperation({ summary: '创建系统管理员' })
createSystemAdmin(@Body() dto: CreateSystemAdminDto) {
return this.authService.createSystemAdmin(dto);
}
/**

View File

@ -1,8 +1,8 @@
import { Injectable } from '@nestjs/common';
import type { ActorContext } from '../common/actor-context.js';
import { UsersService } from '../users/users.service.js';
import { RegisterUserDto } from '../users/dto/register-user.dto.js';
import { LoginDto } from '../users/dto/login.dto.js';
import { CreateSystemAdminDto } from './dto/create-system-admin.dto.js';
/**
*
@ -12,10 +12,10 @@ export class AuthService {
constructor(private readonly usersService: UsersService) {}
/**
*
*
*/
register(dto: RegisterUserDto) {
return this.usersService.register(dto);
createSystemAdmin(dto: CreateSystemAdminDto) {
return this.usersService.createSystemAdmin(dto);
}
/**

View File

@ -0,0 +1,32 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsOptional, IsString } from 'class-validator';
export class CreateSystemAdminDto {
@ApiProperty({ description: '姓名', example: '系统管理员' })
@IsString({ message: 'name 必须是字符串' })
name!: string;
@ApiProperty({ description: '手机号', example: '13800000000' })
@IsString({ message: 'phone 必须是字符串' })
phone!: string;
@ApiProperty({ description: '密码(至少 8 位)', example: 'Admin@12345' })
@IsString({ message: 'password 必须是字符串' })
password!: string;
@ApiPropertyOptional({
description: '可选微信 openId',
example: 'o123abcxyz',
})
@IsOptional()
@IsString({ message: 'openId 必须是字符串' })
openId?: string;
@ApiProperty({
description:
'系统管理员创建引导密钥(来自环境变量 SYSTEM_ADMIN_BOOTSTRAP_KEY',
example: 'init-admin-secret',
})
@IsString({ message: 'systemAdminBootstrapKey 必须是字符串' })
systemAdminBootstrapKey!: string;
}

View File

@ -25,6 +25,7 @@ export const MESSAGES = {
TOKEN_FIELD_INVALID: 'Token 中字段不合法',
INVALID_CREDENTIALS: '手机号、角色或密码错误',
PASSWORD_NOT_ENABLED: '该账号未启用密码登录',
REGISTER_DISABLED: '注册接口已关闭,请联系管理员创建账号',
},
USER: {
@ -53,6 +54,8 @@ export const MESSAGES = {
DELETE_CONFLICT: '用户存在关联患者或任务,无法删除',
MULTI_ACCOUNT_REQUIRE_HOSPITAL:
'检测到多个同手机号账号,请传 hospitalId 指定登录医院',
CREATE_FORBIDDEN: '当前角色无权限创建该用户',
HOSPITAL_ADMIN_SCOPE_FORBIDDEN: '医院管理员仅可操作本院非管理员账号',
},
TASK: {

View File

@ -10,7 +10,7 @@ import { ResponseEnvelopeInterceptor } from './common/response-envelope.intercep
async function bootstrap() {
// 创建应用实例并加载核心模块。
const app = await NestFactory.create(AppModule);
app.enableCors();
// 全局校验:清理未知字段、自动类型转换,并将校验错误统一为中文信息。
app.useGlobalPipes(
new ValidationPipe({
@ -39,7 +39,7 @@ async function bootstrap() {
.setTitle('TYT 多租户医疗调压系统 API')
.setDescription('后端接口文档含认证、RBAC、任务流转与患者聚合')
.setVersion('1.0.0')
.addServer('http://localhost:3000', 'localhost')
.addServer('http://192.168.0.140:3000', 'localhost')
.addBearerAuth(
{
type: 'http',

View File

@ -15,7 +15,10 @@ export class WechatNotifyService {
/**
* / API
*/
async notifyTaskChange(openIds: Array<string | null | undefined>, payload: TaskNotifyPayload) {
async notifyTaskChange(
openIds: Array<string | null | undefined>,
payload: TaskNotifyPayload,
) {
const targets = Array.from(
new Set(
openIds
@ -32,10 +35,22 @@ export class WechatNotifyService {
}
for (const openId of targets) {
const maskedOpenId = this.maskOpenId(openId);
// TODO: 在此处调用微信服务号/小程序消息推送 API。
this.logger.log(
`模拟推送任务通知 event=${payload.event}, taskId=${payload.taskId}, openId=${openId}`,
`模拟推送任务通知 event=${payload.event}, taskId=${payload.taskId}, openId=${maskedOpenId}`,
);
}
}
/**
* openId
*/
private maskOpenId(openId: string) {
if (openId.length <= 6) {
return '***';
}
return `${openId.slice(0, 3)}***${openId.slice(-3)}`;
}
}

View File

@ -1,5 +1,11 @@
import { Controller, Get, Query } from '@nestjs/common';
import { ApiOperation, ApiQuery, ApiTags } from '@nestjs/swagger';
import { Controller, Get, Query, UseGuards } from '@nestjs/common';
import {
ApiBearerAuth,
ApiOperation,
ApiQuery,
ApiTags,
} from '@nestjs/swagger';
import { AccessTokenGuard } from '../../auth/access-token.guard.js';
import { FamilyLifecycleQueryDto } from '../dto/family-lifecycle-query.dto.js';
import { CPatientsService } from './c-patients.service.js';
@ -7,7 +13,9 @@ import { CPatientsService } from './c-patients.service.js';
* C
*/
@ApiTags('患者管理(C端)')
@ApiBearerAuth('bearer')
@Controller('c/patients')
@UseGuards(AccessTokenGuard)
export class CPatientsController {
constructor(private readonly patientsService: CPatientsService) {}

View File

@ -1,4 +1,8 @@
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import {
BadRequestException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { PrismaService } from '../../prisma.service.js';
import { MESSAGES } from '../../common/messages.js';
@ -57,7 +61,6 @@ export class CPatientsService {
patient: {
id: this.toJsonNumber(patient.id),
name: patient.name,
phone: patient.phone,
},
device: {
id: this.toJsonNumber(device.id),
@ -68,9 +71,6 @@ export class CPatientsService {
task: {
id: this.toJsonNumber(task.id),
status: task.status,
creatorId: this.toJsonNumber(task.creatorId),
engineerId: this.toJsonNumber(task.engineerId),
hospitalId: this.toJsonNumber(task.hospitalId),
createdAt: task.createdAt,
},
taskItem: {
@ -89,8 +89,6 @@ export class CPatientsService {
);
return {
phone,
idCardHash,
patientCount: patients.length,
lifecycle,
};

View File

@ -43,7 +43,9 @@ export class TaskService {
throw new BadRequestException(`deviceId 非法: ${item.deviceId}`);
}
if (!Number.isInteger(item.targetPressure)) {
throw new BadRequestException(`targetPressure 非法: ${item.targetPressure}`);
throw new BadRequestException(
`targetPressure 非法: ${item.targetPressure}`,
);
}
return item.deviceId;
}),
@ -138,14 +140,30 @@ export class TaskService {
throw new ForbiddenException(MESSAGES.TASK.ENGINEER_ALREADY_ASSIGNED);
}
const updatedTask = await this.prisma.task.update({
where: { id: task.id },
const accepted = await this.prisma.task.updateMany({
where: {
id: task.id,
hospitalId,
status: TaskStatus.PENDING,
OR: [{ engineerId: null }, { engineerId: actor.id }],
},
data: {
status: TaskStatus.ACCEPTED,
engineerId: actor.id,
},
});
if (accepted.count !== 1) {
throw new ConflictException(MESSAGES.TASK.ACCEPT_ONLY_PENDING);
}
const updatedTask = await this.prisma.task.findUnique({
where: { id: task.id },
include: { items: true },
});
if (!updatedTask) {
throw new NotFoundException(MESSAGES.TASK.TASK_NOT_FOUND);
}
await this.eventEmitter.emitAsync('task.accepted', {
taskId: updatedTask.id,

View File

@ -40,20 +40,18 @@ export class UsersController {
@Post()
@Roles(Role.SYSTEM_ADMIN, Role.HOSPITAL_ADMIN)
@ApiOperation({ summary: '创建用户' })
create(@Body() createUserDto: CreateUserDto) {
return this.usersService.create(createUserDto);
create(
@CurrentActor() actor: ActorContext,
@Body() createUserDto: CreateUserDto,
) {
return this.usersService.create(actor, createUserDto);
}
/**
*
*/
@Get()
@Roles(
Role.SYSTEM_ADMIN,
Role.HOSPITAL_ADMIN,
Role.DIRECTOR,
Role.LEADER,
)
@Roles(Role.SYSTEM_ADMIN, Role.HOSPITAL_ADMIN, Role.DIRECTOR, Role.LEADER)
@ApiOperation({ summary: '查询用户列表' })
findAll(@CurrentActor() actor: ActorContext) {
return this.usersService.findAll(actor);
@ -66,8 +64,8 @@ export class UsersController {
@Roles(Role.SYSTEM_ADMIN, Role.HOSPITAL_ADMIN)
@ApiOperation({ summary: '查询用户详情' })
@ApiParam({ name: 'id', description: '用户 ID' })
findOne(@Param('id') id: string) {
return this.usersService.findOne(+id);
findOne(@CurrentActor() actor: ActorContext, @Param('id') id: string) {
return this.usersService.findOne(actor, +id);
}
/**
@ -77,8 +75,12 @@ export class UsersController {
@Roles(Role.SYSTEM_ADMIN, Role.HOSPITAL_ADMIN)
@ApiOperation({ summary: '更新用户' })
@ApiParam({ name: 'id', description: '用户 ID' })
update(@Param('id') id: string, @Body() updateUserDto: UpdateUserDto) {
return this.usersService.update(+id, updateUserDto);
update(
@CurrentActor() actor: ActorContext,
@Param('id') id: string,
@Body() updateUserDto: UpdateUserDto,
) {
return this.usersService.update(actor, +id, updateUserDto);
}
/**
@ -88,7 +90,7 @@ export class UsersController {
@Roles(Role.SYSTEM_ADMIN)
@ApiOperation({ summary: '删除用户' })
@ApiParam({ name: 'id', description: '用户 ID' })
remove(@Param('id') id: string) {
return this.usersService.remove(+id);
remove(@CurrentActor() actor: ActorContext, @Param('id') id: string) {
return this.usersService.remove(actor, +id);
}
}

View File

@ -15,9 +15,9 @@ import { Role } from '../generated/prisma/enums.js';
import { PrismaService } from '../prisma.service.js';
import type { ActorContext } from '../common/actor-context.js';
import { AssignEngineerHospitalDto } from './dto/assign-engineer-hospital.dto.js';
import { RegisterUserDto } from './dto/register-user.dto.js';
import { LoginDto } from './dto/login.dto.js';
import { MESSAGES } from '../common/messages.js';
import { CreateSystemAdminDto } from '../auth/dto/create-system-admin.dto.js';
const SAFE_USER_SELECT = {
id: true,
@ -35,25 +35,27 @@ export class UsersService {
constructor(private readonly prisma: PrismaService) {}
/**
* bcrypt
*
*/
async register(dto: RegisterUserDto) {
const role = this.normalizeRole(dto.role);
async register() {
throw new ForbiddenException(MESSAGES.AUTH.REGISTER_DISABLED);
}
/**
*
*/
async createSystemAdmin(dto: CreateSystemAdminDto) {
const name = this.normalizeRequiredString(dto.name, 'name');
const phone = this.normalizePhone(dto.phone);
const password = this.normalizePassword(dto.password);
const openId = this.normalizeOptionalString(dto.openId);
const hospitalId = this.normalizeOptionalInt(dto.hospitalId, 'hospitalId');
const departmentId = this.normalizeOptionalInt(
dto.departmentId,
'departmentId',
);
const groupId = this.normalizeOptionalInt(dto.groupId, 'groupId');
this.assertSystemAdminBootstrapKey(role, dto.systemAdminBootstrapKey);
await this.assertOrganizationScope(role, hospitalId, departmentId, groupId);
this.assertSystemAdminBootstrapKey(
Role.SYSTEM_ADMIN,
dto.systemAdminBootstrapKey,
);
await this.assertOpenIdUnique(openId);
await this.assertPhoneRoleScopeUnique(phone, role, hospitalId);
await this.assertPhoneRoleScopeUnique(phone, Role.SYSTEM_ADMIN, null);
const passwordHash = await hash(password, 12);
@ -63,10 +65,10 @@ export class UsersService {
phone,
passwordHash,
openId,
role,
hospitalId,
departmentId,
groupId,
role: Role.SYSTEM_ADMIN,
hospitalId: null,
departmentId: null,
groupId: null,
},
select: SAFE_USER_SELECT,
});
@ -133,13 +135,13 @@ export class UsersService {
*
*/
async me(actor: ActorContext) {
return this.findOne(actor.id);
return this.findOne(actor, actor.id);
}
/**
* B 使
*/
async create(createUserDto: CreateUserDto) {
async create(actor: ActorContext, createUserDto: CreateUserDto) {
const role = this.normalizeRole(createUserDto.role);
const name = this.normalizeRequiredString(createUserDto.name, 'name');
const phone = this.normalizePhone(createUserDto.phone);
@ -157,9 +159,22 @@ export class UsersService {
);
const groupId = this.normalizeOptionalInt(createUserDto.groupId, 'groupId');
await this.assertOrganizationScope(role, hospitalId, departmentId, groupId);
const scoped = this.resolveCreateScope(
actor,
role,
hospitalId,
departmentId,
groupId,
);
await this.assertOrganizationScope(
role,
scoped.hospitalId,
scoped.departmentId,
scoped.groupId,
);
await this.assertOpenIdUnique(openId);
await this.assertPhoneRoleScopeUnique(phone, role, hospitalId);
await this.assertPhoneRoleScopeUnique(phone, role, scoped.hospitalId);
return this.prisma.user.create({
data: {
@ -168,9 +183,9 @@ export class UsersService {
passwordHash: password ? await hash(password, 12) : null,
openId,
role,
hospitalId,
departmentId,
groupId,
hospitalId: scoped.hospitalId,
departmentId: scoped.departmentId,
groupId: scoped.groupId,
},
select: SAFE_USER_SELECT,
});
@ -210,7 +225,7 @@ export class UsersService {
/**
*
*/
async findOne(id: number) {
async findOne(actor: ActorContext, id: number) {
const userId = this.normalizeRequiredInt(id, 'id');
const user = await this.prisma.user.findUnique({
@ -221,13 +236,15 @@ export class UsersService {
throw new NotFoundException(MESSAGES.USER.NOT_FOUND);
}
this.assertUserReadable(actor, user);
return user;
}
/**
*
*/
async update(id: number, updateUserDto: UpdateUserDto) {
async update(actor: ActorContext, id: number, updateUserDto: UpdateUserDto) {
const userId = this.normalizeRequiredInt(id, 'id');
const current = await this.prisma.user.findUnique({
where: { id: userId },
@ -240,8 +257,12 @@ export class UsersService {
throw new NotFoundException(MESSAGES.USER.NOT_FOUND);
}
this.assertUserWritable(actor, current);
const nextRole =
updateUserDto.role != null ? this.normalizeRole(updateUserDto.role) : current.role;
updateUserDto.role != null
? this.normalizeRole(updateUserDto.role)
: current.role;
const nextHospitalId =
updateUserDto.hospitalId !== undefined
? this.normalizeOptionalInt(updateUserDto.hospitalId, 'hospitalId')
@ -255,6 +276,9 @@ export class UsersService {
? this.normalizeOptionalInt(updateUserDto.groupId, 'groupId')
: current.groupId;
this.assertUpdateTargetRoleAllowed(actor, nextRole);
this.assertUpdateHospitalScopeAllowed(actor, nextHospitalId);
const assigningDepartmentOrGroup =
(updateUserDto.departmentId !== undefined && nextDepartmentId != null) ||
(updateUserDto.groupId !== undefined && nextGroupId != null);
@ -329,9 +353,10 @@ export class UsersService {
/**
*
*/
async remove(id: number) {
async remove(actor: ActorContext, id: number) {
const userId = this.normalizeRequiredInt(id, 'id');
await this.findOne(userId);
const target = await this.findOne(actor, userId);
this.assertUserWritable(actor, target);
try {
return await this.prisma.user.delete({
@ -397,7 +422,9 @@ export class UsersService {
/**
*
*/
private toSafeUser(user: { passwordHash?: string | null } & Record<string, unknown>) {
private toSafeUser(
user: { passwordHash?: string | null } & Record<string, unknown>,
) {
const { passwordHash, ...safe } = user;
return safe;
}
@ -512,7 +539,9 @@ export class UsersService {
select: { id: true, hospitalId: true },
});
if (!department || department.hospitalId !== hospitalId) {
throw new BadRequestException(MESSAGES.USER.DEPARTMENT_HOSPITAL_MISMATCH);
throw new BadRequestException(
MESSAGES.USER.DEPARTMENT_HOSPITAL_MISMATCH,
);
}
}
@ -630,6 +659,163 @@ export class UsersService {
return role as Role;
}
/**
*
*/
private resolveCreateScope(
actor: ActorContext,
targetRole: Role,
hospitalId: number | null,
departmentId: number | null,
groupId: number | null,
) {
if (actor.role === Role.SYSTEM_ADMIN) {
if (targetRole === Role.SYSTEM_ADMIN) {
return { hospitalId: null, departmentId: null, groupId: null };
}
// 系统管理员可创建任意角色,具体归属由后续组织范围校验保证合法。
return { hospitalId, departmentId, groupId };
}
if (actor.role !== Role.HOSPITAL_ADMIN) {
throw new ForbiddenException(MESSAGES.USER.CREATE_FORBIDDEN);
}
if (
targetRole === Role.SYSTEM_ADMIN ||
targetRole === Role.HOSPITAL_ADMIN
) {
throw new ForbiddenException(MESSAGES.USER.CREATE_FORBIDDEN);
}
const actorHospitalId = this.requireActorScopeInt(
actor.hospitalId,
MESSAGES.ORG.ACTOR_HOSPITAL_REQUIRED,
);
const scopedHospitalId = hospitalId ?? actorHospitalId;
if (scopedHospitalId !== actorHospitalId) {
throw new ForbiddenException(
MESSAGES.USER.HOSPITAL_ADMIN_SCOPE_FORBIDDEN,
);
}
return {
hospitalId: scopedHospitalId,
departmentId,
groupId,
};
}
/**
*
*/
private assertUserReadable(
actor: ActorContext,
target: Pick<typeof SAFE_USER_SELECT, never> & {
id: number;
role: Role;
hospitalId: number | null;
},
) {
if (actor.role === Role.SYSTEM_ADMIN) {
return;
}
if (actor.id === target.id) {
return;
}
if (actor.role === Role.HOSPITAL_ADMIN) {
const actorHospitalId = this.requireActorScopeInt(
actor.hospitalId,
MESSAGES.ORG.ACTOR_HOSPITAL_REQUIRED,
);
if (target.hospitalId === actorHospitalId) {
return;
}
}
throw new ForbiddenException(MESSAGES.DEFAULT_FORBIDDEN);
}
/**
*
*/
private assertUserWritable(
actor: ActorContext,
target: {
id: number;
role: Role;
hospitalId: number | null;
},
) {
if (actor.role === Role.SYSTEM_ADMIN) {
return;
}
if (actor.role !== Role.HOSPITAL_ADMIN) {
throw new ForbiddenException(MESSAGES.DEFAULT_FORBIDDEN);
}
const actorHospitalId = this.requireActorScopeInt(
actor.hospitalId,
MESSAGES.ORG.ACTOR_HOSPITAL_REQUIRED,
);
if (target.hospitalId !== actorHospitalId) {
throw new ForbiddenException(
MESSAGES.USER.HOSPITAL_ADMIN_SCOPE_FORBIDDEN,
);
}
if (
target.role === Role.HOSPITAL_ADMIN ||
target.role === Role.SYSTEM_ADMIN
) {
throw new ForbiddenException(
MESSAGES.USER.HOSPITAL_ADMIN_SCOPE_FORBIDDEN,
);
}
}
/**
*
*/
private assertUpdateTargetRoleAllowed(actor: ActorContext, nextRole: Role) {
if (actor.role === Role.SYSTEM_ADMIN) {
return;
}
if (
actor.role === Role.HOSPITAL_ADMIN &&
(nextRole === Role.SYSTEM_ADMIN || nextRole === Role.HOSPITAL_ADMIN)
) {
throw new ForbiddenException(
MESSAGES.USER.HOSPITAL_ADMIN_SCOPE_FORBIDDEN,
);
}
}
/**
*
*/
private assertUpdateHospitalScopeAllowed(
actor: ActorContext,
hospitalId: number | null,
) {
if (actor.role !== Role.HOSPITAL_ADMIN) {
return;
}
const actorHospitalId = this.requireActorScopeInt(
actor.hospitalId,
MESSAGES.ORG.ACTOR_HOSPITAL_REQUIRED,
);
if (hospitalId !== actorHospitalId) {
throw new ForbiddenException(
MESSAGES.USER.HOSPITAL_ADMIN_SCOPE_FORBIDDEN,
);
}
}
/**
* 访
*/

View File

@ -45,6 +45,7 @@ declare module 'vue' {
ElTimeline: typeof import('element-plus/es')['ElTimeline']
ElTimelineItem: typeof import('element-plus/es')['ElTimelineItem']
ElTree: typeof import('element-plus/es')['ElTree']
ElTreeSelect: typeof import('element-plus/es')['ElTreeSelect']
RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView']
}

View File

@ -16,7 +16,7 @@
<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="LEADER" />
<el-option label="医生" value="DOCTOR" />
<el-option label="工程师" value="ENGINEER" />
</el-select>

View File

@ -3,16 +3,35 @@
<el-card>
<template #header>
<div class="card-header">
<span>科室管理 {{ currentHospitalName ? `(${currentHospitalName})` : '' }}</span>
<el-button v-if="currentHospitalName" @click="clearHospitalFilter" type="info" size="small">清除医院筛选</el-button>
<span
>科室管理
{{ currentHospitalName ? `(${currentHospitalName})` : '' }}</span
>
<el-button
v-if="currentHospitalName"
@click="clearHospitalFilter"
type="info"
size="small"
>清除医院筛选</el-button
>
</div>
</template>
<!-- Header / Actions -->
<div class="header-actions">
<el-form :inline="true" :model="searchForm" class="search-form">
<el-form-item label="所属医院" v-if="userStore.role === 'SYSTEM_ADMIN' && !currentHospitalIdFromQuery">
<el-select v-model="searchForm.hospitalId" placeholder="请选择医院" clearable @change="fetchData">
<el-form-item
label="所属医院"
v-if="
userStore.role === 'SYSTEM_ADMIN' && !currentHospitalIdFromQuery
"
>
<el-select
v-model="searchForm.hospitalId"
placeholder="请选择医院"
clearable
@change="fetchData"
>
<el-option
v-for="h in hospitals"
:key="h.id"
@ -22,21 +41,44 @@
</el-select>
</el-form-item>
<el-form-item label="科室名称">
<el-input v-model="searchForm.keyword" placeholder="请输入关键词" clearable />
<el-input
v-model="searchForm.keyword"
placeholder="请输入关键词"
clearable
/>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="fetchData" icon="Search">查询</el-button>
<el-button type="primary" @click="fetchData" icon="Search"
>查询</el-button
>
<el-button @click="resetSearch" icon="Refresh">重置</el-button>
<el-button v-if="canCreateDepartment" type="success" @click="openCreateDialog" icon="Plus">新增科室</el-button>
<el-button
v-if="canCreateDepartment"
type="success"
@click="openCreateDialog"
icon="Plus"
>新增科室</el-button
>
</el-form-item>
</el-form>
</div>
<!-- Table -->
<el-table :data="tableData" v-loading="loading" border stripe style="width: 100%">
<el-table
:data="tableData"
v-loading="loading"
border
stripe
style="width: 100%"
>
<el-table-column prop="id" label="ID" width="80" align="center" />
<el-table-column prop="name" label="科室名称" min-width="150" />
<el-table-column prop="hospital.name" label="所属医院" min-width="200" v-if="userStore.role === 'SYSTEM_ADMIN'" />
<el-table-column
prop="hospital.name"
label="所属医院"
min-width="200"
v-if="userStore.role === 'SYSTEM_ADMIN'"
/>
<el-table-column label="科室主任" min-width="180">
<template #default="{ row }">
{{ getDirectorDisplay(row.id) }}
@ -49,9 +91,23 @@
</el-table-column>
<el-table-column label="操作" width="220" fixed="right" align="center">
<template #default="{ row }">
<el-button size="small" @click="goToGroups(row)">管理小组</el-button>
<el-button v-if="canEditDepartment" size="small" type="primary" @click="openEditDialog(row)">编辑</el-button>
<el-button v-if="canDeleteDepartment" size="small" type="danger" @click="handleDelete(row)">删除</el-button>
<el-button size="small" @click="goToGroups(row)"
>管理小组</el-button
>
<el-button
v-if="canEditDepartment"
size="small"
type="primary"
@click="openEditDialog(row)"
>编辑</el-button
>
<el-button
v-if="canDeleteDepartment"
size="small"
type="danger"
@click="handleDelete(row)"
>删除</el-button
>
</template>
</el-table-column>
</el-table>
@ -72,10 +128,24 @@
</el-card>
<!-- Dialog for Create / Edit -->
<el-dialog :title="isEdit ? '编辑科室' : '新增科室'" v-model="dialogVisible" width="500px" @close="resetForm">
<el-dialog
:title="isEdit ? '编辑科室' : '新增科室'"
v-model="dialogVisible"
width="500px"
@close="resetForm"
>
<el-form :model="form" :rules="rules" ref="formRef" label-width="100px">
<el-form-item label="所属医院" prop="hospitalId" v-if="userStore.role === 'SYSTEM_ADMIN'">
<el-select v-model="form.hospitalId" placeholder="请选择所属医院" style="width: 100%;">
<el-form-item
label="所属医院"
prop="hospitalId"
v-if="userStore.role === 'SYSTEM_ADMIN'"
>
<el-select
v-model="form.hospitalId"
placeholder="请选择所属医院"
style="width: 100%"
@change="handleFormHospitalChange"
>
<el-option
v-for="h in hospitals"
:key="h.id"
@ -87,11 +157,32 @@
<el-form-item label="科室名称" prop="name">
<el-input v-model="form.name" placeholder="请输入科室名称" />
</el-form-item>
<el-form-item label="科室主任" v-if="canAssignDirectorInDialog">
<el-select
v-model="form.directorUserId"
placeholder="可选:选择后将任命为科室主任"
clearable
filterable
style="width: 100%;"
>
<el-option
v-for="user in directorOptions"
:key="user.id"
:label="`${user.name}${user.phone} / ${getRoleName(user.role)}`"
:value="user.id"
/>
</el-select>
</el-form-item>
</el-form>
<template #footer>
<div class="dialog-footer">
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" @click="handleSubmit" :loading="submitLoading">确定</el-button>
<el-button
type="primary"
@click="handleSubmit"
:loading="submitLoading"
>确定</el-button
>
</div>
</template>
</el-dialog>
@ -102,8 +193,14 @@
import { ref, reactive, onMounted, computed } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { ElMessage, ElMessageBox } from 'element-plus';
import { getDepartments, createDepartment, updateDepartment, deleteDepartment, getHospitals } from '../../api/organization';
import { getUsers } from '../../api/users';
import {
getDepartments,
createDepartment,
updateDepartment,
deleteDepartment,
getHospitals,
} from '../../api/organization';
import { getUsers, updateUser } from '../../api/users';
import { useUserStore } from '../../store/user';
const route = useRoute();
@ -118,6 +215,15 @@ const page = ref(1);
const pageSize = ref(10);
const hospitals = ref([]);
const directorNameMap = ref({});
const directorOptions = ref([]);
const roleMap = {
DIRECTOR: '科室主任',
LEADER: '小组组长',
DOCTOR: '医生',
};
const getRoleName = (role) => roleMap[role] || role;
const currentHospitalIdFromQuery = computed(() => {
return route.query.hospitalId ? parseInt(route.query.hospitalId) : null;
@ -129,7 +235,7 @@ const currentHospitalName = computed(() => {
const searchForm = reactive({
keyword: '',
hospitalId: null
hospitalId: null,
});
// Dialog State
@ -141,22 +247,31 @@ const currentId = ref(null);
const form = reactive({
hospitalId: null,
name: ''
name: '',
directorUserId: null,
});
const rules = computed(() => ({
hospitalId: userStore.role === 'SYSTEM_ADMIN' ? [{ required: true, message: '请选择所属医院', trigger: 'change' }] : [],
name: [{ required: true, message: '请输入科室名称', trigger: 'blur' }]
hospitalId:
userStore.role === 'SYSTEM_ADMIN'
? [{ required: true, message: '请选择所属医院', trigger: 'change' }]
: [],
name: [{ required: true, message: '请输入科室名称', trigger: 'blur' }],
}));
const canCreateDepartment = computed(() =>
['SYSTEM_ADMIN', 'HOSPITAL_ADMIN'].includes(userStore.role),
);
const canEditDepartment = computed(() =>
['SYSTEM_ADMIN', 'HOSPITAL_ADMIN', 'DIRECTOR', 'LEADER'].includes(userStore.role),
['SYSTEM_ADMIN', 'HOSPITAL_ADMIN', 'DIRECTOR', 'LEADER'].includes(
userStore.role,
),
);
const canDeleteDepartment = computed(() =>
['SYSTEM_ADMIN', 'HOSPITAL_ADMIN'].includes(userStore.role),
);
const canAssignDirectorInDialog = computed(() =>
['SYSTEM_ADMIN', 'HOSPITAL_ADMIN'].includes(userStore.role),
);
// --- Methods ---
const fetchHospitals = async () => {
@ -171,7 +286,8 @@ const fetchHospitals = async () => {
const fetchData = async () => {
loading.value = true;
try {
const activeHospitalId = currentHospitalIdFromQuery.value || searchForm.hospitalId;
const activeHospitalId =
currentHospitalIdFromQuery.value || searchForm.hospitalId;
const [departmentRes, directorRes] = await Promise.all([
getDepartments({
page: page.value,
@ -224,18 +340,23 @@ const clearHospitalFilter = () => {
const goToGroups = (row) => {
router.push({
path: '/organization/groups',
query: {
departmentId: row.id,
query: {
departmentId: row.id,
departmentName: row.name,
hospitalId: row.hospitalId
}
hospitalId: row.hospitalId,
},
});
};
const openCreateDialog = () => {
isEdit.value = false;
currentId.value = null;
form.hospitalId = userStore.role === 'SYSTEM_ADMIN' ? (currentHospitalIdFromQuery.value || searchForm.hospitalId || null) : userStore.userInfo?.hospitalId;
form.hospitalId =
userStore.role === 'SYSTEM_ADMIN'
? currentHospitalIdFromQuery.value || searchForm.hospitalId || null
: userStore.userInfo?.hospitalId;
form.directorUserId = null;
loadDirectorOptions(form.hospitalId);
dialogVisible.value = true;
};
@ -244,6 +365,8 @@ const openEditDialog = (row) => {
currentId.value = row.id;
form.name = row.name;
form.hospitalId = row.hospitalId;
form.directorUserId = null;
loadDirectorOptions(row.hospitalId, row.id);
dialogVisible.value = true;
};
@ -253,6 +376,34 @@ const resetForm = () => {
}
form.name = '';
form.hospitalId = null;
form.directorUserId = null;
};
const handleFormHospitalChange = (hospitalId) => {
form.directorUserId = null;
loadDirectorOptions(hospitalId);
};
const loadDirectorOptions = async (hospitalId, departmentId) => {
if (!canAssignDirectorInDialog.value || !hospitalId) {
directorOptions.value = [];
return;
}
const userRes = await getUsers();
const users = Array.isArray(userRes?.list) ? userRes.list : [];
directorOptions.value = users.filter((user) => {
if (user.role !== 'DIRECTOR') {
return false;
}
if (user.hospitalId !== hospitalId) {
return false;
}
if (departmentId == null) {
return true;
}
return user.departmentId == null || user.departmentId === departmentId;
});
};
const handleSubmit = async () => {
@ -261,14 +412,39 @@ const handleSubmit = async () => {
if (valid) {
submitLoading.value = true;
try {
let targetDepartmentId = null;
let targetHospitalId = form.hospitalId;
if (isEdit.value) {
// Some backend update APIs don't allow changing hospitalId, but we'll send it if needed, or just name
await updateDepartment(currentId.value, { name: form.name });
const updated = await updateDepartment(currentId.value, { name: form.name });
targetDepartmentId = updated?.id ?? currentId.value;
targetHospitalId = updated?.hospitalId ?? form.hospitalId;
ElMessage.success('更新成功');
} else {
await createDepartment(form);
const created = await createDepartment({
hospitalId: form.hospitalId,
name: form.name,
});
targetDepartmentId = created?.id;
targetHospitalId = created?.hospitalId ?? form.hospitalId;
ElMessage.success('创建成功');
}
if (
canAssignDirectorInDialog.value
&& form.directorUserId
&& targetDepartmentId
&& targetHospitalId
) {
await updateUser(form.directorUserId, {
role: 'DIRECTOR',
hospitalId: targetHospitalId,
departmentId: targetDepartmentId,
groupId: null,
});
ElMessage.success('科室主任已设置');
}
dialogVisible.value = false;
fetchData();
} catch (error) {
@ -281,23 +457,21 @@ const handleSubmit = async () => {
};
const handleDelete = (row) => {
ElMessageBox.confirm(
`确定要删除科室 "${row.name}" 吗?`,
'警告',
{
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning',
}
).then(async () => {
try {
await deleteDepartment(row.id);
ElMessage.success('删除成功');
fetchData();
} catch (error) {
console.error('Delete failed', error);
}
}).catch(() => {});
ElMessageBox.confirm(`确定要删除科室 "${row.name}" 吗?`, '警告', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning',
})
.then(async () => {
try {
await deleteDepartment(row.id);
ElMessage.success('删除成功');
fetchData();
} catch (error) {
console.error('Delete failed', error);
}
})
.catch(() => {});
};
// --- Lifecycle ---
@ -313,7 +487,7 @@ watch(
() => {
page.value = 1;
fetchData();
}
},
);
</script>

View File

@ -3,16 +3,37 @@
<el-card>
<template #header>
<div class="card-header">
<span>小组管理 {{ currentDepartmentName ? `(${currentDepartmentName})` : '' }}</span>
<el-button v-if="currentDepartmentIdFromQuery" @click="clearDepartmentFilter" type="info" size="small">清除科室筛选</el-button>
<span
>小组管理
{{
currentDepartmentName ? `(${currentDepartmentName})` : ''
}}</span
>
<el-button
v-if="currentDepartmentIdFromQuery"
@click="clearDepartmentFilter"
type="info"
size="small"
>清除科室筛选</el-button
>
</div>
</template>
<!-- Header / Actions -->
<div class="header-actions">
<el-form :inline="true" :model="searchForm" class="search-form">
<el-form-item label="所属医院" v-if="userStore.role === 'SYSTEM_ADMIN' && !currentDepartmentIdFromQuery">
<el-select v-model="searchForm.hospitalId" placeholder="请选择医院" clearable @change="handleSearchHospitalChange">
<el-form-item
label="所属医院"
v-if="
userStore.role === 'SYSTEM_ADMIN' && !currentDepartmentIdFromQuery
"
>
<el-select
v-model="searchForm.hospitalId"
placeholder="请选择医院"
clearable
@change="handleSearchHospitalChange"
>
<el-option
v-for="h in hospitals"
:key="h.id"
@ -22,7 +43,15 @@
</el-select>
</el-form-item>
<el-form-item label="所属科室" v-if="!currentDepartmentIdFromQuery">
<el-select v-model="searchForm.departmentId" placeholder="请选择科室" clearable @change="fetchData" :disabled="userStore.role === 'SYSTEM_ADMIN' && !searchForm.hospitalId">
<el-select
v-model="searchForm.departmentId"
placeholder="请选择科室"
clearable
@change="fetchData"
:disabled="
userStore.role === 'SYSTEM_ADMIN' && !searchForm.hospitalId
"
>
<el-option
v-for="d in searchDepartments"
:key="d.id"
@ -32,22 +61,49 @@
</el-select>
</el-form-item>
<el-form-item label="小组名称">
<el-input v-model="searchForm.keyword" placeholder="请输入关键词" clearable />
<el-input
v-model="searchForm.keyword"
placeholder="请输入关键词"
clearable
/>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="fetchData" icon="Search">查询</el-button>
<el-button type="primary" @click="fetchData" icon="Search"
>查询</el-button
>
<el-button @click="resetSearch" icon="Refresh">重置</el-button>
<el-button v-if="canCreateGroup" type="success" @click="openCreateDialog" icon="Plus">新增小组</el-button>
<el-button
v-if="canCreateGroup"
type="success"
@click="openCreateDialog"
icon="Plus"
>新增小组</el-button
>
</el-form-item>
</el-form>
</div>
<!-- Table -->
<el-table :data="tableData" v-loading="loading" border stripe style="width: 100%">
<el-table
:data="tableData"
v-loading="loading"
border
stripe
style="width: 100%"
>
<el-table-column prop="id" label="ID" width="80" align="center" />
<el-table-column prop="name" label="小组名称" min-width="150" />
<el-table-column prop="department.name" label="所属科室" min-width="150" />
<el-table-column prop="department.hospital.name" label="所属医院" min-width="150" v-if="userStore.role === 'SYSTEM_ADMIN'" />
<el-table-column
prop="department.name"
label="所属科室"
min-width="150"
/>
<el-table-column
prop="department.hospital.name"
label="所属医院"
min-width="150"
v-if="userStore.role === 'SYSTEM_ADMIN'"
/>
<el-table-column label="小组组长" min-width="180">
<template #default="{ row }">
{{ getLeaderDisplay(row.id) }}
@ -60,8 +116,20 @@
</el-table-column>
<el-table-column label="操作" width="180" fixed="right" align="center">
<template #default="{ row }">
<el-button v-if="canEditGroup" size="small" type="primary" @click="openEditDialog(row)">编辑</el-button>
<el-button v-if="canDeleteGroup" size="small" type="danger" @click="handleDelete(row)">删除</el-button>
<el-button
v-if="canEditGroup"
size="small"
type="primary"
@click="openEditDialog(row)"
>编辑</el-button
>
<el-button
v-if="canDeleteGroup"
size="small"
type="danger"
@click="handleDelete(row)"
>删除</el-button
>
</template>
</el-table-column>
</el-table>
@ -82,10 +150,24 @@
</el-card>
<!-- Dialog for Create / Edit -->
<el-dialog :title="isEdit ? '编辑小组' : '新增小组'" v-model="dialogVisible" width="500px" @close="resetForm">
<el-dialog
:title="isEdit ? '编辑小组' : '新增小组'"
v-model="dialogVisible"
width="500px"
@close="resetForm"
>
<el-form :model="form" :rules="rules" ref="formRef" label-width="100px">
<el-form-item label="所属医院" prop="hospitalId" v-if="userStore.role === 'SYSTEM_ADMIN'">
<el-select v-model="form.hospitalId" placeholder="请选择所属医院" style="width: 100%;" @change="handleFormHospitalChange">
<el-form-item
label="所属医院"
prop="hospitalId"
v-if="userStore.role === 'SYSTEM_ADMIN'"
>
<el-select
v-model="form.hospitalId"
placeholder="请选择所属医院"
style="width: 100%"
@change="handleFormHospitalChange"
>
<el-option
v-for="h in hospitals"
:key="h.id"
@ -95,7 +177,12 @@
</el-select>
</el-form-item>
<el-form-item label="所属科室" prop="departmentId">
<el-select v-model="form.departmentId" placeholder="请选择所属科室" style="width: 100%;" :disabled="userStore.role === 'SYSTEM_ADMIN' && !form.hospitalId">
<el-select
v-model="form.departmentId"
placeholder="请选择所属科室"
style="width: 100%"
:disabled="userStore.role === 'SYSTEM_ADMIN' && !form.hospitalId"
>
<el-option
v-for="d in formDepartments"
:key="d.id"
@ -107,11 +194,32 @@
<el-form-item label="小组名称" prop="name">
<el-input v-model="form.name" placeholder="请输入小组名称" />
</el-form-item>
<el-form-item label="小组组长" v-if="canAssignLeaderInDialog">
<el-select
v-model="form.leaderUserId"
placeholder="可选:选择后将任命为小组组长"
clearable
filterable
style="width: 100%;"
>
<el-option
v-for="user in leaderOptions"
:key="user.id"
:label="`${user.name}${user.phone} / ${getRoleName(user.role)}`"
:value="user.id"
/>
</el-select>
</el-form-item>
</el-form>
<template #footer>
<div class="dialog-footer">
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" @click="handleSubmit" :loading="submitLoading">确定</el-button>
<el-button
type="primary"
@click="handleSubmit"
:loading="submitLoading"
>确定</el-button
>
</div>
</template>
</el-dialog>
@ -122,8 +230,15 @@
import { ref, reactive, onMounted, computed, watch } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { ElMessage, ElMessageBox } from 'element-plus';
import { getGroups, createGroup, updateGroup, deleteGroup, getHospitals, getDepartments } from '../../api/organization';
import { getUsers } from '../../api/users';
import {
getGroups,
createGroup,
updateGroup,
deleteGroup,
getHospitals,
getDepartments,
} from '../../api/organization';
import { getUsers, updateUser } from '../../api/users';
import { useUserStore } from '../../store/user';
const route = useRoute();
@ -138,6 +253,14 @@ const page = ref(1);
const pageSize = ref(10);
const hospitals = ref([]);
const leaderNameMap = ref({});
const leaderOptions = ref([]);
const roleMap = {
LEADER: '小组组长',
DOCTOR: '医生',
};
const getRoleName = (role) => roleMap[role] || role;
const searchDepartments = ref([]);
const formDepartments = ref([]);
@ -157,7 +280,7 @@ const currentDepartmentName = computed(() => {
const searchForm = reactive({
keyword: '',
hospitalId: null,
departmentId: null
departmentId: null,
});
// Dialog State
@ -170,23 +293,34 @@ const currentId = ref(null);
const form = reactive({
hospitalId: null,
departmentId: null,
name: ''
name: '',
leaderUserId: null,
});
const rules = computed(() => ({
hospitalId: userStore.role === 'SYSTEM_ADMIN' ? [{ required: true, message: '请选择所属医院', trigger: 'change' }] : [],
departmentId: [{ required: true, message: '请选择所属科室', trigger: 'change' }],
name: [{ required: true, message: '请输入小组名称', trigger: 'blur' }]
hospitalId:
userStore.role === 'SYSTEM_ADMIN'
? [{ required: true, message: '请选择所属医院', trigger: 'change' }]
: [],
departmentId: [
{ required: true, message: '请选择所属科室', trigger: 'change' },
],
name: [{ required: true, message: '请输入小组名称', trigger: 'blur' }],
}));
const canCreateGroup = computed(() =>
['SYSTEM_ADMIN', 'HOSPITAL_ADMIN', 'DIRECTOR'].includes(userStore.role),
);
const canEditGroup = computed(() =>
['SYSTEM_ADMIN', 'HOSPITAL_ADMIN', 'DIRECTOR', 'LEADER'].includes(userStore.role),
['SYSTEM_ADMIN', 'HOSPITAL_ADMIN', 'DIRECTOR', 'LEADER'].includes(
userStore.role,
),
);
const canDeleteGroup = computed(() =>
['SYSTEM_ADMIN', 'HOSPITAL_ADMIN', 'DIRECTOR'].includes(userStore.role),
);
const canAssignLeaderInDialog = computed(() =>
['SYSTEM_ADMIN', 'HOSPITAL_ADMIN'].includes(userStore.role),
);
// --- Methods ---
const fetchHospitals = async () => {
@ -212,6 +346,7 @@ const handleSearchHospitalChange = async (hospitalId) => {
const handleFormHospitalChange = async (hospitalId) => {
form.departmentId = null;
form.leaderUserId = null;
formDepartments.value = [];
if (hospitalId) {
try {
@ -221,11 +356,35 @@ const handleFormHospitalChange = async (hospitalId) => {
}
};
const loadLeaderOptions = async (hospitalId, departmentId, groupId) => {
if (!canAssignLeaderInDialog.value || !hospitalId || !departmentId) {
leaderOptions.value = [];
return;
}
const userRes = await getUsers();
const users = Array.isArray(userRes?.list) ? userRes.list : [];
leaderOptions.value = users.filter((user) => {
if (user.role !== 'LEADER') {
return false;
}
if (user.hospitalId !== hospitalId || user.departmentId !== departmentId) {
return false;
}
if (groupId == null) {
return true;
}
return user.groupId == null || user.groupId === groupId;
});
};
const fetchData = async () => {
loading.value = true;
try {
const activeDepartmentId = currentDepartmentIdFromQuery.value || searchForm.departmentId;
const activeHospitalId = currentHospitalIdFromQuery.value || searchForm.hospitalId;
const activeDepartmentId =
currentDepartmentIdFromQuery.value || searchForm.departmentId;
const activeHospitalId =
currentHospitalIdFromQuery.value || searchForm.hospitalId;
const [groupRes, leaderRes] = await Promise.all([
getGroups({
page: page.value,
@ -281,11 +440,17 @@ const clearDepartmentFilter = () => {
const openCreateDialog = async () => {
isEdit.value = false;
currentId.value = null;
form.hospitalId = userStore.role === 'SYSTEM_ADMIN' ? (currentHospitalIdFromQuery.value || searchForm.hospitalId || null) : userStore.userInfo?.hospitalId;
form.hospitalId =
userStore.role === 'SYSTEM_ADMIN'
? currentHospitalIdFromQuery.value || searchForm.hospitalId || null
: userStore.userInfo?.hospitalId;
if (userStore.role === 'SYSTEM_ADMIN' && form.hospitalId) {
await handleFormHospitalChange(form.hospitalId);
}
form.departmentId = currentDepartmentIdFromQuery.value || searchForm.departmentId || null;
form.departmentId =
currentDepartmentIdFromQuery.value || searchForm.departmentId || null;
form.leaderUserId = null;
await loadLeaderOptions(form.hospitalId, form.departmentId, null);
dialogVisible.value = true;
};
@ -293,17 +458,19 @@ const openEditDialog = async (row) => {
isEdit.value = true;
currentId.value = row.id;
form.name = row.name;
// Try to find the hospital ID from the nested relation, assuming row.department.hospitalId exists
// if not, we can rely on row.department?.hospital?.id
const hospitalId = row.department?.hospitalId || row.department?.hospital?.id;
form.hospitalId = hospitalId;
if (hospitalId) {
await handleFormHospitalChange(hospitalId);
}
form.departmentId = row.departmentId;
form.leaderUserId = null;
await loadLeaderOptions(form.hospitalId, form.departmentId, row.id);
dialogVisible.value = true;
};
@ -314,7 +481,9 @@ const resetForm = () => {
form.name = '';
form.hospitalId = null;
form.departmentId = null;
form.leaderUserId = null;
formDepartments.value = [];
leaderOptions.value = [];
};
const handleSubmit = async () => {
@ -323,14 +492,42 @@ const handleSubmit = async () => {
if (valid) {
submitLoading.value = true;
try {
let targetGroupId = null;
let targetDepartmentId = form.departmentId;
let targetHospitalId = form.hospitalId;
if (isEdit.value) {
// Backend patch dto might just accept name. Sending departmentId may not be allowed or needed.
await updateGroup(currentId.value, { name: form.name });
const updated = await updateGroup(currentId.value, { name: form.name });
targetGroupId = updated?.id ?? currentId.value;
targetDepartmentId = updated?.departmentId ?? form.departmentId;
targetHospitalId = updated?.department?.hospitalId ?? form.hospitalId;
ElMessage.success('更新成功');
} else {
await createGroup({ name: form.name, departmentId: form.departmentId });
const created = await createGroup({
name: form.name,
departmentId: form.departmentId,
});
targetGroupId = created?.id;
targetDepartmentId = created?.departmentId ?? form.departmentId;
ElMessage.success('创建成功');
}
if (
canAssignLeaderInDialog.value
&& form.leaderUserId
&& targetGroupId
&& targetDepartmentId
&& targetHospitalId
) {
await updateUser(form.leaderUserId, {
role: 'LEADER',
hospitalId: targetHospitalId,
departmentId: targetDepartmentId,
groupId: targetGroupId,
});
ElMessage.success('小组组长已设置');
}
dialogVisible.value = false;
fetchData();
} catch (error) {
@ -343,23 +540,21 @@ const handleSubmit = async () => {
};
const handleDelete = (row) => {
ElMessageBox.confirm(
`确定要删除小组 "${row.name}" 吗?`,
'警告',
{
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning',
}
).then(async () => {
try {
await deleteGroup(row.id);
ElMessage.success('删除成功');
fetchData();
} catch (error) {
console.error('Delete failed', error);
}
}).catch(() => {});
ElMessageBox.confirm(`确定要删除小组 "${row.name}" 吗?`, '警告', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning',
})
.then(async () => {
try {
await deleteGroup(row.id);
ElMessage.success('删除成功');
fetchData();
} catch (error) {
console.error('Delete failed', error);
}
})
.catch(() => {});
};
// --- Lifecycle ---
@ -382,7 +577,17 @@ watch(
() => {
page.value = 1;
fetchData();
}
},
);
watch(
() => [form.hospitalId, form.departmentId],
async ([hospitalId, departmentId]) => {
if (!dialogVisible.value) {
return;
}
await loadLeaderOptions(hospitalId, departmentId, isEdit.value ? currentId.value : null);
},
);
</script>

View File

@ -19,6 +19,11 @@
<el-table :data="tableData" v-loading="loading" border stripe style="width: 100%">
<el-table-column prop="id" label="ID" width="80" align="center" />
<el-table-column prop="name" label="医院名称" min-width="200" />
<el-table-column prop="adminDisplay" label="医院管理员" min-width="220">
<template #default="{ row }">
{{ row.adminDisplay || '未设置' }}
</template>
</el-table-column>
<el-table-column prop="createdAt" label="创建时间" width="180">
<template #default="{ row }">
{{ new Date(row.createdAt).toLocaleString() }}
@ -54,6 +59,22 @@
<el-form-item label="医院名称" prop="name">
<el-input v-model="form.name" placeholder="请输入医院名称" />
</el-form-item>
<el-form-item label="医院管理员" v-if="userStore.role === 'SYSTEM_ADMIN'">
<el-select
v-model="form.adminUserId"
placeholder="可选:选择后将任命为医院管理员"
clearable
filterable
style="width: 100%;"
>
<el-option
v-for="user in hospitalAdminOptions"
:key="user.id"
:label="`${user.name}${user.phone} / ${getRoleName(user.role)}`"
:value="user.id"
/>
</el-select>
</el-form-item>
</el-form>
<template #footer>
<div class="dialog-footer">
@ -70,6 +91,7 @@ import { ref, reactive, onMounted } from 'vue';
import { useRouter } from 'vue-router';
import { ElMessage, ElMessageBox } from 'element-plus';
import { getHospitals, createHospital, updateHospital, deleteHospital } from '../../api/organization';
import { getUsers, updateUser } from '../../api/users';
import { useUserStore } from '../../store/user';
const router = useRouter();
@ -81,6 +103,18 @@ const tableData = ref([]);
const total = ref(0);
const page = ref(1);
const pageSize = ref(10);
const hospitalAdminOptions = ref([]);
const roleMap = {
SYSTEM_ADMIN: '系统管理员',
HOSPITAL_ADMIN: '医院管理员',
DIRECTOR: '科室主任',
LEADER: '小组组长',
DOCTOR: '医生',
ENGINEER: '工程师',
};
const getRoleName = (role) => roleMap[role] || role;
const searchForm = reactive({
keyword: ''
@ -94,7 +128,8 @@ const formRef = ref(null);
const currentId = ref(null);
const form = reactive({
name: ''
name: '',
adminUserId: null,
});
const rules = {
@ -110,7 +145,29 @@ const fetchData = async () => {
pageSize: pageSize.value,
keyword: searchForm.keyword || undefined
});
tableData.value = res.list || [];
let hospitalAdminNameMap = {};
try {
const userRes = await getUsers();
const users = Array.isArray(userRes?.list) ? userRes.list : [];
hospitalAdminNameMap = users.reduce((acc, user) => {
if (user.role !== 'HOSPITAL_ADMIN' || !user.hospitalId) {
return acc;
}
if (!acc[user.hospitalId]) {
acc[user.hospitalId] = [];
}
acc[user.hospitalId].push(user.name || '-');
return acc;
}, {});
} catch (error) {
console.error('Failed to fetch hospital admins', error);
}
tableData.value = (res.list || []).map((hospital) => ({
...hospital,
adminDisplay: (hospitalAdminNameMap[hospital.id] || []).join('、') || '未设置',
}));
total.value = res.total || 0;
} catch (error) {
console.error('Failed to fetch hospitals', error);
@ -128,6 +185,7 @@ const resetSearch = () => {
const openCreateDialog = () => {
isEdit.value = false;
currentId.value = null;
loadHospitalAdminOptions();
dialogVisible.value = true;
};
@ -135,6 +193,7 @@ const openEditDialog = (row) => {
isEdit.value = true;
currentId.value = row.id;
form.name = row.name;
loadHospitalAdminOptions(row.id);
dialogVisible.value = true;
};
@ -143,6 +202,27 @@ const resetForm = () => {
formRef.value.resetFields();
}
form.name = '';
form.adminUserId = null;
};
const loadHospitalAdminOptions = async (hospitalId) => {
if (userStore.role !== 'SYSTEM_ADMIN') {
hospitalAdminOptions.value = [];
return;
}
const userRes = await getUsers();
const users = Array.isArray(userRes?.list) ? userRes.list : [];
hospitalAdminOptions.value = users.filter((user) => {
//
if (user.role !== 'HOSPITAL_ADMIN') {
return false;
}
if (!hospitalId) {
return true;
}
return user.hospitalId == null || user.hospitalId === hospitalId;
});
};
const handleSubmit = async () => {
@ -151,13 +231,35 @@ const handleSubmit = async () => {
if (valid) {
submitLoading.value = true;
try {
let targetHospitalId = null;
if (isEdit.value) {
await updateHospital(currentId.value, form);
const updated = await updateHospital(currentId.value, { name: form.name });
targetHospitalId = updated?.id ?? currentId.value;
ElMessage.success('更新成功');
} else {
await createHospital(form);
const created = await createHospital({ name: form.name });
targetHospitalId = created?.id;
ElMessage.success('创建成功');
}
if (form.adminUserId && targetHospitalId) {
const selectedAdmin = hospitalAdminOptions.value.find(
(user) => user.id === form.adminUserId,
);
if (!selectedAdmin || selectedAdmin.role !== 'HOSPITAL_ADMIN') {
ElMessage.error('仅可选择医院管理员角色人员');
return;
}
await updateUser(form.adminUserId, {
role: 'HOSPITAL_ADMIN',
hospitalId: targetHospitalId,
departmentId: null,
groupId: null,
});
ElMessage.success('医院管理员已设置');
}
dialogVisible.value = false;
fetchData();
} catch (error) {

View File

@ -33,7 +33,10 @@
</div>
<div class="node-text">
<span class="node-label">{{ node.label }}</span>
<span v-if="data.type === 'department'" class="node-sub-label">
<span v-if="data.type === 'hospital'" class="node-sub-label">
医院管理员{{ data.adminDisplay || '未设置' }}
</span>
<span v-else-if="data.type === 'department'" class="node-sub-label">
主任{{ data.directorDisplay || '未设置' }}
</span>
<span v-else-if="data.type === 'group'" class="node-sub-label">
@ -143,7 +146,8 @@
</el-table-column>
<el-table-column label="负责人" width="180" align="center">
<template #default="{ row }">
<span v-if="row.type === 'department'">主任{{ row.directorDisplay || '未设置' }}</span>
<span v-if="row.type === 'hospital'">医院管理员{{ row.adminDisplay || '未设置' }}</span>
<span v-else-if="row.type === 'department'">主任{{ row.directorDisplay || '未设置' }}</span>
<span v-else-if="row.type === 'group'">组长{{ row.leaderDisplay || '未设置' }}</span>
<span v-else>-</span>
</template>
@ -282,7 +286,7 @@ const roleMap = {
SYSTEM_ADMIN: '系统管理员',
HOSPITAL_ADMIN: '医院管理员',
DIRECTOR: '科室主任',
LEADER: '医疗组长',
LEADER: '小组组长',
DOCTOR: '医生',
ENGINEER: '工程师'
};
@ -424,6 +428,9 @@ const activeNodeMeta = computed(() => {
if (!activeNode.value) {
return '';
}
if (activeNode.value.type === 'hospital') {
return `当前医院管理员:${activeNode.value.adminDisplay || '未设置'}`;
}
if (activeNode.value.type === 'department') {
return `当前科室主任:${activeNode.value.directorDisplay || '未设置'}`;
}
@ -478,7 +485,14 @@ const fetchTreeData = async () => {
const directorNameMap = {};
const leaderNameMap = {};
const hospitalAdminNameMap = {};
users.forEach((user) => {
if (user.role === 'HOSPITAL_ADMIN' && user.hospitalId) {
if (!hospitalAdminNameMap[user.hospitalId]) {
hospitalAdminNameMap[user.hospitalId] = [];
}
hospitalAdminNameMap[user.hospitalId].push(user.name);
}
if (user.role === 'DIRECTOR' && user.departmentId) {
if (!directorNameMap[user.departmentId]) {
directorNameMap[user.departmentId] = [];
@ -495,6 +509,8 @@ const fetchTreeData = async () => {
const tree = hospitals.map(h => {
const hDepts = departments.filter(d => d.hospitalId === h.id);
const adminDisplay =
(hospitalAdminNameMap[h.id] || []).join('、') || '未设置';
const deptNodes = hDepts.map(d => {
const dGroups = groups.filter(g => g.departmentId === d.id);
@ -535,7 +551,7 @@ const fetchTreeData = async () => {
}));
return {
key: `h_${h.id}`, id: h.id, name: h.name, type: 'hospital', children: [...deptNodes, ...hUserNodes]
key: `h_${h.id}`, id: h.id, name: h.name, type: 'hospital', adminDisplay, children: [...deptNodes, ...hUserNodes]
};
});
@ -567,7 +583,7 @@ const openSetOwnerDialog = (node) => {
ownerCandidates.value = allUsers.value.filter((user) =>
user.hospitalId === node.hospitalId
&& user.departmentId === node.id
&& ['DIRECTOR', 'LEADER'].includes(user.role),
&& user.role === 'DIRECTOR',
);
} else {
ownerCandidates.value = allUsers.value.filter((user) =>

View File

@ -29,13 +29,6 @@
clearable
/>
</el-form-item>
<el-form-item label="设备 SN">
<el-input
v-model="searchForm.deviceSn"
placeholder="按设备 SN 过滤"
clearable
/>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleSearch" icon="Search">查询</el-button>
<el-button @click="resetSearch" icon="Refresh">重置</el-button>
@ -56,27 +49,17 @@
<el-table-column prop="id" label="ID" width="80" align="center" />
<el-table-column prop="name" label="姓名" min-width="120" />
<el-table-column prop="phone" label="手机号" min-width="140" />
<el-table-column prop="idCardHash" label="件哈希" min-width="200" />
<el-table-column prop="idCardHash" label="身份证" min-width="200" />
<el-table-column label="归属医院" min-width="160">
<template #default="{ row }">
{{ row.hospital?.name || '-' }}
</template>
</el-table-column>
<el-table-column label="归属人员" min-width="140">
<el-table-column label="归属医生" min-width="140">
<template #default="{ row }">
{{ row.doctor?.name || '-' }}
</template>
</el-table-column>
<el-table-column label="设备数" width="100" align="center">
<template #default="{ row }">
{{ row.devices?.length || 0 }}
</template>
</el-table-column>
<el-table-column label="设备 SN" min-width="220">
<template #default="{ row }">
{{ formatDeviceSn(row.devices) }}
</template>
</el-table-column>
<el-table-column label="操作" width="260" fixed="right" align="center">
<template #default="{ row }">
<el-button size="small" type="primary" @click="openRecordDialog(row)">
@ -119,24 +102,21 @@
<el-form-item label="手机号" prop="phone">
<el-input v-model="form.phone" placeholder="请输入手机号" />
</el-form-item>
<el-form-item label="件哈希" prop="idCardHash">
<el-input v-model="form.idCardHash" placeholder="请输入证件哈希" />
<el-form-item label="身份证" prop="idCardHash">
<el-input v-model="form.idCardHash" placeholder="请输入身份证号" />
</el-form-item>
<el-form-item label="归属人员" prop="doctorId">
<el-select
<el-form-item label="归属医生" prop="doctorId">
<el-tree-select
v-model="form.doctorId"
:data="doctorTreeOptions"
:props="doctorTreeProps"
check-strictly
filterable
placeholder="请选择归属人员(医生/主任/组长)"
clearable
placeholder="请选择归属医生(按科室/小组)"
style="width: 100%;"
:disabled="userStore.role === 'DOCTOR'"
>
<el-option
v-for="doctor in doctorOptions"
:key="doctor.id"
:label="`${doctor.name}${getRoleName(doctor.role)} / ${doctor.phone}`"
:value="doctor.id"
/>
</el-select>
/>
</el-form-item>
</el-form>
<template #footer>
@ -153,7 +133,7 @@
<el-descriptions :column="4" border class="mb-16">
<el-descriptions-item label="患者">{{ currentPatientName || '-' }}</el-descriptions-item>
<el-descriptions-item label="手机号">{{ recordSummary.phone || '-' }}</el-descriptions-item>
<el-descriptions-item label="件哈希">{{ recordSummary.idCardHash || '-' }}</el-descriptions-item>
<el-descriptions-item label="身份证">{{ recordSummary.idCardHash || '-' }}</el-descriptions-item>
<el-descriptions-item label="记录数">{{ recordList.length }}</el-descriptions-item>
</el-descriptions>
@ -199,7 +179,7 @@
</template>
<script setup>
import { reactive, ref, onMounted } from 'vue';
import { reactive, ref, onMounted, computed } from 'vue';
import { ElMessage, ElMessageBox } from 'element-plus';
import {
getPatients,
@ -211,13 +191,14 @@ import {
getPatientLifecycle,
} from '../../api/patients';
import { getHospitals } from '../../api/organization';
import { getDepartments, getGroups } from '../../api/organization';
import { useUserStore } from '../../store/user';
const userStore = useUserStore();
const roleMap = {
DIRECTOR: '科室主任',
LEADER: '医疗组长',
LEADER: '小组组长',
DOCTOR: '医生',
};
@ -230,10 +211,11 @@ const total = ref(0);
const page = ref(1);
const pageSize = ref(10);
const hospitals = ref([]);
const departments = ref([]);
const groups = ref([]);
const searchForm = reactive({
keyword: '',
deviceSn: '',
hospitalId: null,
});
@ -270,26 +252,82 @@ const recordSummary = reactive({
});
const recordList = ref([]);
const formatDeviceSn = (devices = []) => {
if (!Array.isArray(devices) || devices.length === 0) {
return '-';
}
return devices.map((item) => item.snCode).join('');
const doctorTreeProps = {
value: 'value',
label: 'label',
children: 'children',
disabled: 'disabled',
};
const departmentNameMap = computed(() => {
return Object.fromEntries((departments.value || []).map((item) => [item.id, item.name]));
});
const groupNameMap = computed(() => {
return Object.fromEntries((groups.value || []).map((item) => [item.id, item.name]));
});
const doctorTreeOptions = computed(() => {
const options = Array.isArray(doctorOptions.value) ? doctorOptions.value : [];
const deptMap = new Map();
options.forEach((doctor) => {
const deptId = doctor.departmentId ?? 0;
const groupId = doctor.groupId ?? 0;
const deptKey = `dept_${deptId}`;
const groupKey = `group_${groupId}`;
if (!deptMap.has(deptKey)) {
const deptLabel = deptId
? departmentNameMap.value[deptId] || `科室#${deptId}`
: '未分配科室';
deptMap.set(deptKey, {
value: deptKey,
label: deptLabel,
disabled: true,
children: [],
});
}
const deptNode = deptMap.get(deptKey);
if (groupId) {
let groupNode = deptNode.children.find((item) => item.value === groupKey);
if (!groupNode) {
groupNode = {
value: groupKey,
label: groupNameMap.value[groupId] || `小组#${groupId}`,
disabled: true,
children: [],
};
deptNode.children.push(groupNode);
}
groupNode.children.push({
value: doctor.id,
label: `${doctor.name}${getRoleName(doctor.role)} / ${doctor.phone}`,
});
return;
}
deptNode.children.push({
value: doctor.id,
label: `${doctor.name}${getRoleName(doctor.role)} / ${doctor.phone}`,
});
});
return Array.from(deptMap.values()).sort((a, b) => String(a.label).localeCompare(String(b.label), 'zh-Hans-CN'));
});
const applyFiltersAndPagination = () => {
const keyword = searchForm.keyword.trim();
const deviceSn = searchForm.deviceSn.trim();
const filtered = allPatients.value.filter((patient) => {
const hitKeyword = !keyword
|| patient.name?.includes(keyword)
|| patient.phone?.includes(keyword);
const hitDevice = !deviceSn
|| (patient.devices || []).some((device) => device.snCode?.includes(deviceSn));
return hitKeyword && hitDevice;
return hitKeyword;
});
total.value = filtered.length;
@ -321,6 +359,25 @@ const fetchDoctorOptions = async () => {
doctorOptions.value = Array.isArray(res) ? res : [];
};
const fetchOrgNodesForDoctorTree = async () => {
const params = { pageSize: 100 };
if (userStore.role === 'SYSTEM_ADMIN') {
if (!searchForm.hospitalId) {
departments.value = [];
groups.value = [];
return;
}
params.hospitalId = searchForm.hospitalId;
}
const [deptRes, groupRes] = await Promise.all([
getDepartments(params),
getGroups(params),
]);
departments.value = Array.isArray(deptRes?.list) ? deptRes.list : [];
groups.value = Array.isArray(groupRes?.list) ? groupRes.list : [];
};
const fetchData = async () => {
if (userStore.role === 'SYSTEM_ADMIN' && !searchForm.hospitalId) {
allPatients.value = [];
@ -345,6 +402,7 @@ const fetchData = async () => {
const handleSearchHospitalChange = async () => {
page.value = 1;
await fetchOrgNodesForDoctorTree();
await fetchDoctorOptions();
await fetchData();
};
@ -356,7 +414,6 @@ const handleSearch = () => {
const resetSearch = () => {
searchForm.keyword = '';
searchForm.deviceSn = '';
page.value = 1;
fetchData();
};
@ -373,6 +430,7 @@ const resetForm = () => {
const openCreateDialog = async () => {
isEdit.value = false;
resetForm();
await fetchOrgNodesForDoctorTree();
await fetchDoctorOptions();
if (userStore.role === 'DOCTOR') {
@ -383,6 +441,7 @@ const openCreateDialog = async () => {
const openEditDialog = async (row) => {
isEdit.value = true;
await fetchOrgNodesForDoctorTree();
await fetchDoctorOptions();
const detail = await getPatientById(row.id);
currentEditId.value = detail.id;
@ -468,6 +527,7 @@ const openRecordDialog = async (row) => {
onMounted(async () => {
await fetchHospitalsForAdmin();
await fetchOrgNodesForDoctorTree();
await fetchDoctorOptions();
await fetchData();
});

View File

@ -19,7 +19,7 @@
<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="LEADER" />
<el-option label="医生" value="DOCTOR" />
<el-option label="工程师" value="ENGINEER" />
</el-select>
@ -127,7 +127,7 @@
<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="LEADER" />
<el-option label="医生" value="DOCTOR" />
<el-option label="工程师" value="ENGINEER" />
</el-select>
@ -318,7 +318,7 @@ const roleMap = {
SYSTEM_ADMIN: '系统管理员',
HOSPITAL_ADMIN: '医院管理员',
DIRECTOR: '科室主任',
LEADER: '医疗组长',
LEADER: '小组组长',
DOCTOR: '医生',
ENGINEER: '工程师',
};