患者列表改为服务端分页/筛选:支持 page、pageSize、keyword、doctorId 参数

患者详情页支持“编辑手术”流程:单手术直接编辑,多手术先进入手术列表选择
手术弹窗新增编辑模式:禁用设备结构增删,仅允许修改既有手术及设备字段
新增更新手术 API:PATCH /b/patients/:patientId/surgeries/:surgeryId
生命周期查询改为 B 端接口:GET /b/patients/:id/lifecycle
手术提交后支持回到详情并保持手术标签页,提升连续操作效率
This commit is contained in:
EL 2026-03-26 03:47:52 +08:00
parent 21941e94fd
commit c830a2131e
27 changed files with 1424 additions and 395 deletions

View File

@ -24,6 +24,7 @@
"@nestjs/mapped-types": "*",
"@nestjs/platform-express": "^11.0.1",
"@nestjs/swagger": "^11.2.6",
"@nestjs/throttler": "^6.5.0",
"@prisma/adapter-pg": "^7.5.0",
"@prisma/client": "^7.5.0",
"bcrypt": "^6.0.0",
@ -31,6 +32,7 @@
"class-validator": "^0.15.1",
"dotenv": "^17.3.1",
"ffmpeg-static": "^5.3.0",
"helmet": "^8.1.0",
"jsonwebtoken": "^9.0.3",
"multer": "^2.1.1",
"pg": "^8.20.0",

25
pnpm-lock.yaml generated
View File

