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

View File

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

View File

@ -88,6 +88,7 @@ model User {
createdTasks Task[] @relation("TaskCreator") createdTasks Task[] @relation("TaskCreator")
acceptedTasks Task[] @relation("TaskEngineer") acceptedTasks Task[] @relation("TaskEngineer")
@@unique([phone, role, hospitalId])
@@index([phone]) @@index([phone])
@@index([hospitalId, role]) @@index([hospitalId, role])
@@index([departmentId, role]) @@index([departmentId, role])

View File

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

View File

@ -1,8 +1,8 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import type { ActorContext } from '../common/actor-context.js'; import type { ActorContext } from '../common/actor-context.js';
import { UsersService } from '../users/users.service.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 { 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) {} constructor(private readonly usersService: UsersService) {}
/** /**
* *
*/ */
register(dto: RegisterUserDto) { createSystemAdmin(dto: CreateSystemAdminDto) {
return this.usersService.register(dto); 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 中字段不合法', TOKEN_FIELD_INVALID: 'Token 中字段不合法',
INVALID_CREDENTIALS: '手机号、角色或密码错误', INVALID_CREDENTIALS: '手机号、角色或密码错误',
PASSWORD_NOT_ENABLED: '该账号未启用密码登录', PASSWORD_NOT_ENABLED: '该账号未启用密码登录',
REGISTER_DISABLED: '注册接口已关闭,请联系管理员创建账号',
}, },
USER: { USER: {
@ -53,6 +54,8 @@ export const MESSAGES = {
DELETE_CONFLICT: '用户存在关联患者或任务,无法删除', DELETE_CONFLICT: '用户存在关联患者或任务,无法删除',
MULTI_ACCOUNT_REQUIRE_HOSPITAL: MULTI_ACCOUNT_REQUIRE_HOSPITAL:
'检测到多个同手机号账号,请传 hospitalId 指定登录医院', '检测到多个同手机号账号,请传 hospitalId 指定登录医院',
CREATE_FORBIDDEN: '当前角色无权限创建该用户',
HOSPITAL_ADMIN_SCOPE_FORBIDDEN: '医院管理员仅可操作本院非管理员账号',
}, },
TASK: { TASK: {

View File

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

View File

@ -15,7 +15,10 @@ export class WechatNotifyService {
/** /**
* / API * / API
*/ */
async notifyTaskChange(openIds: Array<string | null | undefined>, payload: TaskNotifyPayload) { async notifyTaskChange(
openIds: Array<string | null | undefined>,
payload: TaskNotifyPayload,
) {
const targets = Array.from( const targets = Array.from(
new Set( new Set(
openIds openIds
@ -32,10 +35,22 @@ export class WechatNotifyService {
} }
for (const openId of targets) { for (const openId of targets) {
const maskedOpenId = this.maskOpenId(openId);
// TODO: 在此处调用微信服务号/小程序消息推送 API。 // TODO: 在此处调用微信服务号/小程序消息推送 API。
this.logger.log( 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 { Controller, Get, Query, UseGuards } from '@nestjs/common';
import { ApiOperation, ApiQuery, ApiTags } from '@nestjs/swagger'; 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 { FamilyLifecycleQueryDto } from '../dto/family-lifecycle-query.dto.js';
import { CPatientsService } from './c-patients.service.js'; import { CPatientsService } from './c-patients.service.js';
@ -7,7 +13,9 @@ import { CPatientsService } from './c-patients.service.js';
* C * C
*/ */
@ApiTags('患者管理(C端)') @ApiTags('患者管理(C端)')
@ApiBearerAuth('bearer')
@Controller('c/patients') @Controller('c/patients')
@UseGuards(AccessTokenGuard)
export class CPatientsController { export class CPatientsController {
constructor(private readonly patientsService: CPatientsService) {} 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 { PrismaService } from '../../prisma.service.js';
import { MESSAGES } from '../../common/messages.js'; import { MESSAGES } from '../../common/messages.js';
@ -57,7 +61,6 @@ export class CPatientsService {
patient: { patient: {
id: this.toJsonNumber(patient.id), id: this.toJsonNumber(patient.id),
name: patient.name, name: patient.name,
phone: patient.phone,
}, },
device: { device: {
id: this.toJsonNumber(device.id), id: this.toJsonNumber(device.id),
@ -68,9 +71,6 @@ export class CPatientsService {
task: { task: {
id: this.toJsonNumber(task.id), id: this.toJsonNumber(task.id),
status: task.status, status: task.status,
creatorId: this.toJsonNumber(task.creatorId),
engineerId: this.toJsonNumber(task.engineerId),
hospitalId: this.toJsonNumber(task.hospitalId),
createdAt: task.createdAt, createdAt: task.createdAt,
}, },
taskItem: { taskItem: {
@ -89,8 +89,6 @@ export class CPatientsService {
); );
return { return {
phone,
idCardHash,
patientCount: patients.length, patientCount: patients.length,
lifecycle, lifecycle,
}; };

View File

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

View File

@ -40,20 +40,18 @@ export class UsersController {
@Post() @Post()
@Roles(Role.SYSTEM_ADMIN, Role.HOSPITAL_ADMIN) @Roles(Role.SYSTEM_ADMIN, Role.HOSPITAL_ADMIN)
@ApiOperation({ summary: '创建用户' }) @ApiOperation({ summary: '创建用户' })
create(@Body() createUserDto: CreateUserDto) { create(
return this.usersService.create(createUserDto); @CurrentActor() actor: ActorContext,
@Body() createUserDto: CreateUserDto,
) {
return this.usersService.create(actor, createUserDto);
} }
/** /**
* *
*/ */
@Get() @Get()
@Roles( @Roles(Role.SYSTEM_ADMIN, Role.HOSPITAL_ADMIN, Role.DIRECTOR, Role.LEADER)
Role.SYSTEM_ADMIN,
Role.HOSPITAL_ADMIN,
Role.DIRECTOR,
Role.LEADER,
)
@ApiOperation({ summary: '查询用户列表' }) @ApiOperation({ summary: '查询用户列表' })
findAll(@CurrentActor() actor: ActorContext) { findAll(@CurrentActor() actor: ActorContext) {
return this.usersService.findAll(actor); return this.usersService.findAll(actor);
@ -66,8 +64,8 @@ export class UsersController {
@Roles(Role.SYSTEM_ADMIN, Role.HOSPITAL_ADMIN) @Roles(Role.SYSTEM_ADMIN, Role.HOSPITAL_ADMIN)
@ApiOperation({ summary: '查询用户详情' }) @ApiOperation({ summary: '查询用户详情' })
@ApiParam({ name: 'id', description: '用户 ID' }) @ApiParam({ name: 'id', description: '用户 ID' })
findOne(@Param('id') id: string) { findOne(@CurrentActor() actor: ActorContext, @Param('id') id: string) {
return this.usersService.findOne(+id); return this.usersService.findOne(actor, +id);
} }
/** /**
@ -77,8 +75,12 @@ export class UsersController {
@Roles(Role.SYSTEM_ADMIN, Role.HOSPITAL_ADMIN) @Roles(Role.SYSTEM_ADMIN, Role.HOSPITAL_ADMIN)
@ApiOperation({ summary: '更新用户' }) @ApiOperation({ summary: '更新用户' })
@ApiParam({ name: 'id', description: '用户 ID' }) @ApiParam({ name: 'id', description: '用户 ID' })
update(@Param('id') id: string, @Body() updateUserDto: UpdateUserDto) { update(
return this.usersService.update(+id, updateUserDto); @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) @Roles(Role.SYSTEM_ADMIN)
@ApiOperation({ summary: '删除用户' }) @ApiOperation({ summary: '删除用户' })
@ApiParam({ name: 'id', description: '用户 ID' }) @ApiParam({ name: 'id', description: '用户 ID' })
remove(@Param('id') id: string) { remove(@CurrentActor() actor: ActorContext, @Param('id') id: string) {
return this.usersService.remove(+id); 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 { PrismaService } from '../prisma.service.js';
import type { ActorContext } from '../common/actor-context.js'; import type { ActorContext } from '../common/actor-context.js';
import { AssignEngineerHospitalDto } from './dto/assign-engineer-hospital.dto.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 { LoginDto } from './dto/login.dto.js';
import { MESSAGES } from '../common/messages.js'; import { MESSAGES } from '../common/messages.js';
import { CreateSystemAdminDto } from '../auth/dto/create-system-admin.dto.js';
const SAFE_USER_SELECT = { const SAFE_USER_SELECT = {
id: true, id: true,
@ -35,25 +35,27 @@ export class UsersService {
constructor(private readonly prisma: PrismaService) {} constructor(private readonly prisma: PrismaService) {}
/** /**
* bcrypt *
*/ */
async register(dto: RegisterUserDto) { async register() {
const role = this.normalizeRole(dto.role); throw new ForbiddenException(MESSAGES.AUTH.REGISTER_DISABLED);
}
/**
*
*/
async createSystemAdmin(dto: CreateSystemAdminDto) {
const name = this.normalizeRequiredString(dto.name, 'name'); const name = this.normalizeRequiredString(dto.name, 'name');
const phone = this.normalizePhone(dto.phone); const phone = this.normalizePhone(dto.phone);
const password = this.normalizePassword(dto.password); const password = this.normalizePassword(dto.password);
const openId = this.normalizeOptionalString(dto.openId); 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); this.assertSystemAdminBootstrapKey(
await this.assertOrganizationScope(role, hospitalId, departmentId, groupId); Role.SYSTEM_ADMIN,
dto.systemAdminBootstrapKey,
);
await this.assertOpenIdUnique(openId); await this.assertOpenIdUnique(openId);
await this.assertPhoneRoleScopeUnique(phone, role, hospitalId); await this.assertPhoneRoleScopeUnique(phone, Role.SYSTEM_ADMIN, null);
const passwordHash = await hash(password, 12); const passwordHash = await hash(password, 12);
@ -63,10 +65,10 @@ export class UsersService {
phone, phone,
passwordHash, passwordHash,
openId, openId,
role, role: Role.SYSTEM_ADMIN,
hospitalId, hospitalId: null,
departmentId, departmentId: null,
groupId, groupId: null,
}, },
select: SAFE_USER_SELECT, select: SAFE_USER_SELECT,
}); });
@ -133,13 +135,13 @@ export class UsersService {
* *
*/ */
async me(actor: ActorContext) { async me(actor: ActorContext) {
return this.findOne(actor.id); return this.findOne(actor, actor.id);
} }
/** /**
* B 使 * B 使
*/ */
async create(createUserDto: CreateUserDto) { async create(actor: ActorContext, createUserDto: CreateUserDto) {
const role = this.normalizeRole(createUserDto.role); const role = this.normalizeRole(createUserDto.role);
const name = this.normalizeRequiredString(createUserDto.name, 'name'); const name = this.normalizeRequiredString(createUserDto.name, 'name');
const phone = this.normalizePhone(createUserDto.phone); const phone = this.normalizePhone(createUserDto.phone);
@ -157,9 +159,22 @@ export class UsersService {
); );
const groupId = this.normalizeOptionalInt(createUserDto.groupId, 'groupId'); 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.assertOpenIdUnique(openId);
await this.assertPhoneRoleScopeUnique(phone, role, hospitalId); await this.assertPhoneRoleScopeUnique(phone, role, scoped.hospitalId);
return this.prisma.user.create({ return this.prisma.user.create({
data: { data: {
@ -168,9 +183,9 @@ export class UsersService {
passwordHash: password ? await hash(password, 12) : null, passwordHash: password ? await hash(password, 12) : null,
openId, openId,
role, role,
hospitalId, hospitalId: scoped.hospitalId,
departmentId, departmentId: scoped.departmentId,
groupId, groupId: scoped.groupId,
}, },
select: SAFE_USER_SELECT, 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 userId = this.normalizeRequiredInt(id, 'id');
const user = await this.prisma.user.findUnique({ const user = await this.prisma.user.findUnique({
@ -221,13 +236,15 @@ export class UsersService {
throw new NotFoundException(MESSAGES.USER.NOT_FOUND); throw new NotFoundException(MESSAGES.USER.NOT_FOUND);
} }
this.assertUserReadable(actor, user);
return user; return user;
} }
/** /**
* *
*/ */
async update(id: number, updateUserDto: UpdateUserDto) { async update(actor: ActorContext, id: number, updateUserDto: UpdateUserDto) {
const userId = this.normalizeRequiredInt(id, 'id'); const userId = this.normalizeRequiredInt(id, 'id');
const current = await this.prisma.user.findUnique({ const current = await this.prisma.user.findUnique({
where: { id: userId }, where: { id: userId },
@ -240,8 +257,12 @@ export class UsersService {
throw new NotFoundException(MESSAGES.USER.NOT_FOUND); throw new NotFoundException(MESSAGES.USER.NOT_FOUND);
} }
this.assertUserWritable(actor, current);
const nextRole = const nextRole =
updateUserDto.role != null ? this.normalizeRole(updateUserDto.role) : current.role; updateUserDto.role != null
? this.normalizeRole(updateUserDto.role)
: current.role;
const nextHospitalId = const nextHospitalId =
updateUserDto.hospitalId !== undefined updateUserDto.hospitalId !== undefined
? this.normalizeOptionalInt(updateUserDto.hospitalId, 'hospitalId') ? this.normalizeOptionalInt(updateUserDto.hospitalId, 'hospitalId')
@ -255,6 +276,9 @@ export class UsersService {
? this.normalizeOptionalInt(updateUserDto.groupId, 'groupId') ? this.normalizeOptionalInt(updateUserDto.groupId, 'groupId')
: current.groupId; : current.groupId;
this.assertUpdateTargetRoleAllowed(actor, nextRole);
this.assertUpdateHospitalScopeAllowed(actor, nextHospitalId);
const assigningDepartmentOrGroup = const assigningDepartmentOrGroup =
(updateUserDto.departmentId !== undefined && nextDepartmentId != null) || (updateUserDto.departmentId !== undefined && nextDepartmentId != null) ||
(updateUserDto.groupId !== undefined && nextGroupId != 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'); const userId = this.normalizeRequiredInt(id, 'id');
await this.findOne(userId); const target = await this.findOne(actor, userId);
this.assertUserWritable(actor, target);
try { try {
return await this.prisma.user.delete({ 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; const { passwordHash, ...safe } = user;
return safe; return safe;
} }
@ -512,7 +539,9 @@ export class UsersService {
select: { id: true, hospitalId: true }, select: { id: true, hospitalId: true },
}); });
if (!department || department.hospitalId !== hospitalId) { 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; 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'] ElTimeline: typeof import('element-plus/es')['ElTimeline']
ElTimelineItem: typeof import('element-plus/es')['ElTimelineItem'] ElTimelineItem: typeof import('element-plus/es')['ElTimelineItem']
ElTree: typeof import('element-plus/es')['ElTree'] ElTree: typeof import('element-plus/es')['ElTree']
ElTreeSelect: typeof import('element-plus/es')['ElTreeSelect']
RouterLink: typeof import('vue-router')['RouterLink'] RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView'] RouterView: typeof import('vue-router')['RouterView']
} }

View File

@ -16,7 +16,7 @@
<el-option label="系统管理员" value="SYSTEM_ADMIN" /> <el-option label="系统管理员" value="SYSTEM_ADMIN" />
<el-option label="医院管理员" value="HOSPITAL_ADMIN" /> <el-option label="医院管理员" value="HOSPITAL_ADMIN" />
<el-option label="科室主任" value="DIRECTOR" /> <el-option label="科室主任" value="DIRECTOR" />
<el-option label="医疗组长" value="LEADER" /> <el-option label="小组组长" value="LEADER" />
<el-option label="医生" value="DOCTOR" /> <el-option label="医生" value="DOCTOR" />
<el-option label="工程师" value="ENGINEER" /> <el-option label="工程师" value="ENGINEER" />
</el-select> </el-select>

View File

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

View File

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

View File

@ -19,6 +19,11 @@
<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="id" label="ID" width="80" align="center" />
<el-table-column prop="name" label="医院名称" min-width="200" /> <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"> <el-table-column prop="createdAt" label="创建时间" width="180">
<template #default="{ row }"> <template #default="{ row }">
{{ new Date(row.createdAt).toLocaleString() }} {{ new Date(row.createdAt).toLocaleString() }}
@ -54,6 +59,22 @@
<el-form-item label="医院名称" prop="name"> <el-form-item label="医院名称" prop="name">
<el-input v-model="form.name" placeholder="请输入医院名称" /> <el-input v-model="form.name" placeholder="请输入医院名称" />
</el-form-item> </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> </el-form>
<template #footer> <template #footer>
<div class="dialog-footer"> <div class="dialog-footer">
@ -70,6 +91,7 @@ import { ref, reactive, onMounted } from 'vue';
import { useRouter } from 'vue-router'; import { useRouter } from 'vue-router';
import { ElMessage, ElMessageBox } from 'element-plus'; import { ElMessage, ElMessageBox } from 'element-plus';
import { getHospitals, createHospital, updateHospital, deleteHospital } from '../../api/organization'; import { getHospitals, createHospital, updateHospital, deleteHospital } from '../../api/organization';
import { getUsers, updateUser } from '../../api/users';
import { useUserStore } from '../../store/user'; import { useUserStore } from '../../store/user';
const router = useRouter(); const router = useRouter();
@ -81,6 +103,18 @@ const tableData = ref([]);
const total = ref(0); const total = ref(0);
const page = ref(1); const page = ref(1);
const pageSize = ref(10); 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({ const searchForm = reactive({
keyword: '' keyword: ''
@ -94,7 +128,8 @@ const formRef = ref(null);
const currentId = ref(null); const currentId = ref(null);
const form = reactive({ const form = reactive({
name: '' name: '',
adminUserId: null,
}); });
const rules = { const rules = {
@ -110,7 +145,29 @@ const fetchData = async () => {
pageSize: pageSize.value, pageSize: pageSize.value,
keyword: searchForm.keyword || undefined 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; total.value = res.total || 0;
} catch (error) { } catch (error) {
console.error('Failed to fetch hospitals', error); console.error('Failed to fetch hospitals', error);
@ -128,6 +185,7 @@ const resetSearch = () => {
const openCreateDialog = () => { const openCreateDialog = () => {
isEdit.value = false; isEdit.value = false;
currentId.value = null; currentId.value = null;
loadHospitalAdminOptions();
dialogVisible.value = true; dialogVisible.value = true;
}; };
@ -135,6 +193,7 @@ const openEditDialog = (row) => {
isEdit.value = true; isEdit.value = true;
currentId.value = row.id; currentId.value = row.id;
form.name = row.name; form.name = row.name;
loadHospitalAdminOptions(row.id);
dialogVisible.value = true; dialogVisible.value = true;
}; };
@ -143,6 +202,27 @@ const resetForm = () => {
formRef.value.resetFields(); formRef.value.resetFields();
} }
form.name = ''; 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 () => { const handleSubmit = async () => {
@ -151,13 +231,35 @@ const handleSubmit = async () => {
if (valid) { if (valid) {
submitLoading.value = true; submitLoading.value = true;
try { try {
let targetHospitalId = null;
if (isEdit.value) { if (isEdit.value) {
await updateHospital(currentId.value, form); const updated = await updateHospital(currentId.value, { name: form.name });
targetHospitalId = updated?.id ?? currentId.value;
ElMessage.success('更新成功'); ElMessage.success('更新成功');
} else { } else {
await createHospital(form); const created = await createHospital({ name: form.name });
targetHospitalId = created?.id;
ElMessage.success('创建成功'); 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; dialogVisible.value = false;
fetchData(); fetchData();
} catch (error) { } catch (error) {

View File

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

View File

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

View File

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