患者列表改为服务端分页/筛选:支持 page、pageSize、keyword、doctorId 参数
患者详情页支持“编辑手术”流程:单手术直接编辑,多手术先进入手术列表选择 手术弹窗新增编辑模式:禁用设备结构增删,仅允许修改既有手术及设备字段 新增更新手术 API:PATCH /b/patients/:patientId/surgeries/:surgeryId 生命周期查询改为 B 端接口:GET /b/patients/:id/lifecycle 手术提交后支持回到详情并保持手术标签页,提升连续操作效率
This commit is contained in:
parent
21941e94fd
commit
c830a2131e
@ -24,6 +24,7 @@
|
|||||||
"@nestjs/mapped-types": "*",
|
"@nestjs/mapped-types": "*",
|
||||||
"@nestjs/platform-express": "^11.0.1",
|
"@nestjs/platform-express": "^11.0.1",
|
||||||
"@nestjs/swagger": "^11.2.6",
|
"@nestjs/swagger": "^11.2.6",
|
||||||
|
"@nestjs/throttler": "^6.5.0",
|
||||||
"@prisma/adapter-pg": "^7.5.0",
|
"@prisma/adapter-pg": "^7.5.0",
|
||||||
"@prisma/client": "^7.5.0",
|
"@prisma/client": "^7.5.0",
|
||||||
"bcrypt": "^6.0.0",
|
"bcrypt": "^6.0.0",
|
||||||
@ -31,6 +32,7 @@
|
|||||||
"class-validator": "^0.15.1",
|
"class-validator": "^0.15.1",
|
||||||
"dotenv": "^17.3.1",
|
"dotenv": "^17.3.1",
|
||||||
"ffmpeg-static": "^5.3.0",
|
"ffmpeg-static": "^5.3.0",
|
||||||
|
"helmet": "^8.1.0",
|
||||||
"jsonwebtoken": "^9.0.3",
|
"jsonwebtoken": "^9.0.3",
|
||||||
"multer": "^2.1.1",
|
"multer": "^2.1.1",
|
||||||
"pg": "^8.20.0",
|
"pg": "^8.20.0",
|
||||||
|
|||||||
25
pnpm-lock.yaml
generated
25
pnpm-lock.yaml
generated
@ -26,6 +26,9 @@ importers:
|
|||||||
'@nestjs/swagger':
|
'@nestjs/swagger':
|
||||||
specifier: ^11.2.6
|
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)
|
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':
|
'@prisma/adapter-pg':
|
||||||
specifier: ^7.5.0
|
specifier: ^7.5.0
|
||||||
version: 7.5.0
|
version: 7.5.0
|
||||||
@ -47,6 +50,9 @@ importers:
|
|||||||
ffmpeg-static:
|
ffmpeg-static:
|
||||||
specifier: ^5.3.0
|
specifier: ^5.3.0
|
||||||
version: 5.3.0
|
version: 5.3.0
|
||||||
|
helmet:
|
||||||
|
specifier: ^8.1.0
|
||||||
|
version: 8.1.0
|
||||||
jsonwebtoken:
|
jsonwebtoken:
|
||||||
specifier: ^9.0.3
|
specifier: ^9.0.3
|
||||||
version: 9.0.3
|
version: 9.0.3
|
||||||
@ -917,6 +923,13 @@ packages:
|
|||||||
'@nestjs/platform-express':
|
'@nestjs/platform-express':
|
||||||
optional: true
|
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':
|
'@noble/hashes@1.8.0':
|
||||||
resolution: {integrity: sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==}
|
resolution: {integrity: sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==}
|
||||||
engines: {node: ^14.21.3 || >=16}
|
engines: {node: ^14.21.3 || >=16}
|
||||||
@ -2079,6 +2092,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==}
|
resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
|
|
||||||
|
helmet@8.1.0:
|
||||||
|
resolution: {integrity: sha512-jOiHyAZsmnr8LqoPGmCjYAaiuWwjAPLgY8ZX2XrmHawt99/u1y6RgrZMTeoPfpUbV96HOalYgz1qzkRbw54Pmg==}
|
||||||
|
engines: {node: '>=18.0.0'}
|
||||||
|
|
||||||
hono@4.11.4:
|
hono@4.11.4:
|
||||||
resolution: {integrity: sha512-U7tt8JsyrxSRKspfhtLET79pU8K+tInj5QZXs1jSugO1Vq5dFj3kmZsRldo29mTBfcjDRVRXrEZ6LS63Cog9ZA==}
|
resolution: {integrity: sha512-U7tt8JsyrxSRKspfhtLET79pU8K+tInj5QZXs1jSugO1Vq5dFj3kmZsRldo29mTBfcjDRVRXrEZ6LS63Cog9ZA==}
|
||||||
engines: {node: '>=16.9.0'}
|
engines: {node: '>=16.9.0'}
|
||||||
@ -4332,6 +4349,12 @@ snapshots:
|
|||||||
optionalDependencies:
|
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/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': {}
|
'@noble/hashes@1.8.0': {}
|
||||||
|
|
||||||
'@nuxt/opencollective@0.4.1':
|
'@nuxt/opencollective@0.4.1':
|
||||||
@ -5559,6 +5582,8 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
function-bind: 1.1.2
|
function-bind: 1.1.2
|
||||||
|
|
||||||
|
helmet@8.1.0: {}
|
||||||
|
|
||||||
hono@4.11.4: {}
|
hono@4.11.4: {}
|
||||||
|
|
||||||
html-escaper@2.0.2: {}
|
html-escaper@2.0.2: {}
|
||||||
|
|||||||
@ -1,3 +1,9 @@
|
|||||||
onlyBuiltDependencies:
|
onlyBuiltDependencies:
|
||||||
|
- '@nestjs/core'
|
||||||
|
- '@prisma/engines'
|
||||||
|
- '@scarf/scarf'
|
||||||
|
- bcrypt
|
||||||
- ffmpeg-static
|
- ffmpeg-static
|
||||||
|
- prisma
|
||||||
- sharp
|
- sharp
|
||||||
|
- unrs-resolver
|
||||||
|
|||||||
@ -1,5 +1,7 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
|
import { APP_GUARD } from '@nestjs/core';
|
||||||
import { EventEmitterModule } from '@nestjs/event-emitter';
|
import { EventEmitterModule } from '@nestjs/event-emitter';
|
||||||
|
import { ThrottlerGuard, ThrottlerModule } from '@nestjs/throttler';
|
||||||
import { PrismaModule } from './prisma.module.js';
|
import { PrismaModule } from './prisma.module.js';
|
||||||
import { UsersModule } from './users/users.module.js';
|
import { UsersModule } from './users/users.module.js';
|
||||||
import { TasksModule } from './tasks/tasks.module.js';
|
import { TasksModule } from './tasks/tasks.module.js';
|
||||||
@ -15,6 +17,16 @@ import { UploadsModule } from './uploads/uploads.module.js';
|
|||||||
imports: [
|
imports: [
|
||||||
PrismaModule,
|
PrismaModule,
|
||||||
EventEmitterModule.forRoot(),
|
EventEmitterModule.forRoot(),
|
||||||
|
ThrottlerModule.forRoot({
|
||||||
|
errorMessage: '操作过于频繁,请稍后再试',
|
||||||
|
throttlers: [
|
||||||
|
{
|
||||||
|
name: 'default',
|
||||||
|
ttl: 60_000,
|
||||||
|
limit: 120,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
UsersModule,
|
UsersModule,
|
||||||
TasksModule,
|
TasksModule,
|
||||||
PatientsModule,
|
PatientsModule,
|
||||||
@ -25,5 +37,11 @@ import { UploadsModule } from './uploads/uploads.module.js';
|
|||||||
DictionariesModule,
|
DictionariesModule,
|
||||||
UploadsModule,
|
UploadsModule,
|
||||||
],
|
],
|
||||||
|
providers: [
|
||||||
|
{
|
||||||
|
provide: APP_GUARD,
|
||||||
|
useClass: ThrottlerGuard,
|
||||||
|
},
|
||||||
|
],
|
||||||
})
|
})
|
||||||
export class AppModule {}
|
export class AppModule {}
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import { Body, Controller, Get, Post, UseGuards } from '@nestjs/common';
|
import { Body, Controller, Get, Post, UseGuards } from '@nestjs/common';
|
||||||
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||||
|
import { Throttle } from '@nestjs/throttler';
|
||||||
import { AuthService } from './auth.service.js';
|
import { AuthService } from './auth.service.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';
|
||||||
@ -22,6 +23,7 @@ export class AuthController {
|
|||||||
* 创建系统管理员(需引导密钥)。
|
* 创建系统管理员(需引导密钥)。
|
||||||
*/
|
*/
|
||||||
@Post('system-admin')
|
@Post('system-admin')
|
||||||
|
@Throttle({ default: { limit: 3, ttl: 60_000 } })
|
||||||
@ApiOperation({ summary: '创建系统管理员' })
|
@ApiOperation({ summary: '创建系统管理员' })
|
||||||
createSystemAdmin(@Body() dto: CreateSystemAdminDto) {
|
createSystemAdmin(@Body() dto: CreateSystemAdminDto) {
|
||||||
return this.authService.createSystemAdmin(dto);
|
return this.authService.createSystemAdmin(dto);
|
||||||
@ -31,30 +33,35 @@ export class AuthController {
|
|||||||
* 院内账号密码登录:后台与小程序均可复用。
|
* 院内账号密码登录:后台与小程序均可复用。
|
||||||
*/
|
*/
|
||||||
@Post('login')
|
@Post('login')
|
||||||
|
@Throttle({ default: { limit: 5, ttl: 60_000 } })
|
||||||
@ApiOperation({ summary: '院内账号密码登录' })
|
@ApiOperation({ summary: '院内账号密码登录' })
|
||||||
login(@Body() dto: LoginDto) {
|
login(@Body() dto: LoginDto) {
|
||||||
return this.authService.login(dto);
|
return this.authService.login(dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('login/confirm')
|
@Post('login/confirm')
|
||||||
|
@Throttle({ default: { limit: 10, ttl: 60_000 } })
|
||||||
@ApiOperation({ summary: '院内账号密码多账号确认登录' })
|
@ApiOperation({ summary: '院内账号密码多账号确认登录' })
|
||||||
confirmLogin(@Body() dto: PasswordLoginConfirmDto) {
|
confirmLogin(@Body() dto: PasswordLoginConfirmDto) {
|
||||||
return this.authService.confirmLogin(dto);
|
return this.authService.confirmLogin(dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('miniapp/b/phone-login')
|
@Post('miniapp/b/phone-login')
|
||||||
|
@Throttle({ default: { limit: 5, ttl: 60_000 } })
|
||||||
@ApiOperation({ summary: 'B 端小程序手机号登录' })
|
@ApiOperation({ summary: 'B 端小程序手机号登录' })
|
||||||
miniAppBLogin(@Body() dto: MiniappPhoneLoginDto) {
|
miniAppBLogin(@Body() dto: MiniappPhoneLoginDto) {
|
||||||
return this.authService.miniAppBLogin(dto);
|
return this.authService.miniAppBLogin(dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('miniapp/b/phone-login/confirm')
|
@Post('miniapp/b/phone-login/confirm')
|
||||||
|
@Throttle({ default: { limit: 10, ttl: 60_000 } })
|
||||||
@ApiOperation({ summary: 'B 端小程序多账号确认登录' })
|
@ApiOperation({ summary: 'B 端小程序多账号确认登录' })
|
||||||
miniAppBConfirmLogin(@Body() dto: MiniappPhoneLoginConfirmDto) {
|
miniAppBConfirmLogin(@Body() dto: MiniappPhoneLoginConfirmDto) {
|
||||||
return this.authService.miniAppBConfirmLogin(dto);
|
return this.authService.miniAppBConfirmLogin(dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('miniapp/c/phone-login')
|
@Post('miniapp/c/phone-login')
|
||||||
|
@Throttle({ default: { limit: 5, ttl: 60_000 } })
|
||||||
@ApiOperation({ summary: 'C 端小程序手机号登录' })
|
@ApiOperation({ summary: 'C 端小程序手机号登录' })
|
||||||
miniAppCLogin(@Body() dto: MiniappPhoneLoginDto) {
|
miniAppCLogin(@Body() dto: MiniappPhoneLoginDto) {
|
||||||
return this.authService.miniAppCLogin(dto);
|
return this.authService.miniAppCLogin(dto);
|
||||||
|
|||||||
@ -72,7 +72,12 @@ export class WechatMiniAppService {
|
|||||||
(await response.json().catch(() => null)) as WechatCode2SessionResponse | null;
|
(await response.json().catch(() => null)) as WechatCode2SessionResponse | null;
|
||||||
|
|
||||||
if (!response.ok || !payload?.openid || payload.errcode) {
|
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;
|
return payload.openid;
|
||||||
@ -100,7 +105,12 @@ export class WechatMiniAppService {
|
|||||||
const phone = payload?.phone_info?.phoneNumber;
|
const phone = payload?.phone_info?.phoneNumber;
|
||||||
|
|
||||||
if (!response.ok || !phone || payload?.errcode) {
|
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;
|
return phone;
|
||||||
@ -128,7 +138,12 @@ export class WechatMiniAppService {
|
|||||||
(await response.json().catch(() => null)) as WechatAccessTokenResponse | null;
|
(await response.json().catch(() => null)) as WechatAccessTokenResponse | null;
|
||||||
|
|
||||||
if (!response.ok || !payload?.access_token || payload.errcode) {
|
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 = {
|
this.accessTokenCache = {
|
||||||
@ -173,4 +188,32 @@ export class WechatMiniAppService {
|
|||||||
|
|
||||||
return trimmed;
|
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(', ')})`;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -40,6 +40,7 @@ export const MESSAGES = {
|
|||||||
MINIAPP_OPEN_ID_BOUND_OTHER_FAMILY: '当前微信账号已绑定其他家属账号',
|
MINIAPP_OPEN_ID_BOUND_OTHER_FAMILY: '当前微信账号已绑定其他家属账号',
|
||||||
FAMILY_PHONE_NOT_LINKED_PATIENT: '当前手机号未关联患者档案',
|
FAMILY_PHONE_NOT_LINKED_PATIENT: '当前手机号未关联患者档案',
|
||||||
FAMILY_ACCOUNT_NOT_FOUND: '家属登录账号不存在,请重新登录',
|
FAMILY_ACCOUNT_NOT_FOUND: '家属登录账号不存在,请重新登录',
|
||||||
|
THROTTLED: '操作过于频繁,请稍后再试',
|
||||||
},
|
},
|
||||||
|
|
||||||
USER: {
|
USER: {
|
||||||
@ -108,6 +109,11 @@ export const MESSAGES = {
|
|||||||
IMPLANT_CATALOG_NOT_FOUND: '植入物型号不存在或不在当前医院可见范围内',
|
IMPLANT_CATALOG_NOT_FOUND: '植入物型号不存在或不在当前医院可见范围内',
|
||||||
SURGERY_UPDATE_NOT_SUPPORTED:
|
SURGERY_UPDATE_NOT_SUPPORTED:
|
||||||
'患者更新接口不支持直接修改手术,请使用新增手术接口',
|
'患者更新接口不支持直接修改手术,请使用新增手术接口',
|
||||||
|
SURGERY_DEVICE_SET_UPDATE_NOT_SUPPORTED:
|
||||||
|
'编辑手术暂不支持新增或删除植入设备,请逐项修改现有设备信息',
|
||||||
|
SURGERY_ABANDON_UPDATE_NOT_SUPPORTED:
|
||||||
|
'编辑手术暂不支持修改弃用旧设备,请通过追加手术处理',
|
||||||
|
SURGERY_DEVICE_TASK_CONFLICT: '存在调压任务记录的植入设备不支持修改型号',
|
||||||
ABANDON_DEVICE_SCOPE_FORBIDDEN: '仅可弃用当前患者名下设备',
|
ABANDON_DEVICE_SCOPE_FORBIDDEN: '仅可弃用当前患者名下设备',
|
||||||
},
|
},
|
||||||
|
|
||||||
@ -144,6 +150,7 @@ export const MESSAGES = {
|
|||||||
'系统管理员上传文件时必须显式指定 hospitalId',
|
'系统管理员上传文件时必须显式指定 hospitalId',
|
||||||
INVALID_IMAGE_FILE: '上传的图片无法解析或压缩失败',
|
INVALID_IMAGE_FILE: '上传的图片无法解析或压缩失败',
|
||||||
INVALID_VIDEO_FILE: '上传的视频无法解析或压缩失败',
|
INVALID_VIDEO_FILE: '上传的视频无法解析或压缩失败',
|
||||||
|
INVALID_FILE_SIGNATURE: '上传文件内容与声明类型不匹配',
|
||||||
FFMPEG_NOT_AVAILABLE: '服务端缺少视频压缩能力',
|
FFMPEG_NOT_AVAILABLE: '服务端缺少视频压缩能力',
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
124
src/main.ts
124
src/main.ts
@ -1,24 +1,60 @@
|
|||||||
import 'dotenv/config';
|
import 'dotenv/config';
|
||||||
import { mkdirSync } from 'node:fs';
|
import { mkdirSync } from 'node:fs';
|
||||||
|
import { basename, extname } from 'node:path';
|
||||||
import { BadRequestException, ValidationPipe } from '@nestjs/common';
|
import { BadRequestException, ValidationPipe } from '@nestjs/common';
|
||||||
import { NestFactory } from '@nestjs/core';
|
import { NestFactory } from '@nestjs/core';
|
||||||
import { NestExpressApplication } from '@nestjs/platform-express';
|
import { NestExpressApplication } from '@nestjs/platform-express';
|
||||||
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
|
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
|
||||||
|
import helmet from 'helmet';
|
||||||
import { AppModule } from './app.module.js';
|
import { AppModule } from './app.module.js';
|
||||||
import { HttpExceptionFilter } from './common/http-exception.filter.js';
|
import { HttpExceptionFilter } from './common/http-exception.filter.js';
|
||||||
import { MESSAGES } from './common/messages.js';
|
import { MESSAGES } from './common/messages.js';
|
||||||
import { ResponseEnvelopeInterceptor } from './common/response-envelope.interceptor.js';
|
import { ResponseEnvelopeInterceptor } from './common/response-envelope.interceptor.js';
|
||||||
import { resolveUploadRootDir } from './uploads/upload-path.util.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() {
|
async function bootstrap() {
|
||||||
// 创建应用实例并加载核心模块。
|
|
||||||
const app = await NestFactory.create<NestExpressApplication>(AppModule);
|
const app = await NestFactory.create<NestExpressApplication>(AppModule);
|
||||||
app.enableCors();
|
|
||||||
|
app.use(
|
||||||
|
helmet({
|
||||||
|
contentSecurityPolicy: false,
|
||||||
|
crossOriginResourcePolicy: false,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
app.enableCors(buildCorsOptions());
|
||||||
|
|
||||||
mkdirSync(resolveUploadRootDir(), { recursive: true });
|
mkdirSync(resolveUploadRootDir(), { recursive: true });
|
||||||
app.useStaticAssets(resolveUploadRootDir(), {
|
app.useStaticAssets(resolveUploadRootDir(), {
|
||||||
prefix: '/uploads/',
|
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(
|
app.useGlobalPipes(
|
||||||
new ValidationPipe({
|
new ValidationPipe({
|
||||||
whitelist: true,
|
whitelist: true,
|
||||||
@ -30,40 +66,98 @@ async function bootstrap() {
|
|||||||
.filter((item): item is string => Boolean(item));
|
.filter((item): item is string => Boolean(item));
|
||||||
return new BadRequestException(
|
return new BadRequestException(
|
||||||
messages.length > 0
|
messages.length > 0
|
||||||
? messages.join(';')
|
? messages.join('; ')
|
||||||
: MESSAGES.DEFAULT_BAD_REQUEST,
|
: MESSAGES.DEFAULT_BAD_REQUEST,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
// 全局异常与成功响应统一格式化。
|
|
||||||
app.useGlobalFilters(new HttpExceptionFilter());
|
app.useGlobalFilters(new HttpExceptionFilter());
|
||||||
app.useGlobalInterceptors(new ResponseEnvelopeInterceptor());
|
app.useGlobalInterceptors(new ResponseEnvelopeInterceptor());
|
||||||
|
|
||||||
// Swagger 文档:提供在线调试与 OpenAPI JSON 导出。
|
if (shouldEnableSwagger()) {
|
||||||
const swaggerConfig = new DocumentBuilder()
|
const swaggerBuilder = new DocumentBuilder()
|
||||||
.setTitle('TYT 多租户医疗调压系统 API')
|
.setTitle('调压通医疗平台接口文档')
|
||||||
.setDescription('后端接口文档(含认证、RBAC、任务流转与患者聚合)')
|
.setDescription('包含认证、权限、患者、手术、任务和上传等后端接口。')
|
||||||
.setVersion('1.0.0')
|
.setVersion('1.0.0')
|
||||||
.addServer('http://192.168.0.140:3000', 'localhost')
|
|
||||||
.addBearerAuth(
|
.addBearerAuth(
|
||||||
{
|
{
|
||||||
type: 'http',
|
type: 'http',
|
||||||
scheme: 'bearer',
|
scheme: 'bearer',
|
||||||
bearerFormat: 'JWT',
|
bearerFormat: 'JWT',
|
||||||
description: '在此输入登录接口返回的 accessToken',
|
description: '请填写登录接口返回的访问令牌。',
|
||||||
},
|
},
|
||||||
'bearer',
|
'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, {
|
SwaggerModule.setup('api/docs', app, swaggerDocument, {
|
||||||
jsonDocumentUrl: 'api/docs-json',
|
jsonDocumentUrl: 'api/docs-json',
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// 启动 HTTP 服务。
|
|
||||||
await app.listen(process.env.PORT ?? 3000);
|
await app.listen(process.env.PORT ?? 3000);
|
||||||
}
|
}
|
||||||
|
|
||||||
bootstrap();
|
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'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@ -17,20 +17,19 @@ import {
|
|||||||
ApiQuery,
|
ApiQuery,
|
||||||
ApiTags,
|
ApiTags,
|
||||||
} from '@nestjs/swagger';
|
} 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 { 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 { 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 { Role } from '../../generated/prisma/enums.js';
|
||||||
import { BPatientsService } from './b-patients.service.js';
|
|
||||||
import { CreatePatientDto } from '../dto/create-patient.dto.js';
|
import { CreatePatientDto } from '../dto/create-patient.dto.js';
|
||||||
import { CreatePatientSurgeryDto } from '../dto/create-patient-surgery.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 { UpdatePatientDto } from '../dto/update-patient.dto.js';
|
||||||
|
import { BPatientsService } from './b-patients.service.js';
|
||||||
|
|
||||||
/**
|
|
||||||
* B 端患者控制器:院内可见性隔离查询。
|
|
||||||
*/
|
|
||||||
@ApiTags('患者管理(B端)')
|
@ApiTags('患者管理(B端)')
|
||||||
@ApiBearerAuth('bearer')
|
@ApiBearerAuth('bearer')
|
||||||
@Controller('b/patients')
|
@Controller('b/patients')
|
||||||
@ -38,9 +37,6 @@ import { UpdatePatientDto } from '../dto/update-patient.dto.js';
|
|||||||
export class BPatientsController {
|
export class BPatientsController {
|
||||||
constructor(private readonly patientsService: BPatientsService) {}
|
constructor(private readonly patientsService: BPatientsService) {}
|
||||||
|
|
||||||
/**
|
|
||||||
* 查询当前角色可选择的归属人员列表(医生/主任/组长)。
|
|
||||||
*/
|
|
||||||
@Get('doctors')
|
@Get('doctors')
|
||||||
@Roles(
|
@Roles(
|
||||||
Role.SYSTEM_ADMIN,
|
Role.SYSTEM_ADMIN,
|
||||||
@ -49,11 +45,11 @@ export class BPatientsController {
|
|||||||
Role.LEADER,
|
Role.LEADER,
|
||||||
Role.DOCTOR,
|
Role.DOCTOR,
|
||||||
)
|
)
|
||||||
@ApiOperation({ summary: '查询当前角色可见归属人员列表' })
|
@ApiOperation({ summary: '查询当前登录人可见的医生列表' })
|
||||||
@ApiQuery({
|
@ApiQuery({
|
||||||
name: 'hospitalId',
|
name: 'hospitalId',
|
||||||
required: false,
|
required: false,
|
||||||
description: '系统管理员可显式指定医院',
|
description: '医院编号,仅系统管理员可选传',
|
||||||
})
|
})
|
||||||
findVisibleDoctors(
|
findVisibleDoctors(
|
||||||
@CurrentActor() actor: ActorContext,
|
@CurrentActor() actor: ActorContext,
|
||||||
@ -64,9 +60,6 @@ export class BPatientsController {
|
|||||||
return this.patientsService.findVisibleDoctors(actor, requestedHospitalId);
|
return this.patientsService.findVisibleDoctors(actor, requestedHospitalId);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 按角色返回可见患者列表。
|
|
||||||
*/
|
|
||||||
@Get()
|
@Get()
|
||||||
@Roles(
|
@Roles(
|
||||||
Role.SYSTEM_ADMIN,
|
Role.SYSTEM_ADMIN,
|
||||||
@ -75,25 +68,14 @@ export class BPatientsController {
|
|||||||
Role.LEADER,
|
Role.LEADER,
|
||||||
Role.DOCTOR,
|
Role.DOCTOR,
|
||||||
)
|
)
|
||||||
@ApiOperation({ summary: '按角色查询可见患者列表' })
|
@ApiOperation({ summary: '按服务端分页查询当前登录人可见的患者列表' })
|
||||||
@ApiQuery({
|
|
||||||
name: 'hospitalId',
|
|
||||||
required: false,
|
|
||||||
description: '系统管理员可显式指定医院',
|
|
||||||
})
|
|
||||||
findVisiblePatients(
|
findVisiblePatients(
|
||||||
@CurrentActor() actor: ActorContext,
|
@CurrentActor() actor: ActorContext,
|
||||||
@Query('hospitalId') hospitalId?: string,
|
@Query() query: PatientQueryDto,
|
||||||
) {
|
) {
|
||||||
const requestedHospitalId =
|
return this.patientsService.findVisiblePatients(actor, query);
|
||||||
hospitalId == null || hospitalId === '' ? undefined : Number(hospitalId);
|
|
||||||
|
|
||||||
return this.patientsService.findVisiblePatients(actor, requestedHospitalId);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 创建患者。
|
|
||||||
*/
|
|
||||||
@Post()
|
@Post()
|
||||||
@Roles(
|
@Roles(
|
||||||
Role.SYSTEM_ADMIN,
|
Role.SYSTEM_ADMIN,
|
||||||
@ -102,7 +84,7 @@ export class BPatientsController {
|
|||||||
Role.LEADER,
|
Role.LEADER,
|
||||||
Role.DOCTOR,
|
Role.DOCTOR,
|
||||||
)
|
)
|
||||||
@ApiOperation({ summary: '创建患者' })
|
@ApiOperation({ summary: '创建患者档案' })
|
||||||
createPatient(
|
createPatient(
|
||||||
@CurrentActor() actor: ActorContext,
|
@CurrentActor() actor: ActorContext,
|
||||||
@Body() dto: CreatePatientDto,
|
@Body() dto: CreatePatientDto,
|
||||||
@ -110,9 +92,6 @@ export class BPatientsController {
|
|||||||
return this.patientsService.createPatient(actor, dto);
|
return this.patientsService.createPatient(actor, dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 为患者新增手术记录。
|
|
||||||
*/
|
|
||||||
@Post(':id/surgeries')
|
@Post(':id/surgeries')
|
||||||
@Roles(
|
@Roles(
|
||||||
Role.SYSTEM_ADMIN,
|
Role.SYSTEM_ADMIN,
|
||||||
@ -122,7 +101,7 @@ export class BPatientsController {
|
|||||||
Role.DOCTOR,
|
Role.DOCTOR,
|
||||||
)
|
)
|
||||||
@ApiOperation({ summary: '为患者新增手术记录' })
|
@ApiOperation({ summary: '为患者新增手术记录' })
|
||||||
@ApiParam({ name: 'id', description: '患者 ID' })
|
@ApiParam({ name: 'id', description: '患者编号' })
|
||||||
createPatientSurgery(
|
createPatientSurgery(
|
||||||
@CurrentActor() actor: ActorContext,
|
@CurrentActor() actor: ActorContext,
|
||||||
@Param('id', ParseIntPipe) id: number,
|
@Param('id', ParseIntPipe) id: number,
|
||||||
@ -131,9 +110,43 @@ export class BPatientsController {
|
|||||||
return this.patientsService.createPatientSurgery(actor, id, dto);
|
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')
|
@Get(':id')
|
||||||
@Roles(
|
@Roles(
|
||||||
Role.SYSTEM_ADMIN,
|
Role.SYSTEM_ADMIN,
|
||||||
@ -143,7 +156,7 @@ export class BPatientsController {
|
|||||||
Role.DOCTOR,
|
Role.DOCTOR,
|
||||||
)
|
)
|
||||||
@ApiOperation({ summary: '查询患者详情' })
|
@ApiOperation({ summary: '查询患者详情' })
|
||||||
@ApiParam({ name: 'id', description: '患者 ID' })
|
@ApiParam({ name: 'id', description: '患者编号' })
|
||||||
findPatientById(
|
findPatientById(
|
||||||
@CurrentActor() actor: ActorContext,
|
@CurrentActor() actor: ActorContext,
|
||||||
@Param('id', ParseIntPipe) id: number,
|
@Param('id', ParseIntPipe) id: number,
|
||||||
@ -151,9 +164,6 @@ export class BPatientsController {
|
|||||||
return this.patientsService.findPatientById(actor, id);
|
return this.patientsService.findPatientById(actor, id);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 更新患者信息。
|
|
||||||
*/
|
|
||||||
@Patch(':id')
|
@Patch(':id')
|
||||||
@Roles(
|
@Roles(
|
||||||
Role.SYSTEM_ADMIN,
|
Role.SYSTEM_ADMIN,
|
||||||
@ -162,8 +172,8 @@ export class BPatientsController {
|
|||||||
Role.LEADER,
|
Role.LEADER,
|
||||||
Role.DOCTOR,
|
Role.DOCTOR,
|
||||||
)
|
)
|
||||||
@ApiOperation({ summary: '更新患者信息' })
|
@ApiOperation({ summary: '更新患者档案' })
|
||||||
@ApiParam({ name: 'id', description: '患者 ID' })
|
@ApiParam({ name: 'id', description: '患者编号' })
|
||||||
updatePatient(
|
updatePatient(
|
||||||
@CurrentActor() actor: ActorContext,
|
@CurrentActor() actor: ActorContext,
|
||||||
@Param('id', ParseIntPipe) id: number,
|
@Param('id', ParseIntPipe) id: number,
|
||||||
@ -172,9 +182,6 @@ export class BPatientsController {
|
|||||||
return this.patientsService.updatePatient(actor, id, dto);
|
return this.patientsService.updatePatient(actor, id, dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 删除患者。
|
|
||||||
*/
|
|
||||||
@Delete(':id')
|
@Delete(':id')
|
||||||
@Roles(
|
@Roles(
|
||||||
Role.SYSTEM_ADMIN,
|
Role.SYSTEM_ADMIN,
|
||||||
@ -183,8 +190,8 @@ export class BPatientsController {
|
|||||||
Role.LEADER,
|
Role.LEADER,
|
||||||
Role.DOCTOR,
|
Role.DOCTOR,
|
||||||
)
|
)
|
||||||
@ApiOperation({ summary: '删除患者' })
|
@ApiOperation({ summary: '删除患者档案' })
|
||||||
@ApiParam({ name: 'id', description: '患者 ID' })
|
@ApiParam({ name: 'id', description: '患者编号' })
|
||||||
removePatient(
|
removePatient(
|
||||||
@CurrentActor() actor: ActorContext,
|
@CurrentActor() actor: ActorContext,
|
||||||
@Param('id', ParseIntPipe) id: number,
|
@Param('id', ParseIntPipe) id: number,
|
||||||
|
|||||||
@ -13,7 +13,13 @@ import { MESSAGES } from '../../common/messages.js';
|
|||||||
import { normalizePressureLabel } from '../../common/pressure-level.util.js';
|
import { normalizePressureLabel } from '../../common/pressure-level.util.js';
|
||||||
import { CreatePatientDto } from '../dto/create-patient.dto.js';
|
import { CreatePatientDto } from '../dto/create-patient.dto.js';
|
||||||
import { CreatePatientSurgeryDto } from '../dto/create-patient-surgery.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 { 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';
|
import { normalizePatientIdCard } from '../patient-id-card.util.js';
|
||||||
|
|
||||||
type PrismaExecutor = Prisma.TransactionClient | PrismaService;
|
type PrismaExecutor = Prisma.TransactionClient | PrismaService;
|
||||||
@ -147,46 +153,32 @@ export class BPatientsService {
|
|||||||
/**
|
/**
|
||||||
* 查询当前角色可见患者列表。
|
* 查询当前角色可见患者列表。
|
||||||
*/
|
*/
|
||||||
async findVisiblePatients(actor: ActorContext, requestedHospitalId?: number) {
|
async findVisiblePatients(actor: ActorContext, query: PatientQueryDto = {}) {
|
||||||
const hospitalId = this.resolveHospitalId(actor, requestedHospitalId);
|
const hospitalId = this.resolveHospitalId(actor, query.hospitalId);
|
||||||
const where = this.buildVisiblePatientWhere(actor, 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,
|
where,
|
||||||
include: PATIENT_LIST_INCLUDE,
|
include: PATIENT_LIST_INCLUDE,
|
||||||
orderBy: { id: 'desc' },
|
orderBy: { id: 'desc' },
|
||||||
});
|
skip: (page - 1) * pageSize,
|
||||||
|
take: pageSize,
|
||||||
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;
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...rest,
|
total,
|
||||||
primaryDisease: latestSurgery?.primaryDisease ?? null,
|
page,
|
||||||
hydrocephalusTypes: latestSurgery?.hydrocephalusTypes ?? [],
|
pageSize,
|
||||||
surgeryDate: latestSurgery?.surgeryDate ?? null,
|
list: patients.map((patient) => this.decoratePatientListItem(patient)),
|
||||||
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,
|
|
||||||
};
|
};
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -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);
|
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;
|
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) {
|
private buildVisiblePatientWhere(
|
||||||
const where: Record<string, unknown> = { hospitalId };
|
actor: ActorContext,
|
||||||
|
hospitalId: number,
|
||||||
|
query: PatientQueryDto = {},
|
||||||
|
) {
|
||||||
|
const where: Prisma.PatientWhereInput = { hospitalId };
|
||||||
switch (actor.role) {
|
switch (actor.role) {
|
||||||
case Role.DOCTOR:
|
case Role.DOCTOR:
|
||||||
where.doctorId = actor.id;
|
where.doctorId = actor.id;
|
||||||
@ -559,6 +653,71 @@ export class BPatientsService {
|
|||||||
default:
|
default:
|
||||||
throw new ForbiddenException(MESSAGES.PATIENT.ROLE_FORBIDDEN);
|
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;
|
return where;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -773,6 +932,204 @@ export class BPatientsService {
|
|||||||
return surgery;
|
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;
|
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(
|
private decoratePatientDetail(
|
||||||
patient: Awaited<ReturnType<BPatientsService['findPatientWithScope']>>,
|
patient: Awaited<ReturnType<BPatientsService['findPatientWithScope']>>,
|
||||||
) {
|
) {
|
||||||
|
|||||||
@ -1,20 +1,15 @@
|
|||||||
import {
|
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||||
Injectable,
|
|
||||||
NotFoundException,
|
|
||||||
} from '@nestjs/common';
|
|
||||||
import { PrismaService } from '../../prisma.service.js';
|
|
||||||
import { MESSAGES } from '../../common/messages.js';
|
import { MESSAGES } from '../../common/messages.js';
|
||||||
|
import { PrismaService } from '../../prisma.service.js';
|
||||||
|
import {
|
||||||
|
buildPatientLifecycleRecords,
|
||||||
|
PATIENT_LIFECYCLE_INCLUDE,
|
||||||
|
} from '../patient-lifecycle.util.js';
|
||||||
|
|
||||||
/**
|
|
||||||
* C 端患者服务:承载家属跨院生命周期聚合查询。
|
|
||||||
*/
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class CPatientsService {
|
export class CPatientsService {
|
||||||
constructor(private readonly prisma: PrismaService) {}
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
/**
|
|
||||||
* C 端查询:按当前家属账号绑定手机号跨院聚合患者生命周期记录。
|
|
||||||
*/
|
|
||||||
async getFamilyLifecycleByAccount(accountId: number) {
|
async getFamilyLifecycleByAccount(accountId: number) {
|
||||||
const account = await this.prisma.familyMiniAppAccount.findUnique({
|
const account = await this.prisma.familyMiniAppAccount.findUnique({
|
||||||
where: { id: accountId },
|
where: { id: accountId },
|
||||||
@ -31,180 +26,16 @@ export class CPatientsService {
|
|||||||
where: {
|
where: {
|
||||||
phone: account.phone,
|
phone: account.phone,
|
||||||
},
|
},
|
||||||
include: {
|
include: PATIENT_LIFECYCLE_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,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
if (patients.length === 0) {
|
if (patients.length === 0) {
|
||||||
throw new NotFoundException(MESSAGES.PATIENT.LIFE_CYCLE_NOT_FOUND);
|
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 {
|
return {
|
||||||
// 前端详情弹窗和现有 E2E 都依赖这两个回显字段。
|
|
||||||
phone: account.phone,
|
phone: account.phone,
|
||||||
patientCount: patients.length,
|
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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -95,7 +95,7 @@ export class CreatePatientSurgeryDto {
|
|||||||
devices!: CreateSurgeryDeviceDto[];
|
devices!: CreateSurgeryDeviceDto[];
|
||||||
|
|
||||||
@ApiPropertyOptional({
|
@ApiPropertyOptional({
|
||||||
description: '本次手术后需弃用的历史设备 ID 列表',
|
description: '本次手术后需弃用的历史设备编号列表',
|
||||||
type: [Number],
|
type: [Number],
|
||||||
example: [1],
|
example: [1],
|
||||||
})
|
})
|
||||||
|
|||||||
@ -40,7 +40,7 @@ export class CreatePatientDto {
|
|||||||
@IsString({ message: 'idCard 必须是字符串' })
|
@IsString({ message: 'idCard 必须是字符串' })
|
||||||
idCard!: string;
|
idCard!: string;
|
||||||
|
|
||||||
@ApiProperty({ description: '归属人员 ID(医生/主任/组长)', example: 10001 })
|
@ApiProperty({ description: '归属人员编号(医生/主任/组长)', example: 10001 })
|
||||||
@Type(() => Number)
|
@Type(() => Number)
|
||||||
@IsInt({ message: 'doctorId 必须是整数' })
|
@IsInt({ message: 'doctorId 必须是整数' })
|
||||||
@Min(1, { message: 'doctorId 必须大于 0' })
|
@Min(1, { message: 'doctorId 必须大于 0' })
|
||||||
|
|||||||
@ -15,7 +15,7 @@ import {
|
|||||||
*/
|
*/
|
||||||
export class CreateSurgeryDeviceDto {
|
export class CreateSurgeryDeviceDto {
|
||||||
@ApiProperty({
|
@ApiProperty({
|
||||||
description: '植入物型号 ID,选中后自动回填厂家与名称',
|
description: '植入物型号编号,选中后自动回填厂家与名称',
|
||||||
example: 1,
|
example: 1,
|
||||||
})
|
})
|
||||||
@Type(() => Number)
|
@Type(() => Number)
|
||||||
|
|||||||
51
src/patients/dto/patient-query.dto.ts
Normal file
51
src/patients/dto/patient-query.dto.ts
Normal 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;
|
||||||
|
}
|
||||||
17
src/patients/dto/update-patient-surgery.dto.ts
Normal file
17
src/patients/dto/update-patient-surgery.dto.ts
Normal 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[];
|
||||||
|
}
|
||||||
16
src/patients/dto/update-surgery-device.dto.ts
Normal file
16
src/patients/dto/update-surgery-device.dto.ts
Normal 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;
|
||||||
|
}
|
||||||
164
src/patients/patient-lifecycle.util.ts
Normal file
164
src/patients/patient-lifecycle.util.ts
Normal 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;
|
||||||
|
}
|
||||||
@ -36,6 +36,19 @@ import { extname } from 'node:path';
|
|||||||
import { randomUUID } from 'node:crypto';
|
import { randomUUID } from 'node:crypto';
|
||||||
|
|
||||||
const MAX_UPLOAD_SIZE = 1024 * 1024 * 200;
|
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) {
|
function isAllowedMimeType(mimeType: string) {
|
||||||
return (
|
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端)')
|
@ApiTags('上传资产(B端)')
|
||||||
@ApiBearerAuth('bearer')
|
@ApiBearerAuth('bearer')
|
||||||
@Controller('b/uploads')
|
@Controller('b/uploads')
|
||||||
@ -83,7 +112,10 @@ export class BUploadsController {
|
|||||||
}),
|
}),
|
||||||
limits: { fileSize: MAX_UPLOAD_SIZE },
|
limits: { fileSize: MAX_UPLOAD_SIZE },
|
||||||
fileFilter: (_req, file, cb) => {
|
fileFilter: (_req, file, cb) => {
|
||||||
if (!isAllowedMimeType(file.mimetype)) {
|
if (
|
||||||
|
!isAllowedMimeType(file.mimetype) ||
|
||||||
|
!isAllowedOriginalExtension(file.originalname, file.mimetype)
|
||||||
|
) {
|
||||||
cb(
|
cb(
|
||||||
new BadRequestException(MESSAGES.UPLOAD.UNSUPPORTED_FILE_TYPE),
|
new BadRequestException(MESSAGES.UPLOAD.UNSUPPORTED_FILE_TYPE),
|
||||||
false,
|
false,
|
||||||
|
|||||||
@ -5,7 +5,7 @@ import {
|
|||||||
NotFoundException,
|
NotFoundException,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { spawn } from 'node:child_process';
|
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 { extname, join } from 'node:path';
|
||||||
import { randomUUID } from 'node:crypto';
|
import { randomUUID } from 'node:crypto';
|
||||||
import ffmpegPath from 'ffmpeg-static';
|
import ffmpegPath from 'ffmpeg-static';
|
||||||
@ -22,6 +22,20 @@ import {
|
|||||||
resolveUploadTempDir,
|
resolveUploadTempDir,
|
||||||
} from './upload-path.util.js';
|
} 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()
|
@Injectable()
|
||||||
export class UploadsService {
|
export class UploadsService {
|
||||||
constructor(private readonly prisma: PrismaService) {}
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
@ -237,12 +251,7 @@ export class UploadsService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return this.prepareGenericFile(file);
|
||||||
tempPath: file.path,
|
|
||||||
outputExtension: extname(file.originalname),
|
|
||||||
mimeType: file.mimetype,
|
|
||||||
fileSize: file.size,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async buildFinalFileName(
|
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) {
|
private async compressVideo(inputPath: string, outputPath: string) {
|
||||||
const command = ffmpegPath as unknown as string | null;
|
const command = ffmpegPath as unknown as string | null;
|
||||||
if (!command) {
|
if (!command) {
|
||||||
|
|||||||
@ -24,10 +24,14 @@ export const createPatientSurgery = (id, data) => {
|
|||||||
return request.post(`/b/patients/${id}/surgeries`, 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) => {
|
export const deletePatient = (id) => {
|
||||||
return request.delete(`/b/patients/${id}`);
|
return request.delete(`/b/patients/${id}`);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getPatientLifecycle = (params) => {
|
export const getPatientLifecycle = (id) => {
|
||||||
return request.get('/c/patients/lifecycle', { params });
|
return request.get(`/b/patients/${id}/lifecycle`);
|
||||||
};
|
};
|
||||||
|
|||||||
16
tyt-admin/src/constants/role-labels.js
Normal file
16
tyt-admin/src/constants/role-labels.js
Normal 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;
|
||||||
|
}
|
||||||
@ -84,12 +84,14 @@
|
|||||||
<el-container>
|
<el-container>
|
||||||
<el-header class="header">
|
<el-header class="header">
|
||||||
<div class="header-left">
|
<div class="header-left">
|
||||||
<!-- Breadcrumbs can go here -->
|
<!-- 这里预留面包屑区域 -->
|
||||||
</div>
|
</div>
|
||||||
<div class="header-right">
|
<div class="header-right">
|
||||||
<el-dropdown @command="handleCommand">
|
<el-dropdown @command="handleCommand">
|
||||||
<span class="el-dropdown-link user-info">
|
<span class="el-dropdown-link user-info">
|
||||||
{{ userStore.userInfo?.name || '用户' }} ({{ userStore.role }})
|
{{ userStore.userInfo?.name || '用户' }}({{
|
||||||
|
getRoleLabel(userStore.role) || '未分配角色'
|
||||||
|
}})
|
||||||
<el-icon class="el-icon--right">
|
<el-icon class="el-icon--right">
|
||||||
<arrow-down />
|
<arrow-down />
|
||||||
</el-icon>
|
</el-icon>
|
||||||
@ -117,6 +119,7 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { computed } from 'vue';
|
import { computed } from 'vue';
|
||||||
import { useRoute, useRouter } from 'vue-router';
|
import { useRoute, useRouter } from 'vue-router';
|
||||||
|
import { getRoleLabel } from '../constants/role-labels';
|
||||||
import { useUserStore } from '../store/user';
|
import { useUserStore } from '../store/user';
|
||||||
import {
|
import {
|
||||||
ROLE_PERMISSIONS,
|
ROLE_PERMISSIONS,
|
||||||
|
|||||||
@ -2,7 +2,7 @@
|
|||||||
<div class="dashboard">
|
<div class="dashboard">
|
||||||
<el-card shadow="never" class="welcome-card">
|
<el-card shadow="never" class="welcome-card">
|
||||||
<h2>欢迎使用调压通管理后台</h2>
|
<h2>欢迎使用调压通管理后台</h2>
|
||||||
<p>当前角色:{{ userStore.role || '未登录' }}</p>
|
<p>当前角色:{{ getRoleLabel(userStore.role) || '未登录' }}</p>
|
||||||
</el-card>
|
</el-card>
|
||||||
|
|
||||||
<el-card v-if="isSystemAdmin" shadow="never" class="filter-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 { getHospitals, getDepartments, getGroups } from '../api/organization';
|
||||||
import { getUsers } from '../api/users';
|
import { getUsers } from '../api/users';
|
||||||
import { getPatients } from '../api/patients';
|
import { getPatients } from '../api/patients';
|
||||||
|
import { getRoleLabel } from '../constants/role-labels';
|
||||||
|
|
||||||
const userStore = useUserStore();
|
const userStore = useUserStore();
|
||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
@ -158,10 +159,17 @@ const fetchDashboardData = async () => {
|
|||||||
if (isSystemAdmin.value && !selectedHospitalId.value) {
|
if (isSystemAdmin.value && !selectedHospitalId.value) {
|
||||||
stats.value.patients = 0;
|
stats.value.patients = 0;
|
||||||
} else {
|
} else {
|
||||||
const patientRes = await getPatients(params);
|
const patientRes = await getPatients({
|
||||||
stats.value.patients = Array.isArray(patientRes)
|
page: 1,
|
||||||
|
pageSize: 1,
|
||||||
|
...params,
|
||||||
|
});
|
||||||
|
stats.value.patients = Number(
|
||||||
|
patientRes?.total ??
|
||||||
|
(Array.isArray(patientRes)
|
||||||
? patientRes.length
|
? patientRes.length
|
||||||
: 0;
|
: patientRes?.list?.length ?? 0),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@ -86,6 +86,7 @@ import { useRouter, useRoute } from 'vue-router';
|
|||||||
import { useUserStore } from '../store/user';
|
import { useUserStore } from '../store/user';
|
||||||
import { User, Lock } from '@element-plus/icons-vue';
|
import { User, Lock } from '@element-plus/icons-vue';
|
||||||
import { ElMessage } from 'element-plus';
|
import { ElMessage } from 'element-plus';
|
||||||
|
import { getRoleLabel } from '../constants/role-labels';
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
@ -116,7 +117,7 @@ const rules = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const formatAccountMeta = (account) => {
|
const formatAccountMeta = (account) => {
|
||||||
const parts = [account.role];
|
const parts = [getRoleLabel(account.role)];
|
||||||
if (account.hospitalName) {
|
if (account.hospitalName) {
|
||||||
parts.push(account.hospitalName);
|
parts.push(account.hospitalName);
|
||||||
}
|
}
|
||||||
@ -157,7 +158,7 @@ const handleLogin = async () => {
|
|||||||
ElMessage.error('登录失败,未获取到登录信息');
|
ElMessage.error('登录失败,未获取到登录信息');
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Login failed', error);
|
console.error('登录请求失败', error);
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false;
|
loading.value = false;
|
||||||
}
|
}
|
||||||
@ -185,7 +186,7 @@ const handleConfirmLogin = async () => {
|
|||||||
|
|
||||||
ElMessage.error('登录失败,未获取到登录信息');
|
ElMessage.error('登录失败,未获取到登录信息');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Confirm login failed', error);
|
console.error('确认登录失败', error);
|
||||||
} finally {
|
} finally {
|
||||||
confirmLoading.value = false;
|
confirmLoading.value = false;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -78,7 +78,11 @@
|
|||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column prop="phone" label="联系电话" min-width="140" />
|
<el-table-column prop="phone" label="联系电话" min-width="140" />
|
||||||
<el-table-column prop="idCard" label="身份证号" min-width="200" />
|
<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 }">
|
<template #default="{ row }">
|
||||||
{{ row.hospital?.name || '-' }}
|
{{ row.hospital?.name || '-' }}
|
||||||
</template>
|
</template>
|
||||||
@ -88,7 +92,7 @@
|
|||||||
{{ row.doctor?.name || '-' }}
|
{{ row.doctor?.name || '-' }}
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</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 }">
|
<template #default="{ row }">
|
||||||
<el-button
|
<el-button
|
||||||
v-if="canPublishAdjustTask"
|
v-if="canPublishAdjustTask"
|
||||||
@ -106,6 +110,17 @@
|
|||||||
>
|
>
|
||||||
详情
|
详情
|
||||||
</el-button>
|
</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
|
<el-button
|
||||||
size="small"
|
size="small"
|
||||||
type="success"
|
type="success"
|
||||||
@ -131,8 +146,8 @@
|
|||||||
:total="total"
|
:total="total"
|
||||||
background
|
background
|
||||||
layout="total, sizes, prev, pager, next, jumper"
|
layout="total, sizes, prev, pager, next, jumper"
|
||||||
@size-change="applyFiltersAndPagination"
|
@size-change="fetchData"
|
||||||
@current-change="applyFiltersAndPagination"
|
@current-change="fetchData"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</el-card>
|
</el-card>
|
||||||
@ -257,7 +272,7 @@
|
|||||||
</el-dialog>
|
</el-dialog>
|
||||||
|
|
||||||
<el-dialog
|
<el-dialog
|
||||||
title="追加手术"
|
:title="isSurgeryEditMode ? '编辑手术' : '追加手术'"
|
||||||
v-model="surgeryDialogVisible"
|
v-model="surgeryDialogVisible"
|
||||||
width="1120px"
|
width="1120px"
|
||||||
top="3vh"
|
top="3vh"
|
||||||
@ -272,15 +287,29 @@
|
|||||||
class="context-alert"
|
class="context-alert"
|
||||||
>
|
>
|
||||||
<template #title>
|
<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 }}」追加手术
|
为患者「{{ surgeryPatientContext.name }}」追加手术
|
||||||
<span v-if="surgeryPatientContext.latestSurgery">
|
<span v-if="surgeryPatientContext.latestSurgery">
|
||||||
,上次手术:
|
,上次手术:
|
||||||
{{ surgeryPatientContext.latestSurgery.surgeryName }}
|
{{ surgeryPatientContext.latestSurgery.surgeryName }}
|
||||||
({{
|
({{
|
||||||
formatDateTime(surgeryPatientContext.latestSurgery.surgeryDate)
|
formatDateTime(
|
||||||
|
surgeryPatientContext.latestSurgery.surgeryDate,
|
||||||
|
)
|
||||||
}})
|
}})
|
||||||
</span>
|
</span>
|
||||||
</template>
|
</template>
|
||||||
|
</template>
|
||||||
</el-alert>
|
</el-alert>
|
||||||
|
|
||||||
<SurgeryFormSection
|
<SurgeryFormSection
|
||||||
@ -291,9 +320,14 @@
|
|||||||
:dictionary-options="medicalDictionaryOptions"
|
:dictionary-options="medicalDictionaryOptions"
|
||||||
:upload-hospital-id="surgeryPatientContext.hospitalId"
|
:upload-hospital-id="surgeryPatientContext.hospitalId"
|
||||||
:abandonable-devices="surgeryPatientContext.activeDevices"
|
:abandonable-devices="surgeryPatientContext.activeDevices"
|
||||||
:show-abandon-selector="true"
|
:show-abandon-selector="!isSurgeryEditMode"
|
||||||
title="手术录入"
|
:allow-device-structure-edit="!isSurgeryEditMode"
|
||||||
description="若本次手术后需要停用旧设备,可在上方直接勾选弃用。"
|
:title="isSurgeryEditMode ? '编辑手术内容' : '手术录入'"
|
||||||
|
:description="
|
||||||
|
isSurgeryEditMode
|
||||||
|
? '可修改本次手术及已有植入设备信息;如需调整历史弃用设备,请通过追加手术处理。'
|
||||||
|
: '若本次手术后需要停用旧设备,可在上方直接勾选弃用。'
|
||||||
|
"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -305,7 +339,7 @@
|
|||||||
:loading="surgerySubmitLoading"
|
:loading="surgerySubmitLoading"
|
||||||
@click="handleSubmitSurgery"
|
@click="handleSubmitSurgery"
|
||||||
>
|
>
|
||||||
保存手术
|
{{ isSurgeryEditMode ? '保存修改' : '保存手术' }}
|
||||||
</el-button>
|
</el-button>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@ -421,7 +455,10 @@
|
|||||||
<el-descriptions-item label="创建人">
|
<el-descriptions-item label="创建人">
|
||||||
{{ detailPatient.creator?.name || '-' }}
|
{{ detailPatient.creator?.name || '-' }}
|
||||||
</el-descriptions-item>
|
</el-descriptions-item>
|
||||||
<el-descriptions-item v-if="canViewHospitalInfo" label="归属医院">
|
<el-descriptions-item
|
||||||
|
v-if="canViewHospitalInfo"
|
||||||
|
label="归属医院"
|
||||||
|
>
|
||||||
{{ detailPatient.hospital?.name || '-' }}
|
{{ detailPatient.hospital?.name || '-' }}
|
||||||
</el-descriptions-item>
|
</el-descriptions-item>
|
||||||
<el-descriptions-item label="建档时间">
|
<el-descriptions-item label="建档时间">
|
||||||
@ -460,6 +497,14 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="surgery-card-tags">
|
<div class="surgery-card-tags">
|
||||||
|
<el-button
|
||||||
|
size="small"
|
||||||
|
type="primary"
|
||||||
|
plain
|
||||||
|
@click="openSurgeryEditFromDetail(surgery)"
|
||||||
|
>
|
||||||
|
编辑手术
|
||||||
|
</el-button>
|
||||||
<el-tag type="primary">
|
<el-tag type="primary">
|
||||||
第 {{ surgery.shuntSurgeryCount || '-' }} 次分流手术
|
第 {{ surgery.shuntSurgeryCount || '-' }} 次分流手术
|
||||||
</el-tag>
|
</el-tag>
|
||||||
@ -716,7 +761,11 @@
|
|||||||
{{ row.surgery?.surgeryName || '-' }}
|
{{ row.surgery?.surgeryName || '-' }}
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</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 }">
|
<template #default="{ row }">
|
||||||
{{ row.hospital?.name || '-' }}
|
{{ row.hospital?.name || '-' }}
|
||||||
</template>
|
</template>
|
||||||
@ -757,6 +806,7 @@ import {
|
|||||||
getPatientLifecycle,
|
getPatientLifecycle,
|
||||||
getPatients,
|
getPatients,
|
||||||
updatePatient,
|
updatePatient,
|
||||||
|
updatePatientSurgery,
|
||||||
} from '../../api/patients';
|
} from '../../api/patients';
|
||||||
import { getImplantCatalogs } from '../../api/devices';
|
import { getImplantCatalogs } from '../../api/devices';
|
||||||
import { getDictionaries } from '../../api/dictionaries';
|
import { getDictionaries } from '../../api/dictionaries';
|
||||||
@ -805,7 +855,6 @@ const submitLoading = ref(false);
|
|||||||
const surgerySubmitLoading = ref(false);
|
const surgerySubmitLoading = ref(false);
|
||||||
const detailLoading = ref(false);
|
const detailLoading = ref(false);
|
||||||
|
|
||||||
const allPatients = ref([]);
|
|
||||||
const tableData = ref([]);
|
const tableData = ref([]);
|
||||||
const total = ref(0);
|
const total = ref(0);
|
||||||
const page = ref(1);
|
const page = ref(1);
|
||||||
@ -832,6 +881,9 @@ const currentEditId = ref(null);
|
|||||||
|
|
||||||
const surgeryDialogVisible = ref(false);
|
const surgeryDialogVisible = ref(false);
|
||||||
const surgeryPatientContext = ref(null);
|
const surgeryPatientContext = ref(null);
|
||||||
|
const surgeryDialogMode = ref('create');
|
||||||
|
const currentEditSurgeryId = ref(null);
|
||||||
|
const reopenDetailAfterSurgerySubmit = ref(false);
|
||||||
const adjustDialogVisible = ref(false);
|
const adjustDialogVisible = ref(false);
|
||||||
const adjustSubmitLoading = ref(false);
|
const adjustSubmitLoading = ref(false);
|
||||||
const currentAdjustPatient = ref(null);
|
const currentAdjustPatient = ref(null);
|
||||||
@ -867,6 +919,7 @@ const currentAdjustDevices = computed(() => {
|
|||||||
isAdjustableDeviceAvailable(device),
|
isAdjustableDeviceAvailable(device),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
const isSurgeryEditMode = computed(() => surgeryDialogMode.value === 'edit');
|
||||||
|
|
||||||
const currentAdjustDevice = computed(() => {
|
const currentAdjustDevice = computed(() => {
|
||||||
return (
|
return (
|
||||||
@ -939,8 +992,18 @@ function createSurgeryForm() {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function createDeviceFormItem() {
|
function createMaterialFormItem(overrides = {}) {
|
||||||
return {
|
return {
|
||||||
|
type: 'IMAGE',
|
||||||
|
name: '',
|
||||||
|
url: '',
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createDeviceFormItem(overrides = {}) {
|
||||||
|
return {
|
||||||
|
id: null,
|
||||||
implantCatalogId: null,
|
implantCatalogId: null,
|
||||||
shuntMode: '',
|
shuntMode: '',
|
||||||
proximalPunctureAreas: [],
|
proximalPunctureAreas: [],
|
||||||
@ -949,6 +1012,7 @@ function createDeviceFormItem() {
|
|||||||
initialPressure: '',
|
initialPressure: '',
|
||||||
implantNotes: '',
|
implantNotes: '',
|
||||||
labelImageUrl: '',
|
labelImageUrl: '',
|
||||||
|
...overrides,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1102,8 +1166,10 @@ function validateSurgeryForm(form) {
|
|||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildSurgeryPayload(form) {
|
function buildSurgeryPayload(form, options = {}) {
|
||||||
return {
|
const includeAbandonedDeviceIds = options.includeAbandonedDeviceIds !== false;
|
||||||
|
|
||||||
|
const payload = {
|
||||||
surgeryDate: new Date(form.surgeryDate).toISOString(),
|
surgeryDate: new Date(form.surgeryDate).toISOString(),
|
||||||
surgeryName: String(form.surgeryName || '').trim(),
|
surgeryName: String(form.surgeryName || '').trim(),
|
||||||
preOpPressure:
|
preOpPressure:
|
||||||
@ -1122,7 +1188,7 @@ function buildSurgeryPayload(form) {
|
|||||||
}))
|
}))
|
||||||
.filter((material) => material.url),
|
.filter((material) => material.url),
|
||||||
devices: (form.devices || []).map((device) => {
|
devices: (form.devices || []).map((device) => {
|
||||||
return {
|
const payloadDevice = {
|
||||||
implantCatalogId: device.implantCatalogId,
|
implantCatalogId: device.implantCatalogId,
|
||||||
shuntMode: String(device.shuntMode || '').trim(),
|
shuntMode: String(device.shuntMode || '').trim(),
|
||||||
proximalPunctureAreas: normalizeStringArray(
|
proximalPunctureAreas: normalizeStringArray(
|
||||||
@ -1141,10 +1207,68 @@ function buildSurgeryPayload(form) {
|
|||||||
implantNotes: normalizeOptionalString(device.implantNotes),
|
implantNotes: normalizeOptionalString(device.implantNotes),
|
||||||
labelImageUrl: normalizeOptionalString(device.labelImageUrl),
|
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() {
|
function applyFiltersAndPagination() {
|
||||||
const keyword = String(searchForm.keyword || '').trim();
|
page.value = Math.max(1, Number(page.value) || 1);
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function syncDoctorFilterFromRoute() {
|
function syncDoctorFilterFromRoute() {
|
||||||
@ -1388,7 +1485,6 @@ async function fetchMedicalDictionaryOptions() {
|
|||||||
|
|
||||||
async function fetchData() {
|
async function fetchData() {
|
||||||
if (isSystemAdmin.value && !searchForm.hospitalId) {
|
if (isSystemAdmin.value && !searchForm.hospitalId) {
|
||||||
allPatients.value = [];
|
|
||||||
tableData.value = [];
|
tableData.value = [];
|
||||||
total.value = 0;
|
total.value = 0;
|
||||||
return;
|
return;
|
||||||
@ -1396,14 +1492,23 @@ async function fetchData() {
|
|||||||
|
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
try {
|
try {
|
||||||
const params = {};
|
const params = {
|
||||||
|
page: page.value,
|
||||||
|
pageSize: pageSize.value,
|
||||||
|
};
|
||||||
if (isSystemAdmin.value) {
|
if (isSystemAdmin.value) {
|
||||||
params.hospitalId = searchForm.hospitalId;
|
params.hospitalId = searchForm.hospitalId;
|
||||||
}
|
}
|
||||||
|
if (searchForm.keyword) {
|
||||||
|
params.keyword = searchForm.keyword;
|
||||||
|
}
|
||||||
|
if (searchForm.doctorId) {
|
||||||
|
params.doctorId = searchForm.doctorId;
|
||||||
|
}
|
||||||
|
|
||||||
const res = await getPatients(params);
|
const res = await getPatients(params);
|
||||||
allPatients.value = Array.isArray(res) ? res : [];
|
tableData.value = Array.isArray(res?.list) ? res.list : [];
|
||||||
applyFiltersAndPagination();
|
total.value = Number(res?.total || 0);
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false;
|
loading.value = false;
|
||||||
}
|
}
|
||||||
@ -1411,6 +1516,8 @@ async function fetchData() {
|
|||||||
|
|
||||||
async function handleSearchHospitalChange() {
|
async function handleSearchHospitalChange() {
|
||||||
page.value = 1;
|
page.value = 1;
|
||||||
|
searchForm.doctorId = null;
|
||||||
|
searchForm.doctorName = '';
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
fetchOrgNodesForDoctorTree(searchForm.hospitalId),
|
fetchOrgNodesForDoctorTree(searchForm.hospitalId),
|
||||||
fetchDoctorOptions(searchForm.hospitalId),
|
fetchDoctorOptions(searchForm.hospitalId),
|
||||||
@ -1452,6 +1559,9 @@ function resetPatientForm() {
|
|||||||
function resetSurgeryDialog() {
|
function resetSurgeryDialog() {
|
||||||
appendSurgeryForm.value = createSurgeryForm();
|
appendSurgeryForm.value = createSurgeryForm();
|
||||||
surgeryPatientContext.value = null;
|
surgeryPatientContext.value = null;
|
||||||
|
surgeryDialogMode.value = 'create';
|
||||||
|
currentEditSurgeryId.value = null;
|
||||||
|
reopenDetailAfterSurgerySubmit.value = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function ensureCreateContext() {
|
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 detail = await getPatientById(row.id);
|
||||||
const hospitalId = detail.hospital?.id || detail.hospitalId;
|
const hospitalId = detail.hospital?.id || detail.hospitalId;
|
||||||
|
|
||||||
@ -1562,16 +1672,37 @@ async function openSurgeryDialog(row) {
|
|||||||
fetchDoctorOptions(hospitalId),
|
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(
|
appendSurgeryForm.value.previousShuntSurgeryDate = toDateTimeFormValue(
|
||||||
detail.latestSurgery?.surgeryDate || detail.surgeries?.[0]?.surgeryDate,
|
detail.latestSurgery?.surgeryDate || detail.surgeries?.[0]?.surgeryDate,
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
|
||||||
surgeryPatientContext.value = {
|
surgeryPatientContext.value = {
|
||||||
id: detail.id,
|
id: detail.id,
|
||||||
name: detail.name,
|
name: detail.name,
|
||||||
hospitalId,
|
hospitalId,
|
||||||
latestSurgery: detail.latestSurgery || detail.surgeries?.[0] || null,
|
latestSurgery: detail.latestSurgery || detail.surgeries?.[0] || null,
|
||||||
|
editingSurgery,
|
||||||
surgeonLabel:
|
surgeonLabel:
|
||||||
formatDoctorOptionLabel(detail.doctor) ||
|
formatDoctorOptionLabel(detail.doctor) ||
|
||||||
resolveSurgerySurgeonLabel(detail.doctorId),
|
resolveSurgerySurgeonLabel(detail.doctorId),
|
||||||
@ -1597,17 +1728,34 @@ async function handleSubmitSurgery() {
|
|||||||
surgerySubmitLoading.value = true;
|
surgerySubmitLoading.value = true;
|
||||||
try {
|
try {
|
||||||
const patientId = surgeryPatientContext.value.id;
|
const patientId = surgeryPatientContext.value.id;
|
||||||
await createPatientSurgery(
|
const payload = buildSurgeryPayload(appendSurgeryForm.value, {
|
||||||
|
includeAbandonedDeviceIds: !isSurgeryEditMode.value,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (isSurgeryEditMode.value) {
|
||||||
|
await updatePatientSurgery(
|
||||||
patientId,
|
patientId,
|
||||||
buildSurgeryPayload(appendSurgeryForm.value),
|
currentEditSurgeryId.value,
|
||||||
|
payload,
|
||||||
);
|
);
|
||||||
|
ElMessage.success('手术记录已更新');
|
||||||
|
} else {
|
||||||
|
await createPatientSurgery(patientId, payload);
|
||||||
ElMessage.success('手术记录已保存');
|
ElMessage.success('手术记录已保存');
|
||||||
|
}
|
||||||
|
|
||||||
surgeryDialogVisible.value = false;
|
surgeryDialogVisible.value = false;
|
||||||
await fetchData();
|
await fetchData();
|
||||||
|
|
||||||
if (detailDialogVisible.value && detailPatient.value?.id === patientId) {
|
if (
|
||||||
|
(detailDialogVisible.value || reopenDetailAfterSurgerySubmit.value) &&
|
||||||
|
detailPatient.value?.id === patientId
|
||||||
|
) {
|
||||||
await openDetailDialog({ ...detailPatient.value });
|
await openDetailDialog({ ...detailPatient.value });
|
||||||
|
detailTab.value = 'surgeries';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
reopenDetailAfterSurgerySubmit.value = false;
|
||||||
} finally {
|
} finally {
|
||||||
surgerySubmitLoading.value = false;
|
surgerySubmitLoading.value = false;
|
||||||
}
|
}
|
||||||
@ -1617,10 +1765,26 @@ function openSurgeryFromDetail() {
|
|||||||
if (!detailPatient.value) {
|
if (!detailPatient.value) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
reopenDetailAfterSurgerySubmit.value = true;
|
||||||
detailDialogVisible.value = false;
|
detailDialogVisible.value = false;
|
||||||
openSurgeryDialog(detailPatient.value);
|
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) {
|
function resolveDevicePressureLevels(device) {
|
||||||
const pressureLevels = device?.implantCatalog?.pressureLevels;
|
const pressureLevels = device?.implantCatalog?.pressureLevels;
|
||||||
return Array.isArray(pressureLevels) ? pressureLevels : [];
|
return Array.isArray(pressureLevels) ? pressureLevels : [];
|
||||||
@ -1800,17 +1964,14 @@ async function openDetailDialog(row) {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const detailPromise = getPatientById(row.id);
|
const detailPromise = getPatientById(row.id);
|
||||||
const lifecyclePromise =
|
const lifecyclePromise = getPatientLifecycle(row.id);
|
||||||
row.phone && row.idCard
|
|
||||||
? getPatientLifecycle({
|
|
||||||
phone: row.phone,
|
|
||||||
idCard: row.idCard,
|
|
||||||
}).catch(() => ({ lifecycle: [] }))
|
|
||||||
: Promise.resolve({ lifecycle: [] });
|
|
||||||
|
|
||||||
const [detail, lifecycle] = await Promise.all([
|
const [detail, lifecycle] = await Promise.all([
|
||||||
detailPromise,
|
detailPromise,
|
||||||
lifecyclePromise,
|
lifecyclePromise.catch((error) => {
|
||||||
|
ElMessage.error(error?.response?.data?.msg || '患者生命周期加载失败');
|
||||||
|
return { lifecycle: [] };
|
||||||
|
}),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
detailPatient.value = detail;
|
detailPatient.value = detail;
|
||||||
|
|||||||
@ -212,7 +212,12 @@
|
|||||||
选型号后自动联动厂家和名称;支持一次手术录入多台设备
|
选型号后自动联动厂家和名称;支持一次手术录入多台设备
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<el-button type="primary" @click="addDevice">新增设备</el-button>
|
<el-button
|
||||||
|
v-if="allowDeviceStructureEdit"
|
||||||
|
type="primary"
|
||||||
|
@click="addDevice"
|
||||||
|
>新增设备</el-button
|
||||||
|
>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@ -224,6 +229,7 @@
|
|||||||
<div class="device-card-head">
|
<div class="device-card-head">
|
||||||
<div class="device-card-title">设备 {{ index + 1 }}</div>
|
<div class="device-card-title">设备 {{ index + 1 }}</div>
|
||||||
<el-button
|
<el-button
|
||||||
|
v-if="allowDeviceStructureEdit"
|
||||||
type="danger"
|
type="danger"
|
||||||
plain
|
plain
|
||||||
@click="removeDevice(index)"
|
@click="removeDevice(index)"
|
||||||
@ -477,6 +483,10 @@ const props = defineProps({
|
|||||||
type: Boolean,
|
type: Boolean,
|
||||||
default: false,
|
default: false,
|
||||||
},
|
},
|
||||||
|
allowDeviceStructureEdit: {
|
||||||
|
type: Boolean,
|
||||||
|
default: true,
|
||||||
|
},
|
||||||
title: {
|
title: {
|
||||||
type: String,
|
type: String,
|
||||||
default: '',
|
default: '',
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user