@ -26,6 +26,9 @@ importers:
'@nestjs/swagger':
specifier: ^11.2.6
version: 11.2.6(@nestjs/common@11.1.16(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.16)(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)
'@nestjs/throttler':
specifier: ^6.5.0
version: 6.5.0(@nestjs/common@11.1.16(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.16)(reflect-metadata@0.2.2)
'@prisma/adapter-pg':
specifier: ^7.5.0
version: 7.5.0
@ -47,6 +50,9 @@ importers:
ffmpeg-static:
specifier: ^5.3.0
version: 5.3.0
helmet:
specifier: ^8.1.0
version: 8.1.0
jsonwebtoken:
specifier: ^9.0.3
version: 9.0.3
@ -917,6 +923,13 @@ packages:
'@nestjs/platform-express':
optional: true
'@nestjs/throttler@6.5.0':
resolution: {integrity: sha512-9j0ZRfH0QE1qyrj9JjIRDz5gQLPqq9yVC2nHsrosDVAfI5HHw08/aUAWx9DZLSdQf4HDkmhTTEGLrRFHENvchQ==}
peerDependencies:
'@nestjs/common': ^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0
'@nestjs/core': ^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0
reflect-metadata: ^0.1.13 || ^0.2.0
'@noble/hashes@1.8.0':
resolution: {integrity: sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==}
engines: {node: ^14.21.3 || >=16}
@ -2079,6 +2092,10 @@ packages:
resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==}
engines: {node: '>= 0.4'}
helmet@8.1.0:
resolution: {integrity: sha512-jOiHyAZsmnr8LqoPGmCjYAaiuWwjAPLgY8ZX2XrmHawt99/u1y6RgrZMTeoPfpUbV96HOalYgz1qzkRbw54Pmg==}
engines: {node: '>=18.0.0'}
hono@4.11.4:
resolution: {integrity: sha512-U7tt8JsyrxSRKspfhtLET79pU8K+tInj5QZXs1jSugO1Vq5dFj3kmZsRldo29mTBfcjDRVRXrEZ6LS63Cog9ZA==}
engines: {node: '>=16.9.0'}
@ -4332,6 +4349,12 @@ snapshots:
optionalDependencies:
'@nestjs/platform-express': 11.1.16(@nestjs/common@11.1.16(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.16)
'@nestjs/throttler@6.5.0(@nestjs/common@11.1.16(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.16)(reflect-metadata@0.2.2)':
dependencies:
'@nestjs/common': 11.1.16(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)
'@nestjs/core': 11.1.16(@nestjs/common@11.1.16(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.16)(reflect-metadata@0.2.2)(rxjs@7.8.2)
reflect-metadata: 0.2.2
'@noble/hashes@1.8.0': {}
'@nuxt/opencollective@0.4.1':
@ -5559,6 +5582,8 @@ snapshots:
dependencies:
function-bind: 1.1.2
helmet@8.1.0: {}
hono@4.11.4: {}
html-escaper@2.0.2: {}

View File

@ -1,3 +1,9 @@
onlyBuiltDependencies:
- '@nestjs/core'
- '@prisma/engines'
- '@scarf/scarf'
- bcrypt
- ffmpeg-static
- prisma
- sharp
- unrs-resolver

View File

@ -1,5 +1,7 @@
import { Module } from '@nestjs/common';
import { APP_GUARD } from '@nestjs/core';
import { EventEmitterModule } from '@nestjs/event-emitter';
import { ThrottlerGuard, ThrottlerModule } from '@nestjs/throttler';
import { PrismaModule } from './prisma.module.js';
import { UsersModule } from './users/users.module.js';
import { TasksModule } from './tasks/tasks.module.js';
@ -15,6 +17,16 @@ import { UploadsModule } from './uploads/uploads.module.js';
imports: [
PrismaModule,
EventEmitterModule.forRoot(),
ThrottlerModule.forRoot({
errorMessage: '操作过于频繁,请稍后再试',
throttlers: [
{
name: 'default',
ttl: 60_000,
limit: 120,
},
],
}),
UsersModule,
TasksModule,
PatientsModule,
@ -25,5 +37,11 @@ import { UploadsModule } from './uploads/uploads.module.js';
DictionariesModule,
UploadsModule,
],
providers: [
{
provide: APP_GUARD,
useClass: ThrottlerGuard,
},
],
})
export class AppModule {}

View File

@ -1,5 +1,6 @@
import { Body, Controller, Get, Post, UseGuards } from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { Throttle } from '@nestjs/throttler';
import { AuthService } from './auth.service.js';
import { AccessTokenGuard } from './access-token.guard.js';
import { CurrentActor } from './current-actor.decorator.js';
@ -22,6 +23,7 @@ export class AuthController {
*
*/
@Post('system-admin')
@Throttle({ default: { limit: 3, ttl: 60_000 } })
@ApiOperation({ summary: '创建系统管理员' })
createSystemAdmin(@Body() dto: CreateSystemAdminDto) {
return this.authService.createSystemAdmin(dto);
@ -31,30 +33,35 @@ export class AuthController {
*
*/
@Post('login')
@Throttle({ default: { limit: 5, ttl: 60_000 } })
@ApiOperation({ summary: '院内账号密码登录' })
login(@Body() dto: LoginDto) {
return this.authService.login(dto);
}
@Post('login/confirm')
@Throttle({ default: { limit: 10, ttl: 60_000 } })
@ApiOperation({ summary: '院内账号密码多账号确认登录' })
confirmLogin(@Body() dto: PasswordLoginConfirmDto) {
return this.authService.confirmLogin(dto);
}
@Post('miniapp/b/phone-login')
@Throttle({ default: { limit: 5, ttl: 60_000 } })
@ApiOperation({ summary: 'B 端小程序手机号登录' })
miniAppBLogin(@Body() dto: MiniappPhoneLoginDto) {
return this.authService.miniAppBLogin(dto);
}
@Post('miniapp/b/phone-login/confirm')
@Throttle({ default: { limit: 10, ttl: 60_000 } })
@ApiOperation({ summary: 'B 端小程序多账号确认登录' })
miniAppBConfirmLogin(@Body() dto: MiniappPhoneLoginConfirmDto) {
return this.authService.miniAppBConfirmLogin(dto);
}
@Post('miniapp/c/phone-login')
@Throttle({ default: { limit: 5, ttl: 60_000 } })
@ApiOperation({ summary: 'C 端小程序手机号登录' })
miniAppCLogin(@Body() dto: MiniappPhoneLoginDto) {
return this.authService.miniAppCLogin(dto);

View File

@ -72,7 +72,12 @@ export class WechatMiniAppService {
(await response.json().catch(() => null)) as WechatCode2SessionResponse | null;
if (!response.ok || !payload?.openid || payload.errcode) {
throw new UnauthorizedException(MESSAGES.AUTH.WECHAT_MINIAPP_LOGIN_FAILED);
throw new UnauthorizedException(
this.buildWechatAuthErrorMessage(
MESSAGES.AUTH.WECHAT_MINIAPP_LOGIN_FAILED,
payload,
),
);
}
return payload.openid;
@ -100,7 +105,12 @@ export class WechatMiniAppService {
const phone = payload?.phone_info?.phoneNumber;
if (!response.ok || !phone || payload?.errcode) {
throw new UnauthorizedException(MESSAGES.AUTH.WECHAT_MINIAPP_PHONE_FAILED);
throw new UnauthorizedException(
this.buildWechatAuthErrorMessage(
MESSAGES.AUTH.WECHAT_MINIAPP_PHONE_FAILED,
payload,
),
);
}
return phone;
@ -128,7 +138,12 @@ export class WechatMiniAppService {
(await response.json().catch(() => null)) as WechatAccessTokenResponse | null;
if (!response.ok || !payload?.access_token || payload.errcode) {
throw new UnauthorizedException(MESSAGES.AUTH.WECHAT_MINIAPP_PHONE_FAILED);
throw new UnauthorizedException(
this.buildWechatAuthErrorMessage(
MESSAGES.AUTH.WECHAT_MINIAPP_PHONE_FAILED,
payload,
),
);
}
this.accessTokenCache = {
@ -173,4 +188,32 @@ export class WechatMiniAppService {
return trimmed;
}
/**
* 便 appid/code/
*/
private buildWechatAuthErrorMessage(
fallbackMessage: string,
payload:
| WechatAccessTokenResponse
| WechatCode2SessionResponse
| WechatPhoneResponse
| null,
) {
const errcode =
payload && typeof payload.errcode === 'number' ? payload.errcode : null;
const errmsg =
payload && typeof payload.errmsg === 'string' ? payload.errmsg.trim() : '';
if (errcode == null && !errmsg) {
return fallbackMessage;
}
const details = [`errcode=${errcode ?? 'unknown'}`];
if (errmsg) {
details.push(`errmsg=${errmsg}`);
}
return `${fallbackMessage}${details.join(', ')}`;
}
}

View File

@ -40,6 +40,7 @@ export const MESSAGES = {
MINIAPP_OPEN_ID_BOUND_OTHER_FAMILY: '当前微信账号已绑定其他家属账号',
FAMILY_PHONE_NOT_LINKED_PATIENT: '当前手机号未关联患者档案',
FAMILY_ACCOUNT_NOT_FOUND: '家属登录账号不存在,请重新登录',
THROTTLED: '操作过于频繁,请稍后再试',
},
USER: {
@ -108,6 +109,11 @@ export const MESSAGES = {
IMPLANT_CATALOG_NOT_FOUND: '植入物型号不存在或不在当前医院可见范围内',
SURGERY_UPDATE_NOT_SUPPORTED:
'患者更新接口不支持直接修改手术,请使用新增手术接口',
SURGERY_DEVICE_SET_UPDATE_NOT_SUPPORTED:
'编辑手术暂不支持新增或删除植入设备,请逐项修改现有设备信息',
SURGERY_ABANDON_UPDATE_NOT_SUPPORTED:
'编辑手术暂不支持修改弃用旧设备,请通过追加手术处理',
SURGERY_DEVICE_TASK_CONFLICT: '存在调压任务记录的植入设备不支持修改型号',
ABANDON_DEVICE_SCOPE_FORBIDDEN: '仅可弃用当前患者名下设备',
},
@ -144,6 +150,7 @@ export const MESSAGES = {
'系统管理员上传文件时必须显式指定 hospitalId',
INVALID_IMAGE_FILE: '上传的图片无法解析或压缩失败',
INVALID_VIDEO_FILE: '上传的视频无法解析或压缩失败',
INVALID_FILE_SIGNATURE: '上传文件内容与声明类型不匹配',
FFMPEG_NOT_AVAILABLE: '服务端缺少视频压缩能力',
},

View File

@ -1,24 +1,60 @@
import 'dotenv/config';
import { mkdirSync } from 'node:fs';
import { basename, extname } from 'node:path';
import { BadRequestException, ValidationPipe } from '@nestjs/common';
import { NestFactory } from '@nestjs/core';
import { NestExpressApplication } from '@nestjs/platform-express';
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
import helmet from 'helmet';
import { AppModule } from './app.module.js';
import { HttpExceptionFilter } from './common/http-exception.filter.js';
import { MESSAGES } from './common/messages.js';
import { ResponseEnvelopeInterceptor } from './common/response-envelope.interceptor.js';
import { resolveUploadRootDir } from './uploads/upload-path.util.js';
const INLINE_UPLOAD_EXTENSIONS = new Set([
'.png',
'.jpg',
'.jpeg',
'.gif',
'.bmp',
'.webp',
'.mp4',
'.mov',
'.avi',
'.mkv',
'.webm',
]);
async function bootstrap() {
// 创建应用实例并加载核心模块。
const app = await NestFactory.create<NestExpressApplication>(AppModule);
app.enableCors();
app.use(
helmet({
contentSecurityPolicy: false,
crossOriginResourcePolicy: false,
}),
);
app.enableCors(buildCorsOptions());
mkdirSync(resolveUploadRootDir(), { recursive: true });
app.useStaticAssets(resolveUploadRootDir(), {
prefix: '/uploads/',
setHeaders: (res, filePath) => {
const extension = extname(filePath).toLowerCase();
res.setHeader('X-Content-Type-Options', 'nosniff');
if (!INLINE_UPLOAD_EXTENSIONS.has(extension)) {
const fileName = basename(filePath);
res.setHeader(
'Content-Disposition',
`attachment; filename*=UTF-8''${encodeURIComponent(fileName)}`,
);
res.setHeader('Content-Type', 'application/octet-stream');
}
},
});
// 全局校验:清理未知字段、自动类型转换,并将校验错误统一为中文信息。
app.useGlobalPipes(
new ValidationPipe({
whitelist: true,
@ -30,40 +66,98 @@ async function bootstrap() {
.filter((item): item is string => Boolean(item));
return new BadRequestException(
messages.length > 0
? messages.join('')
? messages.join('; ')
: MESSAGES.DEFAULT_BAD_REQUEST,
);
},
}),
);
// 全局异常与成功响应统一格式化。
app.useGlobalFilters(new HttpExceptionFilter());
app.useGlobalInterceptors(new ResponseEnvelopeInterceptor());
// Swagger 文档:提供在线调试与 OpenAPI JSON 导出。
const swaggerConfig = new DocumentBuilder()
.setTitle('TYT 多租户医疗调压系统 API')
.setDescription('后端接口文档含认证、RBAC、任务流转与患者聚合')
if (shouldEnableSwagger()) {
const swaggerBuilder = new DocumentBuilder()
.setTitle('调压通医疗平台接口文档')
.setDescription('包含认证、权限、患者、手术、任务和上传等后端接口。')
.setVersion('1.0.0')
.addServer('http://192.168.0.140:3000', 'localhost')
.addBearerAuth(
{
type: 'http',
scheme: 'bearer',
bearerFormat: 'JWT',
description: '在此输入登录接口返回的 accessToken',
description: '请填写登录接口返回的访问令牌。',
},
'bearer',
)
.build();
);
const swaggerDocument = SwaggerModule.createDocument(app, swaggerConfig);
const publicBaseUrl = normalizeOrigin(process.env.PUBLIC_BASE_URL);
if (publicBaseUrl) {
swaggerBuilder.addServer(publicBaseUrl, '公开地址');
}
const swaggerDocument = SwaggerModule.createDocument(
app,
swaggerBuilder.build(),
);
SwaggerModule.setup('api/docs', app, swaggerDocument, {
jsonDocumentUrl: 'api/docs-json',
});
}
// 启动 HTTP 服务。
await app.listen(process.env.PORT ?? 3000);
}
bootstrap();
function buildCorsOptions() {
const allowedOrigins = parseAllowedOrigins(process.env.CORS_ALLOWED_ORIGINS);
if (allowedOrigins.length === 0) {
return {
origin: process.env.NODE_ENV === 'production' ? false : true,
credentials: true,
};
}
return {
credentials: true,
origin: (
origin: string | undefined,
callback: (error: Error | null, allow?: boolean) => void,
) => {
if (!origin) {
callback(null, true);
return;
}
callback(null, allowedOrigins.includes(normalizeOrigin(origin)));
},
};
}
function parseAllowedOrigins(value: string | undefined) {
if (!value) {
return [];
}
return Array.from(
new Set(
value
.split(',')
.map((origin) => normalizeOrigin(origin))
.filter(Boolean),
),
);
}
function normalizeOrigin(origin: string | undefined) {
return String(origin ?? '').trim().replace(/\/+$/, '');
}
function shouldEnableSwagger() {
return (
process.env.ENABLE_SWAGGER === 'true' ||
process.env.NODE_ENV !== 'production'
);
}

View File

@ -17,20 +17,19 @@ import {
ApiQuery,
ApiTags,
} from '@nestjs/swagger';
import type { ActorContext } from '../../common/actor-context.js';
import { CurrentActor } from '../../auth/current-actor.decorator.js';
import { AccessTokenGuard } from '../../auth/access-token.guard.js';
import { RolesGuard } from '../../auth/roles.guard.js';
import { CurrentActor } from '../../auth/current-actor.decorator.js';
import { Roles } from '../../auth/roles.decorator.js';
import { RolesGuard } from '../../auth/roles.guard.js';
import type { ActorContext } from '../../common/actor-context.js';
import { Role } from '../../generated/prisma/enums.js';
import { BPatientsService } from './b-patients.service.js';
import { CreatePatientDto } from '../dto/create-patient.dto.js';
import { CreatePatientSurgeryDto } from '../dto/create-patient-surgery.dto.js';
import { PatientQueryDto } from '../dto/patient-query.dto.js';
import { UpdatePatientSurgeryDto } from '../dto/update-patient-surgery.dto.js';
import { UpdatePatientDto } from '../dto/update-patient.dto.js';
import { BPatientsService } from './b-patients.service.js';
/**
* B
*/
@ApiTags('患者管理(B端)')
@ApiBearerAuth('bearer')
@Controller('b/patients')
@ -38,9 +37,6 @@ import { UpdatePatientDto } from '../dto/update-patient.dto.js';
export class BPatientsController {
constructor(private readonly patientsService: BPatientsService) {}
/**
* //
*/
@Get('doctors')
@Roles(
Role.SYSTEM_ADMIN,
@ -49,11 +45,11 @@ export class BPatientsController {
Role.LEADER,
Role.DOCTOR,
)
@ApiOperation({ summary: '查询当前角色可见归属人员列表' })
@ApiOperation({ summary: '查询当前登录人可见的医生列表' })
@ApiQuery({
name: 'hospitalId',
required: false,
description: '系统管理员可显式指定医院',
description: '医院编号,仅系统管理员可选传',
})
findVisibleDoctors(
@CurrentActor() actor: ActorContext,
@ -64,9 +60,6 @@ export class BPatientsController {
return this.patientsService.findVisibleDoctors(actor, requestedHospitalId);
}
/**
*
*/
@Get()
@Roles(
Role.SYSTEM_ADMIN,
@ -75,25 +68,14 @@ export class BPatientsController {
Role.LEADER,
Role.DOCTOR,
)
@ApiOperation({ summary: '按角色查询可见患者列表' })
@ApiQuery({
name: 'hospitalId',
required: false,
description: '系统管理员可显式指定医院',
})
@ApiOperation({ summary: '按服务端分页查询当前登录人可见的患者列表' })
findVisiblePatients(
@CurrentActor() actor: ActorContext,
@Query('hospitalId') hospitalId?: string,
@Query() query: PatientQueryDto,
) {
const requestedHospitalId =
hospitalId == null || hospitalId === '' ? undefined : Number(hospitalId);
return this.patientsService.findVisiblePatients(actor, requestedHospitalId);
return this.patientsService.findVisiblePatients(actor, query);
}
/**
*
*/
@Post()
@Roles(
Role.SYSTEM_ADMIN,
@ -102,7 +84,7 @@ export class BPatientsController {
Role.LEADER,
Role.DOCTOR,
)
@ApiOperation({ summary: '创建患者' })
@ApiOperation({ summary: '创建患者档案' })
createPatient(
@CurrentActor() actor: ActorContext,
@Body() dto: CreatePatientDto,
@ -110,9 +92,6 @@ export class BPatientsController {
return this.patientsService.createPatient(actor, dto);
}
/**
*
*/
@Post(':id/surgeries')
@Roles(
Role.SYSTEM_ADMIN,
@ -122,7 +101,7 @@ export class BPatientsController {
Role.DOCTOR,
)
@ApiOperation({ summary: '为患者新增手术记录' })
@ApiParam({ name: 'id', description: '患者 ID' })
@ApiParam({ name: 'id', description: '患者编号' })
createPatientSurgery(
@CurrentActor() actor: ActorContext,
@Param('id', ParseIntPipe) id: number,
@ -131,9 +110,43 @@ export class BPatientsController {
return this.patientsService.createPatientSurgery(actor, id, dto);
}
/**
*
*/
@Patch(':id/surgeries/:surgeryId')
@Roles(
Role.SYSTEM_ADMIN,
Role.HOSPITAL_ADMIN,
Role.DIRECTOR,
Role.LEADER,
Role.DOCTOR,
)
@ApiOperation({ summary: '更新患者手术记录' })
@ApiParam({ name: 'id', description: '患者编号' })
@ApiParam({ name: 'surgeryId', description: '手术编号' })
updatePatientSurgery(
@CurrentActor() actor: ActorContext,
@Param('id', ParseIntPipe) id: number,
@Param('surgeryId', ParseIntPipe) surgeryId: number,
@Body() dto: UpdatePatientSurgeryDto,
) {
return this.patientsService.updatePatientSurgery(actor, id, surgeryId, dto);
}
@Get(':id/lifecycle')
@Roles(
Role.SYSTEM_ADMIN,
Role.HOSPITAL_ADMIN,
Role.DIRECTOR,
Role.LEADER,
Role.DOCTOR,
)
@ApiOperation({ summary: '查询患者生命周期事件B端详情页' })
@ApiParam({ name: 'id', description: '患者编号' })
findPatientLifecycle(
@CurrentActor() actor: ActorContext,
@Param('id', ParseIntPipe) id: number,
) {
return this.patientsService.findPatientLifecycle(actor, id);
}
@Get(':id')
@Roles(
Role.SYSTEM_ADMIN,
@ -143,7 +156,7 @@ export class BPatientsController {
Role.DOCTOR,
)
@ApiOperation({ summary: '查询患者详情' })
@ApiParam({ name: 'id', description: '患者 ID' })
@ApiParam({ name: 'id', description: '患者编号' })
findPatientById(
@CurrentActor() actor: ActorContext,
@Param('id', ParseIntPipe) id: number,
@ -151,9 +164,6 @@ export class BPatientsController {
return this.patientsService.findPatientById(actor, id);
}
/**
*
*/
@Patch(':id')
@Roles(
Role.SYSTEM_ADMIN,
@ -162,8 +172,8 @@ export class BPatientsController {
Role.LEADER,
Role.DOCTOR,
)
@ApiOperation({ summary: '更新患者信息' })
@ApiParam({ name: 'id', description: '患者 ID' })
@ApiOperation({ summary: '更新患者档案' })
@ApiParam({ name: 'id', description: '患者编号' })
updatePatient(
@CurrentActor() actor: ActorContext,
@Param('id', ParseIntPipe) id: number,
@ -172,9 +182,6 @@ export class BPatientsController {
return this.patientsService.updatePatient(actor, id, dto);
}
/**
*
*/
@Delete(':id')
@Roles(
Role.SYSTEM_ADMIN,
@ -183,8 +190,8 @@ export class BPatientsController {
Role.LEADER,
Role.DOCTOR,
)
@ApiOperation({ summary: '删除患者' })
@ApiParam({ name: 'id', description: '患者 ID' })
@ApiOperation({ summary: '删除患者档案' })
@ApiParam({ name: 'id', description: '患者编号' })
removePatient(
@CurrentActor() actor: ActorContext,
@Param('id', ParseIntPipe) id: number,

View File

@ -13,7 +13,13 @@ import { MESSAGES } from '../../common/messages.js';
import { normalizePressureLabel } from '../../common/pressure-level.util.js';
import { CreatePatientDto } from '../dto/create-patient.dto.js';
import { CreatePatientSurgeryDto } from '../dto/create-patient-surgery.dto.js';
import { PatientQueryDto } from '../dto/patient-query.dto.js';
import { UpdatePatientSurgeryDto } from '../dto/update-patient-surgery.dto.js';
import { UpdatePatientDto } from '../dto/update-patient.dto.js';
import {
buildPatientLifecycleRecords,
PATIENT_LIFECYCLE_INCLUDE,
} from '../patient-lifecycle.util.js';
import { normalizePatientIdCard } from '../patient-id-card.util.js';
type PrismaExecutor = Prisma.TransactionClient | PrismaService;
@ -147,46 +153,32 @@ export class BPatientsService {
/**
*
*/
async findVisiblePatients(actor: ActorContext, requestedHospitalId?: number) {
const hospitalId = this.resolveHospitalId(actor, requestedHospitalId);
const where = this.buildVisiblePatientWhere(actor, hospitalId);
async findVisiblePatients(actor: ActorContext, query: PatientQueryDto = {}) {
const hospitalId = this.resolveHospitalId(actor, query.hospitalId);
const where = this.buildVisiblePatientWhere(actor, hospitalId, query);
const page = query.page && query.page > 0 ? query.page : 1;
const pageSize =
query.pageSize && query.pageSize > 0 && query.pageSize <= 100
? query.pageSize
: 20;
const patients = await this.prisma.patient.findMany({
const [total, patients] = await this.prisma.$transaction([
this.prisma.patient.count({ where }),
this.prisma.patient.findMany({
where,
include: PATIENT_LIST_INCLUDE,
orderBy: { id: 'desc' },
});
return patients.map((patient) => {
const { _count, surgeries, ...rest } = patient;
const latestSurgery = surgeries[0] ?? null;
const currentDevice =
patient.devices.find(
(device) =>
device.status === DeviceStatus.ACTIVE && !device.isAbandoned,
) ??
patient.devices.find((device) => !device.isAbandoned) ??
patient.devices[0] ??
null;
skip: (page - 1) * pageSize,
take: pageSize,
}),
]);
return {
...rest,
primaryDisease: latestSurgery?.primaryDisease ?? null,
hydrocephalusTypes: latestSurgery?.hydrocephalusTypes ?? [],
surgeryDate: latestSurgery?.surgeryDate ?? null,
currentPressure: currentDevice?.currentPressure ?? null,
initialPressure: currentDevice?.initialPressure ?? null,
shuntSurgeryCount: _count.surgeries,
latestSurgery,
activeDeviceCount: patient.devices.filter(
(device) =>
device.status === DeviceStatus.ACTIVE && !device.isAbandoned,
).length,
abandonedDeviceCount: patient.devices.filter(
(device) => device.isAbandoned,
).length,
total,
page,
pageSize,
list: patients.map((patient) => this.decoratePatientListItem(patient)),
};
});
}
/**
@ -313,6 +305,47 @@ export class BPatientsService {
});
}
/**
*
*/
async updatePatientSurgery(
actor: ActorContext,
patientId: number,
surgeryId: number,
dto: UpdatePatientSurgeryDto,
) {
const patient = await this.findPatientWithScope(patientId);
this.assertPatientScope(actor, patient);
return this.prisma.$transaction(async (tx) => {
const currentSurgery = await this.findPatientSurgeryForUpdate(
tx,
patient.id,
surgeryId,
);
const updatedSurgery = await this.updatePatientSurgeryRecord(
tx,
actor,
patient.id,
patient.doctorId,
currentSurgery,
dto,
);
const detail = await this.loadPatientDetail(tx, patient.id);
const decoratedPatient = this.decoratePatientDetail(detail);
const updated = decoratedPatient.surgeries.find(
(surgery) => surgery.id === updatedSurgery.id,
);
if (!updated) {
throw new NotFoundException(MESSAGES.PATIENT.SURGERY_NOT_FOUND);
}
return updated;
});
}
/**
*
*/
@ -322,6 +355,25 @@ export class BPatientsService {
return this.decoratePatientDetail(patient);
}
async findPatientLifecycle(actor: ActorContext, id: number) {
const patient = await this.prisma.patient.findUnique({
where: { id },
include: PATIENT_LIFECYCLE_INCLUDE,
});
if (!patient) {
throw new NotFoundException(MESSAGES.PATIENT.NOT_FOUND);
}
this.assertPatientScope(actor, patient);
return {
patientId: patient.id,
patientCount: 1,
lifecycle: buildPatientLifecycleRecords([patient]),
};
}
/**
*
*/
@ -425,6 +477,44 @@ export class BPatientsService {
return patient;
}
private async findPatientSurgeryForUpdate(
prisma: PrismaExecutor,
patientId: number,
surgeryId: number,
) {
const normalizedSurgeryId = Number(surgeryId);
if (!Number.isInteger(normalizedSurgeryId)) {
throw new BadRequestException('surgeryId 必须为整数');
}
const surgery = await prisma.patientSurgery.findFirst({
where: {
id: normalizedSurgeryId,
patientId,
},
include: {
devices: {
select: {
id: true,
implantCatalogId: true,
currentPressure: true,
taskItems: {
select: { id: true },
take: 1,
},
},
orderBy: { id: 'desc' },
},
},
});
if (!surgery) {
throw new NotFoundException(MESSAGES.PATIENT.SURGERY_NOT_FOUND);
}
return surgery;
}
/**
*
*/
@ -529,8 +619,12 @@ export class BPatientsService {
/**
*
*/
private buildVisiblePatientWhere(actor: ActorContext, hospitalId: number) {
const where: Record<string, unknown> = { hospitalId };
private buildVisiblePatientWhere(
actor: ActorContext,
hospitalId: number,
query: PatientQueryDto = {},
) {
const where: Prisma.PatientWhereInput = { hospitalId };
switch (actor.role) {
case Role.DOCTOR:
where.doctorId = actor.id;
@ -559,6 +653,71 @@ export class BPatientsService {
default:
throw new ForbiddenException(MESSAGES.PATIENT.ROLE_FORBIDDEN);
}
if (query.doctorId != null) {
const doctorId = this.toInt(query.doctorId, 'doctorId');
if (actor.role === Role.DOCTOR && doctorId !== actor.id) {
throw new ForbiddenException(MESSAGES.PATIENT.DOCTOR_SCOPE_FORBIDDEN);
}
where.doctorId = doctorId;
}
const keyword = query.keyword?.trim();
if (keyword) {
where.OR = [
{
name: {
contains: keyword,
mode: 'insensitive',
},
},
{
phone: {
contains: keyword,
mode: 'insensitive',
},
},
{
idCard: {
contains: keyword,
mode: 'insensitive',
},
},
{
inpatientNo: {
contains: keyword,
mode: 'insensitive',
},
},
{
doctor: {
name: {
contains: keyword,
mode: 'insensitive',
},
},
},
{
hospital: {
name: {
contains: keyword,
mode: 'insensitive',
},
},
},
{
surgeries: {
some: {
surgeryName: {
contains: keyword,
mode: 'insensitive',
},
},
},
},
];
}
return where;
}
@ -773,6 +932,204 @@ export class BPatientsService {
return surgery;
}
private async updatePatientSurgeryRecord(
prisma: PrismaExecutor,
actor: ActorContext,
patientId: number,
patientDoctorId: number,
currentSurgery: Awaited<
ReturnType<BPatientsService['findPatientSurgeryForUpdate']>
>,
dto: UpdatePatientSurgeryDto,
) {
if (
Array.isArray(dto.abandonedDeviceIds) &&
dto.abandonedDeviceIds.length > 0
) {
throw new BadRequestException(
MESSAGES.PATIENT.SURGERY_ABANDON_UPDATE_NOT_SUPPORTED,
);
}
const existingDeviceMap = new Map(
currentSurgery.devices.map((device) => [device.id, device]),
);
const requestedDeviceIds = dto.devices.map((device, index) =>
this.toInt(device.id, `devices[${index}].id`),
);
const uniqueRequestedIds = Array.from(new Set(requestedDeviceIds));
if (
requestedDeviceIds.length !== currentSurgery.devices.length ||
uniqueRequestedIds.length !== currentSurgery.devices.length ||
uniqueRequestedIds.some((deviceId) => !existingDeviceMap.has(deviceId))
) {
throw new BadRequestException(
MESSAGES.PATIENT.SURGERY_DEVICE_SET_UPDATE_NOT_SUPPORTED,
);
}
const catalogIds = Array.from(
new Set(
dto.devices.map((device) =>
this.toInt(device.implantCatalogId, 'implantCatalogId'),
),
),
);
const [catalogMap, surgeon] = await Promise.all([
this.resolveImplantCatalogMap(prisma, catalogIds),
this.resolveWritableDoctor(actor, patientDoctorId),
]);
await prisma.patientSurgery.update({
where: { id: currentSurgery.id },
data: {
surgeryDate: this.normalizeIsoDate(dto.surgeryDate, 'surgeryDate'),
surgeryName: this.normalizeRequiredString(
dto.surgeryName,
'surgeryName',
),
surgeonId: surgeon.id,
surgeonName: surgeon.name,
preOpPressure:
dto.preOpPressure == null
? null
: this.normalizeNonNegativeInteger(
dto.preOpPressure,
'preOpPressure',
),
primaryDisease: this.normalizeRequiredString(
dto.primaryDisease,
'primaryDisease',
),
hydrocephalusTypes: this.normalizeStringArray(
dto.hydrocephalusTypes,
'hydrocephalusTypes',
),
previousShuntSurgeryDate: dto.previousShuntSurgeryDate
? this.normalizeIsoDate(
dto.previousShuntSurgeryDate,
'previousShuntSurgeryDate',
)
: null,
preOpMaterials:
dto.preOpMaterials == null
? Prisma.DbNull
: this.normalizePreOpMaterials(dto.preOpMaterials),
notes:
dto.notes === undefined
? null
: this.normalizeNullableString(dto.notes, 'notes'),
},
});
for (const device of dto.devices) {
const deviceId = this.toInt(device.id, 'devices.id');
const currentDevice = existingDeviceMap.get(deviceId);
if (!currentDevice) {
throw new BadRequestException(
MESSAGES.PATIENT.SURGERY_DEVICE_SET_UPDATE_NOT_SUPPORTED,
);
}
const catalogId = this.toInt(device.implantCatalogId, 'implantCatalogId');
const catalog = catalogMap.get(catalogId);
if (!catalog) {
throw new NotFoundException(MESSAGES.PATIENT.IMPLANT_CATALOG_NOT_FOUND);
}
const catalogChanged = currentDevice.implantCatalogId !== catalog.id;
if (catalogChanged && currentDevice.taskItems.length > 0) {
throw new ConflictException(
MESSAGES.PATIENT.SURGERY_DEVICE_TASK_CONFLICT,
);
}
const initialPressure =
!catalog.isValve || device.initialPressure == null
? null
: this.assertPressureLevelAllowed(
catalog,
this.normalizePressureLevel(
device.initialPressure,
'initialPressure',
),
);
const currentPressure = catalogChanged
? this.resolveUpdatedSurgeryDeviceCurrentPressure(
currentDevice.currentPressure,
catalog,
initialPressure,
)
: currentDevice.currentPressure;
await prisma.device.update({
where: {
id: currentDevice.id,
},
data: {
patient: { connect: { id: patientId } },
surgery: { connect: { id: currentSurgery.id } },
implantCatalog: { connect: { id: catalog.id } },
currentPressure,
implantModel: catalog.modelCode,
implantManufacturer: catalog.manufacturer,
implantName: catalog.name,
isValve: catalog.isValve,
isPressureAdjustable: catalog.isPressureAdjustable,
shuntMode: this.normalizeRequiredString(
device.shuntMode,
'shuntMode',
),
proximalPunctureAreas: this.normalizeStringArray(
device.proximalPunctureAreas,
'proximalPunctureAreas',
),
valvePlacementSites: catalog.isValve
? this.normalizeStringArray(
device.valvePlacementSites,
'valvePlacementSites',
)
: this.normalizeOptionalStringArray(
device.valvePlacementSites,
'valvePlacementSites',
),
distalShuntDirection: this.normalizeRequiredString(
device.distalShuntDirection,
'distalShuntDirection',
),
initialPressure,
implantNotes:
device.implantNotes === undefined
? null
: this.normalizeNullableString(
device.implantNotes,
'implantNotes',
),
labelImageUrl:
device.labelImageUrl === undefined
? null
: this.normalizeNullableString(
device.labelImageUrl,
'labelImageUrl',
),
},
});
}
const updatedSurgery = await prisma.patientSurgery.findUnique({
where: { id: currentSurgery.id },
include: PATIENT_SURGERY_DETAIL_INCLUDE,
});
if (!updatedSurgery) {
throw new NotFoundException(MESSAGES.PATIENT.SURGERY_NOT_FOUND);
}
return updatedSurgery;
}
/**
*
*/
@ -825,9 +1182,90 @@ export class BPatientsService {
return pressure;
}
private resolveUpdatedSurgeryDeviceCurrentPressure(
currentPressure: string,
catalog: {
isValve: boolean;
isPressureAdjustable: boolean;
pressureLevels: string[];
},
initialPressure: string | null,
) {
if (!catalog.isValve) {
return '0';
}
const fallbackPressureLevel =
catalog.pressureLevels.length > 0 ? catalog.pressureLevels[0] : '0';
const normalizedCurrentPressure = this.normalizePressureLevel(
currentPressure,
'currentPressure',
);
if (
catalog.isPressureAdjustable &&
catalog.pressureLevels.length > 0 &&
catalog.pressureLevels.includes(normalizedCurrentPressure)
) {
return normalizedCurrentPressure;
}
if (!catalog.isPressureAdjustable) {
return (
normalizedCurrentPressure || initialPressure || fallbackPressureLevel
);
}
return this.assertPressureLevelAllowed(
catalog,
initialPressure ?? fallbackPressureLevel,
);
}
/**
*
*/
private decoratePatientListItem(
patient: Prisma.PatientGetPayload<{ include: typeof PATIENT_LIST_INCLUDE }>,
) {
const latestSurgery = patient.surgeries[0] ?? null;
const currentDevice =
patient.devices.find(
(device) =>
device.status === DeviceStatus.ACTIVE && !device.isAbandoned,
) ??
patient.devices.find((device) => !device.isAbandoned) ??
patient.devices[0] ??
null;
return {
id: patient.id,
name: patient.name,
inpatientNo: patient.inpatientNo,
phone: patient.phone,
idCard: patient.idCard,
hospitalId: patient.hospitalId,
doctorId: patient.doctorId,
hospital: patient.hospital,
doctor: patient.doctor,
devices: patient.devices,
primaryDisease: latestSurgery?.primaryDisease ?? null,
hydrocephalusTypes: latestSurgery?.hydrocephalusTypes ?? [],
surgeryDate: latestSurgery?.surgeryDate ?? null,
currentPressure: currentDevice?.currentPressure ?? null,
initialPressure: currentDevice?.initialPressure ?? null,
shuntSurgeryCount: patient._count.surgeries,
latestSurgery,
activeDeviceCount: patient.devices.filter(
(device) =>
device.status === DeviceStatus.ACTIVE && !device.isAbandoned,
).length,
abandonedDeviceCount: patient.devices.filter(
(device) => device.isAbandoned,
).length,
};
}
private decoratePatientDetail(
patient: Awaited<ReturnType<BPatientsService['findPatientWithScope']>>,
) {

View File

@ -1,20 +1,15 @@
import {
Injectable,
NotFoundException,
} from '@nestjs/common';
import { PrismaService } from '../../prisma.service.js';
import { Injectable, NotFoundException } from '@nestjs/common';
import { MESSAGES } from '../../common/messages.js';
import { PrismaService } from '../../prisma.service.js';
import {
buildPatientLifecycleRecords,
PATIENT_LIFECYCLE_INCLUDE,
} from '../patient-lifecycle.util.js';
/**
* C
*/
@Injectable()
export class CPatientsService {
constructor(private readonly prisma: PrismaService) {}
/**
* C
*/
async getFamilyLifecycleByAccount(accountId: number) {
const account = await this.prisma.familyMiniAppAccount.findUnique({
where: { id: accountId },
@ -31,180 +26,16 @@ export class CPatientsService {
where: {
phone: account.phone,
},
include: {
hospital: { select: { id: true, name: true } },
surgeries: {
include: {
devices: {
include: {
implantCatalog: {
select: {
id: true,
modelCode: true,
manufacturer: true,
name: true,
isValve: true,
pressureLevels: true,
isPressureAdjustable: true,
notes: true,
},
},
},
},
},
orderBy: { surgeryDate: 'desc' },
},
devices: {
include: {
surgery: {
select: {
id: true,
surgeryDate: true,
surgeryName: true,
},
},
implantCatalog: {
select: {
id: true,
modelCode: true,
manufacturer: true,
name: true,
isValve: true,
pressureLevels: true,
isPressureAdjustable: true,
notes: true,
},
},
taskItems: {
include: {
task: true,
},
},
},
},
},
include: PATIENT_LIFECYCLE_INCLUDE,
});
if (patients.length === 0) {
throw new NotFoundException(MESSAGES.PATIENT.LIFE_CYCLE_NOT_FOUND);
}
const lifecycle = patients
.flatMap((patient) => {
const surgeryEvents = patient.surgeries.map(
(surgery, index, surgeries) => ({
eventType: 'SURGERY',
occurredAt: surgery.surgeryDate,
hospital: patient.hospital,
patient: {
id: this.toJsonNumber(patient.id),
name: patient.name,
inpatientNo: patient.inpatientNo,
projectName: patient.projectName,
},
surgery: {
id: this.toJsonNumber(surgery.id),
surgeryDate: surgery.surgeryDate,
surgeryName: surgery.surgeryName,
surgeonName: surgery.surgeonName,
primaryDisease: surgery.primaryDisease,
hydrocephalusTypes: surgery.hydrocephalusTypes,
previousShuntSurgeryDate: surgery.previousShuntSurgeryDate,
shuntSurgeryCount: surgeries.length - index,
},
devices: surgery.devices.map((device) => ({
id: this.toJsonNumber(device.id),
status: device.status,
isAbandoned: device.isAbandoned,
currentPressure: device.currentPressure,
initialPressure: device.initialPressure,
implantModel: device.implantModel,
implantManufacturer: device.implantManufacturer,
implantName: device.implantName,
isValve: device.isValve,
isPressureAdjustable: device.isPressureAdjustable,
shuntMode: device.shuntMode,
distalShuntDirection: device.distalShuntDirection,
proximalPunctureAreas: device.proximalPunctureAreas,
valvePlacementSites: device.valvePlacementSites,
implantCatalog: device.implantCatalog,
})),
}),
);
const taskEvents = patient.devices.flatMap((device) =>
device.taskItems.flatMap((taskItem) => {
// 容错:若存在脏数据导致 task 为空,直接跳过该条明细,避免接口 500。
if (!taskItem.task) {
return [];
}
const task = taskItem.task;
return [
{
eventType: 'TASK_PRESSURE_ADJUSTMENT',
occurredAt: task.createdAt,
hospital: patient.hospital,
patient: {
id: this.toJsonNumber(patient.id),
name: patient.name,
inpatientNo: patient.inpatientNo,
projectName: patient.projectName,
},
device: {
id: this.toJsonNumber(device.id),
status: device.status,
isAbandoned: device.isAbandoned,
currentPressure: device.currentPressure,
implantModel: device.implantModel,
implantManufacturer: device.implantManufacturer,
implantName: device.implantName,
isValve: device.isValve,
isPressureAdjustable: device.isPressureAdjustable,
},
surgery: device.surgery
? {
id: this.toJsonNumber(device.surgery.id),
surgeryDate: device.surgery.surgeryDate,
surgeryName: device.surgery.surgeryName,
}
: null,
task: {
id: this.toJsonNumber(task.id),
status: task.status,
createdAt: task.createdAt,
},
taskItem: {
id: this.toJsonNumber(taskItem.id),
oldPressure: taskItem.oldPressure,
targetPressure: taskItem.targetPressure,
},
},
];
}),
);
return [...surgeryEvents, ...taskEvents];
})
.sort(
(a, b) =>
new Date(b.occurredAt).getTime() - new Date(a.occurredAt).getTime(),
);
return {
// 前端详情弹窗和现有 E2E 都依赖这两个回显字段。
phone: account.phone,
patientCount: patients.length,
lifecycle,
lifecycle: buildPatientLifecycleRecords(patients),
};
}
/**
* number/bigint JSON number BigInt
*/
private toJsonNumber(value: number | bigint | null | undefined) {
if (value == null) {
return null;
}
return typeof value === 'bigint' ? Number(value) : value;
}
}

View File

@ -95,7 +95,7 @@ export class CreatePatientSurgeryDto {
devices!: CreateSurgeryDeviceDto[];
@ApiPropertyOptional({
description: '本次手术后需弃用的历史设备 ID 列表',
description: '本次手术后需弃用的历史设备编号列表',
type: [Number],
example: [1],
})

View File

@ -40,7 +40,7 @@ export class CreatePatientDto {
@IsString({ message: 'idCard 必须是字符串' })
idCard!: string;
@ApiProperty({ description: '归属人员 ID(医生/主任/组长)', example: 10001 })
@ApiProperty({ description: '归属人员编号(医生/主任/组长)', example: 10001 })
@Type(() => Number)
@IsInt({ message: 'doctorId 必须是整数' })
@Min(1, { message: 'doctorId 必须大于 0' })

View File

@ -15,7 +15,7 @@ import {
*/
export class CreateSurgeryDeviceDto {
@ApiProperty({
description: '植入物型号 ID,选中后自动回填厂家与名称',
description: '植入物型号编号,选中后自动回填厂家与名称',
example: 1,
})
@Type(() => Number)

View File

@ -0,0 +1,51 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import { EmptyStringToUndefined } from '../../common/transforms/empty-string-to-undefined.transform.js';
import { IsInt, IsOptional, IsString, Max, Min } from 'class-validator';
export class PatientQueryDto {
@ApiPropertyOptional({
description: '患者名称、手机号、住院号、医生、医院或手术名称关键字',
example: 'VPS',
})
@IsOptional()
@IsString({ message: 'keyword 必须为字符串' })
keyword?: string;
@ApiPropertyOptional({ description: '医院编号', example: 1 })
@IsOptional()
@EmptyStringToUndefined()
@Type(() => Number)
@IsInt({ message: 'hospitalId 必须为整数' })
@Min(1, { message: 'hospitalId 必须大于 0' })
hospitalId?: number;
@ApiPropertyOptional({ description: '医生编号', example: 1 })
@IsOptional()
@EmptyStringToUndefined()
@Type(() => Number)
@IsInt({ message: 'doctorId 必须为整数' })
@Min(1, { message: 'doctorId 必须大于 0' })
doctorId?: number;
@ApiPropertyOptional({ description: '页码', example: 1, default: 1 })
@IsOptional()
@EmptyStringToUndefined()
@Type(() => Number)
@IsInt({ message: 'page 必须为整数' })
@Min(1, { message: 'page 最小值为 1' })
page?: number = 1;
@ApiPropertyOptional({
description: '每页条数',
example: 20,
default: 20,
})
@IsOptional()
@EmptyStringToUndefined()
@Type(() => Number)
@IsInt({ message: 'pageSize 必须为整数' })
@Min(1, { message: 'pageSize 最小值为 1' })
@Max(100, { message: 'pageSize 最大值为 100' })
pageSize?: number = 20;
}

View File

@ -0,0 +1,17 @@
import { ApiProperty } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import { ArrayMinSize, IsArray, ValidateNested } from 'class-validator';
import { CreatePatientSurgeryDto } from './create-patient-surgery.dto.js';
import { UpdateSurgeryDeviceDto } from './update-surgery-device.dto.js';
export class UpdatePatientSurgeryDto extends CreatePatientSurgeryDto {
@ApiProperty({
description: '本次手术植入设备列表,编辑时需回传已有设备编号',
type: [UpdateSurgeryDeviceDto],
})
@IsArray({ message: 'devices 必须是数组' })
@ArrayMinSize(1, { message: 'devices 至少录入 1 个设备' })
@ValidateNested({ each: true })
@Type(() => UpdateSurgeryDeviceDto)
declare devices: UpdateSurgeryDeviceDto[];
}

View File

@ -0,0 +1,16 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import { IsInt, IsOptional, Min } from 'class-validator';
import { CreateSurgeryDeviceDto } from './create-surgery-device.dto.js';
export class UpdateSurgeryDeviceDto extends CreateSurgeryDeviceDto {
@ApiPropertyOptional({
description: '已存在的手术设备编号,编辑手术时必传',
example: 12,
})
@IsOptional()
@Type(() => Number)
@IsInt({ message: 'devices.id 必须为整数' })
@Min(1, { message: 'devices.id 必须大于 0' })
id?: number;
}

View File

@ -0,0 +1,164 @@
import { Prisma } from '../generated/prisma/client.js';
const IMPLANT_CATALOG_SELECT = {
id: true,
modelCode: true,
manufacturer: true,
name: true,
isValve: true,
pressureLevels: true,
isPressureAdjustable: true,
notes: true,
} as const;
export const PATIENT_LIFECYCLE_INCLUDE = {
hospital: { select: { id: true, name: true } },
doctor: { select: { departmentId: true, groupId: true } },
surgeries: {
include: {
devices: {
include: {
implantCatalog: {
select: IMPLANT_CATALOG_SELECT,
},
},
},
},
orderBy: { surgeryDate: 'desc' },
},
devices: {
include: {
surgery: {
select: {
id: true,
surgeryDate: true,
surgeryName: true,
},
},
implantCatalog: {
select: IMPLANT_CATALOG_SELECT,
},
taskItems: {
include: {
task: true,
},
},
},
},
} as const satisfies Prisma.PatientInclude;
export type PatientLifecycleSource = Prisma.PatientGetPayload<{
include: typeof PATIENT_LIFECYCLE_INCLUDE;
}>;
export function buildPatientLifecycleRecords(
patients: PatientLifecycleSource[],
) {
return patients
.flatMap((patient) => {
const surgeryEvents = patient.surgeries.map((surgery, index, surgeries) => ({
eventType: 'SURGERY',
occurredAt: surgery.surgeryDate,
hospital: patient.hospital,
patient: {
id: toJsonNumber(patient.id),
name: patient.name,
inpatientNo: patient.inpatientNo,
projectName: patient.projectName,
},
surgery: {
id: toJsonNumber(surgery.id),
surgeryDate: surgery.surgeryDate,
surgeryName: surgery.surgeryName,
surgeonName: surgery.surgeonName,
primaryDisease: surgery.primaryDisease,
hydrocephalusTypes: surgery.hydrocephalusTypes,
previousShuntSurgeryDate: surgery.previousShuntSurgeryDate,
shuntSurgeryCount: surgeries.length - index,
},
devices: surgery.devices.map((device) => ({
id: toJsonNumber(device.id),
status: device.status,
isAbandoned: device.isAbandoned,
currentPressure: device.currentPressure,
initialPressure: device.initialPressure,
implantModel: device.implantModel,
implantManufacturer: device.implantManufacturer,
implantName: device.implantName,
isValve: device.isValve,
isPressureAdjustable: device.isPressureAdjustable,
shuntMode: device.shuntMode,
distalShuntDirection: device.distalShuntDirection,
proximalPunctureAreas: device.proximalPunctureAreas,
valvePlacementSites: device.valvePlacementSites,
implantCatalog: device.implantCatalog,
})),
}));
const taskEvents = patient.devices.flatMap((device) =>
device.taskItems.flatMap((taskItem) => {
if (!taskItem.task) {
return [];
}
const task = taskItem.task;
return [
{
eventType: 'TASK_PRESSURE_ADJUSTMENT',
occurredAt: task.createdAt,
hospital: patient.hospital,
patient: {
id: toJsonNumber(patient.id),
name: patient.name,
inpatientNo: patient.inpatientNo,
projectName: patient.projectName,
},
device: {
id: toJsonNumber(device.id),
status: device.status,
isAbandoned: device.isAbandoned,
currentPressure: device.currentPressure,
implantModel: device.implantModel,
implantManufacturer: device.implantManufacturer,
implantName: device.implantName,
isValve: device.isValve,
isPressureAdjustable: device.isPressureAdjustable,
},
surgery: device.surgery
? {
id: toJsonNumber(device.surgery.id),
surgeryDate: device.surgery.surgeryDate,
surgeryName: device.surgery.surgeryName,
}
: null,
task: {
id: toJsonNumber(task.id),
status: task.status,
createdAt: task.createdAt,
},
taskItem: {
id: toJsonNumber(taskItem.id),
oldPressure: taskItem.oldPressure,
targetPressure: taskItem.targetPressure,
},
},
];
}),
);
return [...surgeryEvents, ...taskEvents];
})
.sort(
(left, right) =>
new Date(right.occurredAt).getTime() -
new Date(left.occurredAt).getTime(),
);
}
function toJsonNumber(value: number | bigint | null | undefined) {
if (value == null) {
return null;
}
return typeof value === 'bigint' ? Number(value) : value;
}

View File

@ -36,6 +36,19 @@ import { extname } from 'node:path';
import { randomUUID } from 'node:crypto';
const MAX_UPLOAD_SIZE = 1024 * 1024 * 200;
const ALLOWED_FILE_EXTENSIONS_BY_MIME = new Map<string, string[]>([
['application/pdf', ['.pdf']],
['application/msword', ['.doc']],
[
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
['.docx'],
],
['application/vnd.ms-excel', ['.xls']],
[
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
['.xlsx'],
],
]);
function isAllowedMimeType(mimeType: string) {
return (
@ -51,6 +64,22 @@ function isAllowedMimeType(mimeType: string) {
);
}
function isAllowedOriginalExtension(
originalName: string,
mimeType: string,
) {
if (mimeType.startsWith('image/') || mimeType.startsWith('video/')) {
return true;
}
const allowedExtensions = ALLOWED_FILE_EXTENSIONS_BY_MIME.get(mimeType);
if (!allowedExtensions) {
return false;
}
return allowedExtensions.includes(extname(originalName).toLowerCase());
}
@ApiTags('上传资产(B端)')
@ApiBearerAuth('bearer')
@Controller('b/uploads')
@ -83,7 +112,10 @@ export class BUploadsController {
}),
limits: { fileSize: MAX_UPLOAD_SIZE },
fileFilter: (_req, file, cb) => {
if (!isAllowedMimeType(file.mimetype)) {
if (
!isAllowedMimeType(file.mimetype) ||
!isAllowedOriginalExtension(file.originalname, file.mimetype)
) {
cb(
new BadRequestException(MESSAGES.UPLOAD.UNSUPPORTED_FILE_TYPE),
false,

View File

@ -5,7 +5,7 @@ import {
NotFoundException,
} from '@nestjs/common';
import { spawn } from 'node:child_process';
import { access, mkdir, rename, stat, unlink } from 'node:fs/promises';
import { access, mkdir, open, rename, stat, unlink } from 'node:fs/promises';
import { extname, join } from 'node:path';
import { randomUUID } from 'node:crypto';
import ffmpegPath from 'ffmpeg-static';
@ -22,6 +22,20 @@ import {
resolveUploadTempDir,
} from './upload-path.util.js';
const SAFE_FILE_MIME_TYPES: Record<string, string> = {
'.pdf': 'application/pdf',
'.doc': 'application/msword',
'.docx':
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'.xls': 'application/vnd.ms-excel',
'.xlsx':
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
};
const OLE_SIGNATURE = Buffer.from([
0xd0, 0xcf, 0x11, 0xe0, 0xa1, 0xb1, 0x1a, 0xe1,
]);
@Injectable()
export class UploadsService {
constructor(private readonly prisma: PrismaService) {}
@ -237,12 +251,7 @@ export class UploadsService {
};
}
return {
tempPath: file.path,
outputExtension: extname(file.originalname),
mimeType: file.mimetype,
fileSize: file.size,
};
return this.prepareGenericFile(file);
}
private async buildFinalFileName(
@ -299,6 +308,65 @@ export class UploadsService {
}
}
private async prepareGenericFile(file: Express.Multer.File) {
const outputExtension = this.normalizeSafeFileExtension(file.originalname);
const signature = await this.readFileSignature(file.path, 8);
if (!this.isSafeFileSignature(outputExtension, signature)) {
throw new BadRequestException(MESSAGES.UPLOAD.INVALID_FILE_SIGNATURE);
}
const fileInfo = await stat(file.path);
return {
tempPath: file.path,
outputExtension,
mimeType: SAFE_FILE_MIME_TYPES[outputExtension],
fileSize: fileInfo.size,
};
}
private normalizeSafeFileExtension(originalName: string) {
const extension = extname(originalName).toLowerCase();
if (!SAFE_FILE_MIME_TYPES[extension]) {
throw new BadRequestException(MESSAGES.UPLOAD.UNSUPPORTED_FILE_TYPE);
}
return extension;
}
private async readFileSignature(path: string, byteLength: number) {
const handle = await open(path, 'r');
try {
const buffer = Buffer.alloc(byteLength);
const { bytesRead } = await handle.read(buffer, 0, byteLength, 0);
return buffer.subarray(0, bytesRead);
} finally {
await handle.close();
}
}
private isSafeFileSignature(extension: string, signature: Buffer) {
switch (extension) {
case '.pdf':
return signature.subarray(0, 5).toString('ascii') === '%PDF-';
case '.doc':
case '.xls':
return signature.length >= OLE_SIGNATURE.length &&
signature.subarray(0, OLE_SIGNATURE.length).equals(OLE_SIGNATURE);
case '.docx':
case '.xlsx':
return (
signature.length >= 4 &&
signature[0] === 0x50 &&
signature[1] === 0x4b &&
[0x03, 0x05, 0x07].includes(signature[2]) &&
[0x04, 0x06, 0x08].includes(signature[3])
);
default:
return false;
}
}
private async compressVideo(inputPath: string, outputPath: string) {
const command = ffmpegPath as unknown as string | null;
if (!command) {

View File

@ -24,10 +24,14 @@ export const createPatientSurgery = (id, data) => {
return request.post(`/b/patients/${id}/surgeries`, data);
};
export const updatePatientSurgery = (patientId, surgeryId, data) => {
return request.patch(`/b/patients/${patientId}/surgeries/${surgeryId}`, data);
};
export const deletePatient = (id) => {
return request.delete(`/b/patients/${id}`);
};
export const getPatientLifecycle = (params) => {
return request.get('/c/patients/lifecycle', { params });
export const getPatientLifecycle = (id) => {
return request.get(`/b/patients/${id}/lifecycle`);
};

View File

@ -0,0 +1,16 @@
export const ROLE_LABELS = Object.freeze({
SYSTEM_ADMIN: '系统管理员',
HOSPITAL_ADMIN: '医院管理员',
DIRECTOR: '科室主任',
LEADER: '小组组长',
DOCTOR: '医生',
ENGINEER: '工程师',
});
export function getRoleLabel(role) {
if (!role) {
return '';
}
return ROLE_LABELS[role] || role;
}

View File

@ -84,12 +84,14 @@
<el-container>
<el-header class="header">
<div class="header-left">
<!-- Breadcrumbs can go here -->
<!-- 这里预留面包屑区域 -->
</div>
<div class="header-right">
<el-dropdown @command="handleCommand">
<span class="el-dropdown-link user-info">
{{ userStore.userInfo?.name || '用户' }} ({{ userStore.role }})
{{ userStore.userInfo?.name || '用户' }}{{
getRoleLabel(userStore.role) || '未分配角色'
}}
<el-icon class="el-icon--right">
<arrow-down />
</el-icon>
@ -117,6 +119,7 @@
<script setup>
import { computed } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { getRoleLabel } from '../constants/role-labels';
import { useUserStore } from '../store/user';
import {
ROLE_PERMISSIONS,

View File

@ -2,7 +2,7 @@
<div class="dashboard">
<el-card shadow="never" class="welcome-card">
<h2>欢迎使用调压通管理后台</h2>
<p>当前角色{{ userStore.role || '未登录' }}</p>
<p>当前角色{{ getRoleLabel(userStore.role) || '未登录' }}</p>
</el-card>
<el-card v-if="isSystemAdmin" shadow="never" class="filter-card">
@ -59,6 +59,7 @@ import { useUserStore } from '../store/user';
import { getHospitals, getDepartments, getGroups } from '../api/organization';
import { getUsers } from '../api/users';
import { getPatients } from '../api/patients';
import { getRoleLabel } from '../constants/role-labels';
const userStore = useUserStore();
const loading = ref(false);
@ -158,10 +159,17 @@ const fetchDashboardData = async () => {
if (isSystemAdmin.value && !selectedHospitalId.value) {
stats.value.patients = 0;
} else {
const patientRes = await getPatients(params);
stats.value.patients = Array.isArray(patientRes)
const patientRes = await getPatients({
page: 1,
pageSize: 1,
...params,
});
stats.value.patients = Number(
patientRes?.total ??
(Array.isArray(patientRes)
? patientRes.length
: 0;
: patientRes?.list?.length ?? 0),
);
}
}
} finally {

View File

@ -86,6 +86,7 @@ import { useRouter, useRoute } from 'vue-router';
import { useUserStore } from '../store/user';
import { User, Lock } from '@element-plus/icons-vue';
import { ElMessage } from 'element-plus';
import { getRoleLabel } from '../constants/role-labels';
const router = useRouter();
const route = useRoute();
@ -116,7 +117,7 @@ const rules = {
};
const formatAccountMeta = (account) => {
const parts = [account.role];
const parts = [getRoleLabel(account.role)];
if (account.hospitalName) {
parts.push(account.hospitalName);
}
@ -157,7 +158,7 @@ const handleLogin = async () => {
ElMessage.error('登录失败,未获取到登录信息');
}
} catch (error) {
console.error('Login failed', error);
console.error('登录请求失败', error);
} finally {
loading.value = false;
}
@ -185,7 +186,7 @@ const handleConfirmLogin = async () => {
ElMessage.error('登录失败,未获取到登录信息');
} catch (error) {
console.error('Confirm login failed', error);
console.error('确认登录失败', error);
} finally {
confirmLoading.value = false;
}

View File

@ -78,7 +78,11 @@
</el-table-column>
<el-table-column prop="phone" label="联系电话" min-width="140" />
<el-table-column prop="idCard" label="身份证号" min-width="200" />
<el-table-column v-if="canViewHospitalInfo" label="归属医院" min-width="150">
<el-table-column
v-if="canViewHospitalInfo"
label="归属医院"
min-width="150"
>
<template #default="{ row }">
{{ row.hospital?.name || '-' }}
</template>
@ -88,7 +92,7 @@
{{ row.doctor?.name || '-' }}
</template>
</el-table-column>
<el-table-column label="操作" width="360" fixed="right" align="center">
<el-table-column label="操作" width="440" fixed="right" align="center">
<template #default="{ row }">
<el-button
v-if="canPublishAdjustTask"
@ -106,6 +110,17 @@
>
详情
</el-button>
<el-button
v-if="row.latestSurgery?.id"
size="small"
@click="
row.shuntSurgeryCount > 1
? openSurgeriesTab(row)
: openSurgeryDialog(row, row.latestSurgery.id)
"
>
{{ row.shuntSurgeryCount > 1 ? '选择手术' : '编辑手术' }}
</el-button>
<el-button
size="small"
type="success"
@ -131,8 +146,8 @@
:total="total"
background
layout="total, sizes, prev, pager, next, jumper"
@size-change="applyFiltersAndPagination"
@current-change="applyFiltersAndPagination"
@size-change="fetchData"
@current-change="fetchData"
/>
</div>
</el-card>
@ -257,7 +272,7 @@
</el-dialog>
<el-dialog
title="追加手术"
:title="isSurgeryEditMode ? '编辑手术' : '追加手术'"
v-model="surgeryDialogVisible"
width="1120px"
top="3vh"
@ -272,15 +287,29 @@
class="context-alert"
>
<template #title>
<template v-if="isSurgeryEditMode">
正在编辑患者{{ surgeryPatientContext.name }}的手术记录
<span v-if="surgeryPatientContext.editingSurgery">
{{ surgeryPatientContext.editingSurgery.surgeryName }} {{
formatDateTime(
surgeryPatientContext.editingSurgery.surgeryDate,
)
}}
</span>
</template>
<template v-else>
为患者{{ surgeryPatientContext.name }}追加手术
<span v-if="surgeryPatientContext.latestSurgery">
上次手术
{{ surgeryPatientContext.latestSurgery.surgeryName }}
{{
formatDateTime(surgeryPatientContext.latestSurgery.surgeryDate)
formatDateTime(
surgeryPatientContext.latestSurgery.surgeryDate,
)
}}
</span>
</template>
</template>
</el-alert>
<SurgeryFormSection
@ -291,9 +320,14 @@
:dictionary-options="medicalDictionaryOptions"
:upload-hospital-id="surgeryPatientContext.hospitalId"
:abandonable-devices="surgeryPatientContext.activeDevices"
:show-abandon-selector="true"
title="手术录入"
description="若本次手术后需要停用旧设备,可在上方直接勾选弃用。"
:show-abandon-selector="!isSurgeryEditMode"
:allow-device-structure-edit="!isSurgeryEditMode"
:title="isSurgeryEditMode ? '编辑手术内容' : '手术录入'"
:description="
isSurgeryEditMode
? '可修改本次手术及已有植入设备信息;如需调整历史弃用设备,请通过追加手术处理。'
: '若本次手术后需要停用旧设备,可在上方直接勾选弃用。'
"
/>
</div>
@ -305,7 +339,7 @@
:loading="surgerySubmitLoading"
@click="handleSubmitSurgery"
>
保存手术
{{ isSurgeryEditMode ? '保存修改' : '保存手术' }}
</el-button>
</div>
</template>
@ -421,7 +455,10 @@
<el-descriptions-item label="创建人">
{{ detailPatient.creator?.name || '-' }}
</el-descriptions-item>
<el-descriptions-item v-if="canViewHospitalInfo" label="归属医院">
<el-descriptions-item
v-if="canViewHospitalInfo"
label="归属医院"
>
{{ detailPatient.hospital?.name || '-' }}
</el-descriptions-item>
<el-descriptions-item label="建档时间">
@ -460,6 +497,14 @@
</div>
</div>
<div class="surgery-card-tags">
<el-button
size="small"
type="primary"
plain
@click="openSurgeryEditFromDetail(surgery)"
>
编辑手术
</el-button>
<el-tag type="primary">
{{ surgery.shuntSurgeryCount || '-' }} 次分流手术
</el-tag>
@ -716,7 +761,11 @@
{{ row.surgery?.surgeryName || '-' }}
</template>
</el-table-column>
<el-table-column v-if="canViewHospitalInfo" label="医院" min-width="150">
<el-table-column
v-if="canViewHospitalInfo"
label="医院"
min-width="150"
>
<template #default="{ row }">
{{ row.hospital?.name || '-' }}
</template>
@ -757,6 +806,7 @@ import {
getPatientLifecycle,
getPatients,
updatePatient,
updatePatientSurgery,
} from '../../api/patients';
import { getImplantCatalogs } from '../../api/devices';
import { getDictionaries } from '../../api/dictionaries';
@ -805,7 +855,6 @@ const submitLoading = ref(false);
const surgerySubmitLoading = ref(false);
const detailLoading = ref(false);
const allPatients = ref([]);
const tableData = ref([]);
const total = ref(0);
const page = ref(1);
@ -832,6 +881,9 @@ const currentEditId = ref(null);
const surgeryDialogVisible = ref(false);
const surgeryPatientContext = ref(null);
const surgeryDialogMode = ref('create');
const currentEditSurgeryId = ref(null);
const reopenDetailAfterSurgerySubmit = ref(false);
const adjustDialogVisible = ref(false);
const adjustSubmitLoading = ref(false);
const currentAdjustPatient = ref(null);
@ -867,6 +919,7 @@ const currentAdjustDevices = computed(() => {
isAdjustableDeviceAvailable(device),
);
});
const isSurgeryEditMode = computed(() => surgeryDialogMode.value === 'edit');
const currentAdjustDevice = computed(() => {
return (
@ -939,8 +992,18 @@ function createSurgeryForm() {
};
}
function createDeviceFormItem() {
function createMaterialFormItem(overrides = {}) {
return {
type: 'IMAGE',
name: '',
url: '',
...overrides,
};
}
function createDeviceFormItem(overrides = {}) {
return {
id: null,
implantCatalogId: null,
shuntMode: '',
proximalPunctureAreas: [],
@ -949,6 +1012,7 @@ function createDeviceFormItem() {
initialPressure: '',
implantNotes: '',
labelImageUrl: '',
...overrides,
};
}
@ -1102,8 +1166,10 @@ function validateSurgeryForm(form) {
return '';
}
function buildSurgeryPayload(form) {
return {
function buildSurgeryPayload(form, options = {}) {
const includeAbandonedDeviceIds = options.includeAbandonedDeviceIds !== false;
const payload = {
surgeryDate: new Date(form.surgeryDate).toISOString(),
surgeryName: String(form.surgeryName || '').trim(),
preOpPressure:
@ -1122,7 +1188,7 @@ function buildSurgeryPayload(form) {
}))
.filter((material) => material.url),
devices: (form.devices || []).map((device) => {
return {
const payloadDevice = {
implantCatalogId: device.implantCatalogId,
shuntMode: String(device.shuntMode || '').trim(),
proximalPunctureAreas: normalizeStringArray(
@ -1141,10 +1207,68 @@ function buildSurgeryPayload(form) {
implantNotes: normalizeOptionalString(device.implantNotes),
labelImageUrl: normalizeOptionalString(device.labelImageUrl),
};
if (Number.isInteger(Number(device.id)) && Number(device.id) > 0) {
payloadDevice.id = Number(device.id);
}
return payloadDevice;
}),
abandonedDeviceIds: normalizeStringArray(form.abandonedDeviceIds).map(
Number,
};
if (includeAbandonedDeviceIds) {
payload.abandonedDeviceIds = normalizeStringArray(
form.abandonedDeviceIds,
).map(Number);
}
return payload;
}
function mapSurgeryToForm(surgery) {
return {
surgeryDate: toDateTimeFormValue(surgery?.surgeryDate),
surgeryName: surgery?.surgeryName || '',
preOpPressure:
typeof surgery?.preOpPressure === 'number' ? surgery.preOpPressure : null,
primaryDisease: surgery?.primaryDisease || '',
hydrocephalusTypes: Array.isArray(surgery?.hydrocephalusTypes)
? [...surgery.hydrocephalusTypes]
: [],
previousShuntSurgeryDate: toDateTimeFormValue(
surgery?.previousShuntSurgeryDate,
),
notes: surgery?.notes || '',
preOpMaterials: Array.isArray(surgery?.preOpMaterials)
? surgery.preOpMaterials.map((material) =>
createMaterialFormItem({
type: material?.type || 'IMAGE',
name: material?.name || '',
url: material?.url || '',
}),
)
: [],
devices:
Array.isArray(surgery?.devices) && surgery.devices.length > 0
? surgery.devices.map((device) =>
createDeviceFormItem({
id: device.id,
implantCatalogId: device.implantCatalogId ?? null,
shuntMode: device.shuntMode || '',
proximalPunctureAreas: Array.isArray(device.proximalPunctureAreas)
? [...device.proximalPunctureAreas]
: [],
valvePlacementSites: Array.isArray(device.valvePlacementSites)
? [...device.valvePlacementSites]
: [],
distalShuntDirection: device.distalShuntDirection || '',
initialPressure: device.initialPressure || '',
implantNotes: device.implantNotes || '',
labelImageUrl: device.labelImageUrl || '',
}),
)
: [createDeviceFormItem()],
abandonedDeviceIds: [],
};
}
@ -1293,34 +1417,7 @@ function formatAdjustRecordPressureChange(event) {
}
function applyFiltersAndPagination() {
const keyword = String(searchForm.keyword || '').trim();
const doctorId = searchForm.doctorId ? Number(searchForm.doctorId) : null;
const filtered = allPatients.value.filter((patient) => {
if (doctorId && patient.doctorId !== doctorId) {
return false;
}
if (!keyword) {
return true;
}
return [
patient.name,
patient.phone,
patient.idCard,
patient.inpatientNo,
patient.doctor?.name,
patient.hospital?.name,
patient.latestSurgery?.surgeryName,
]
.filter(Boolean)
.some((field) => String(field).includes(keyword));
});
total.value = filtered.length;
const start = (page.value - 1) * pageSize.value;
tableData.value = filtered.slice(start, start + pageSize.value);
page.value = Math.max(1, Number(page.value) || 1);
}
function syncDoctorFilterFromRoute() {
@ -1388,7 +1485,6 @@ async function fetchMedicalDictionaryOptions() {
async function fetchData() {
if (isSystemAdmin.value && !searchForm.hospitalId) {
allPatients.value = [];
tableData.value = [];
total.value = 0;
return;
@ -1396,14 +1492,23 @@ async function fetchData() {
loading.value = true;
try {
const params = {};
const params = {
page: page.value,
pageSize: pageSize.value,
};
if (isSystemAdmin.value) {
params.hospitalId = searchForm.hospitalId;
}
if (searchForm.keyword) {
params.keyword = searchForm.keyword;
}
if (searchForm.doctorId) {
params.doctorId = searchForm.doctorId;
}
const res = await getPatients(params);
allPatients.value = Array.isArray(res) ? res : [];
applyFiltersAndPagination();
tableData.value = Array.isArray(res?.list) ? res.list : [];
total.value = Number(res?.total || 0);
} finally {
loading.value = false;
}
@ -1411,6 +1516,8 @@ async function fetchData() {
async function handleSearchHospitalChange() {
page.value = 1;
searchForm.doctorId = null;
searchForm.doctorName = '';
await Promise.all([
fetchOrgNodesForDoctorTree(searchForm.hospitalId),
fetchDoctorOptions(searchForm.hospitalId),
@ -1452,6 +1559,9 @@ function resetPatientForm() {
function resetSurgeryDialog() {
appendSurgeryForm.value = createSurgeryForm();
surgeryPatientContext.value = null;
surgeryDialogMode.value = 'create';
currentEditSurgeryId.value = null;
reopenDetailAfterSurgerySubmit.value = false;
}
async function ensureCreateContext() {
@ -1553,7 +1663,7 @@ async function handleSubmitPatient() {
}
}
async function openSurgeryDialog(row) {
async function openSurgeryDialog(row, surgeryId = null) {
const detail = await getPatientById(row.id);
const hospitalId = detail.hospital?.id || detail.hospitalId;
@ -1562,16 +1672,37 @@ async function openSurgeryDialog(row) {
fetchDoctorOptions(hospitalId),
]);
appendSurgeryForm.value = createSurgeryForm();
const normalizedSurgeryId = Number(surgeryId);
const editingSurgery =
Number.isInteger(normalizedSurgeryId) && normalizedSurgeryId > 0
? (detail.surgeries || []).find(
(item) => item.id === normalizedSurgeryId,
) || null
: null;
if (surgeryId != null && !editingSurgery) {
ElMessage.warning('未找到要编辑的手术记录');
return;
}
surgeryDialogMode.value = editingSurgery ? 'edit' : 'create';
currentEditSurgeryId.value = editingSurgery?.id || null;
appendSurgeryForm.value = editingSurgery
? mapSurgeryToForm(editingSurgery)
: createSurgeryForm();
if (!editingSurgery) {
appendSurgeryForm.value.previousShuntSurgeryDate = toDateTimeFormValue(
detail.latestSurgery?.surgeryDate || detail.surgeries?.[0]?.surgeryDate,
);
}
surgeryPatientContext.value = {
id: detail.id,
name: detail.name,
hospitalId,
latestSurgery: detail.latestSurgery || detail.surgeries?.[0] || null,
editingSurgery,
surgeonLabel:
formatDoctorOptionLabel(detail.doctor) ||
resolveSurgerySurgeonLabel(detail.doctorId),
@ -1597,17 +1728,34 @@ async function handleSubmitSurgery() {
surgerySubmitLoading.value = true;
try {
const patientId = surgeryPatientContext.value.id;
await createPatientSurgery(
const payload = buildSurgeryPayload(appendSurgeryForm.value, {
includeAbandonedDeviceIds: !isSurgeryEditMode.value,
});
if (isSurgeryEditMode.value) {
await updatePatientSurgery(
patientId,
buildSurgeryPayload(appendSurgeryForm.value),
currentEditSurgeryId.value,
payload,
);
ElMessage.success('手术记录已更新');
} else {
await createPatientSurgery(patientId, payload);
ElMessage.success('手术记录已保存');
}
surgeryDialogVisible.value = false;
await fetchData();
if (detailDialogVisible.value && detailPatient.value?.id === patientId) {
if (
(detailDialogVisible.value || reopenDetailAfterSurgerySubmit.value) &&
detailPatient.value?.id === patientId
) {
await openDetailDialog({ ...detailPatient.value });
detailTab.value = 'surgeries';
}
reopenDetailAfterSurgerySubmit.value = false;
} finally {
surgerySubmitLoading.value = false;
}
@ -1617,10 +1765,26 @@ function openSurgeryFromDetail() {
if (!detailPatient.value) {
return;
}
reopenDetailAfterSurgerySubmit.value = true;
detailDialogVisible.value = false;
openSurgeryDialog(detailPatient.value);
}
async function openSurgeriesTab(row) {
await openDetailDialog(row);
detailTab.value = 'surgeries';
}
function openSurgeryEditFromDetail(surgery) {
if (!detailPatient.value || !surgery?.id) {
return;
}
reopenDetailAfterSurgerySubmit.value = true;
detailDialogVisible.value = false;
openSurgeryDialog(detailPatient.value, surgery.id);
}
function resolveDevicePressureLevels(device) {
const pressureLevels = device?.implantCatalog?.pressureLevels;
return Array.isArray(pressureLevels) ? pressureLevels : [];
@ -1800,17 +1964,14 @@ async function openDetailDialog(row) {
try {
const detailPromise = getPatientById(row.id);
const lifecyclePromise =
row.phone && row.idCard
? getPatientLifecycle({
phone: row.phone,
idCard: row.idCard,
}).catch(() => ({ lifecycle: [] }))
: Promise.resolve({ lifecycle: [] });
const lifecyclePromise = getPatientLifecycle(row.id);
const [detail, lifecycle] = await Promise.all([
detailPromise,
lifecyclePromise,
lifecyclePromise.catch((error) => {
ElMessage.error(error?.response?.data?.msg || '患者生命周期加载失败');
return { lifecycle: [] };
}),
]);
detailPatient.value = detail;

View File

@ -212,7 +212,12 @@
选型号后自动联动厂家和名称支持一次手术录入多台设备
</div>
</div>
<el-button type="primary" @click="addDevice">新增设备</el-button>
<el-button
v-if="allowDeviceStructureEdit"
type="primary"
@click="addDevice"
>新增设备</el-button
>
</div>
</template>
@ -224,6 +229,7 @@
<div class="device-card-head">
<div class="device-card-title">设备 {{ index + 1 }}</div>
<el-button
v-if="allowDeviceStructureEdit"
type="danger"
plain
@click="removeDevice(index)"
@ -477,6 +483,10 @@ const props = defineProps({
type: Boolean,
default: false,
},
allowDeviceStructureEdit: {
type: Boolean,
default: true,
},
title: {
type: String,
default: '',