调压任务流程从“发布即指派”改为“发布待接收(PENDING) -> 工程师接收(ACCEPTED) -> 完成(COMPLETED)”。

新增工程师“取消接收”能力,任务可从 ACCEPTED 回退到 PENDING。
发布任务不再要求 engineerId,并增加同设备存在未结束任务时的重复发布拦截。
完成任务新增 completionMaterials 必填校验,仅允许图片/视频凭证,并在完成时落库。
植入物目录新增 isValve,区分阀门与管子;非阀门不维护压力挡位,阀门至少 1 个挡位。
患者设备与任务查询返回新增字段,前端任务页支持接收/取消接收/上传凭证后完成。
增补 Prisma 迁移、接口文档、E2E 用例与夹具修复逻辑。
This commit is contained in:
EL 2026-03-20 06:03:09 +08:00
parent 2bfe8ac8c8
commit 0b5640a977
32 changed files with 2124 additions and 428 deletions

6
.gitignore vendored
View File

@ -58,4 +58,8 @@ report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
/src/generated/prisma
/tyt-admin/dist
/tyt-admin/node_modules
/tyt-admin/node_modules
# Runtime upload assets
/storage/uploads
/storage/tmp-uploads

View File

@ -4,7 +4,7 @@
- 提供“全局植入物目录”管理,供患者手术表单选择。
- 维护患者手术下的植入实例记录。
- 支持为可调压器械配置挡位列表。
- 支持区分“阀门 / 管子”,并仅为阀门配置挡位列表。
- 支持管理员按医院、患者、状态和关键词分页查询患者植入实例。
## 2. 设备实例
@ -17,6 +17,7 @@
- `surgeryId`:归属手术,可为空
- `implantCatalogId`:型号字典 ID可为空
- `implantModel` / `implantManufacturer` / `implantName`:历史快照
- `isValve`:是否为阀门
- `isPressureAdjustable`:是否可调压
- `isAbandoned`:是否弃用
- `currentPressure`:当前压力挡位标签
@ -35,8 +36,9 @@
- `modelCode`:型号编码,唯一
- `manufacturer`:厂商
- `name`:名称
- `isValve`:是否为阀门;关闭时表示管子或附件
- `pressureLevels`:可调压器械的挡位字符串标签列表
- `isPressureAdjustable`是否可调压
- `isPressureAdjustable`后端按 `isValve` 自动派生
- `notes`:目录备注
可见性:
@ -47,6 +49,8 @@
说明:
- 非阀门目录项不会保存压力挡位,前端也不会显示压力录入区域。
- 阀门目录项至少需要配置一个挡位。
- 挡位列表按字符串标签保存,例如 `["0.5", "1", "1.5"]``["10", "20", "30"]`
- 保存前会自动标准化并去重排序,例如 `["01.0", "1.50", "1"]` 最终会整理为 `["1", "1.5"]`

View File

@ -5,7 +5,7 @@
- 登录页:`/auth/login`,支持可选 `hospitalId`
- 首页看板:按角色拉取组织与患者统计。
- 设备页:新增管理员专用设备 CRUD复用真实设备接口。
- 任务页:改为只读调压记录页,接入真实任务列表接口。
- 任务页:接入真实任务列表、工程师接收与完成接口。
- 用户页:修复用户列表响应结构、组织字段联动、工程师分配医院参数。
- 患者页:接入真实患者字段与生命周期查询参数(`phone + idCard`
后端直接保存身份证号原文,不再做哈希转换;调压任务入口迁到患者页。
@ -20,7 +20,7 @@
- `GET /b/tasks` 返回 `{ list, total, page, pageSize }`,供任务页只读展示调压记录。
- `POST /b/uploads` 使用 `multipart/form-data` 上传文件,返回上传资产元数据。
- `GET /b/uploads` 返回 `{ list, total, page, pageSize }`,仅供系统管理员/医院管理员影像库分页展示。
- `GET /b/tasks/engineers` 返回可选接收工程师列表,患者页发布调压任务前需要先拉取
- `GET /b/tasks/engineers` 返回当前角色可见的医院工程师列表
- `GET /c/patients/lifecycle` 必须同时传 `phone``idCard`
- 患者表单中的 `idCard` 字段直接传身份证号;
服务端只会做去空格与 `x/X` 标准化,不会转哈希。
@ -29,8 +29,8 @@
## 3. 角色权限提示
- 任务接口权限:
- `SYSTEM_ADMIN/HOSPITAL_ADMIN/DOCTOR/DIRECTOR/LEADER`:发布时必须指定接收工程师;可取消自己创建的任务
- `ENGINEER`:仅可完成分配给自己的任务
- `SYSTEM_ADMIN/HOSPITAL_ADMIN/DOCTOR/DIRECTOR/LEADER`:发布时不再指定接收工程师;可取消自己创建的任务
- `ENGINEER`可接收本院待接收任务;仅可完成自己已接收的任务
- 患者列表权限:
- `SYSTEM_ADMIN` 查询时必须传 `hospitalId`
- 用户管理接口:
@ -61,7 +61,7 @@
- `patients`
- `SYSTEM_ADMIN``HOSPITAL_ADMIN``DIRECTOR``LEADER``DOCTOR` 可访问
患者页负责发起调压任务并指定接收人,任务页仅查看调压记录,不再提供发布或接收入口
患者页负责发起调压任务,任务页负责查看、接收与完成调压任务
患者手术表单中的主刀医生不再单独选择,直接跟随患者归属医生展示和保存。

View File

@ -58,6 +58,7 @@
- `implantCatalogId`:植入物型号字典 ID
- `implantModel` / `implantManufacturer` / `implantName`:型号快照
- `isValve`:是否为阀门
- `isPressureAdjustable`:是否可调压
- `isAbandoned`:是否已弃用
- `shuntMode`:分流方式
@ -75,6 +76,7 @@
- 旧设备弃用后,`TaskItem` 历史不会删除。
- 任务发布只允许选择 `ACTIVE + isPressureAdjustable=true + isAbandoned=false` 的设备。
- 同一患者一次手术可录入多个设备,因此支持“两个可调压仪器同时佩戴”。
- 管子/附件类型不会显示“阀门植入部位”和“初始压力”录入项。
- 如果植入物目录配置了挡位,`initialPressure` 必须取挡位中的字符串标签值,例如 `0.5 / 1 / 1.5 / 10 / 20 / 30`
- 挡位标签保存前会自动标准化,例如 `01.0 -> 1``1.50 -> 1.5`
- 手术创建时不允许手工录入 `currentPressure`,设备当前压力默认继承 `initialPressure`;发布调压任务时不会立刻改当前压力,只有工程师完成任务后才会更新。

View File

@ -7,24 +7,26 @@
## 2. 状态机
- 当前发布流程:`ACCEPTED -> COMPLETED`
- 当前取消流程:`ACCEPTED -> CANCELLED`
- 历史兼容:保留 `PENDING` 状态枚举,便于兼容旧数据与旧任务记录
- 当前发布流程:`PENDING -> ACCEPTED -> COMPLETED`
- 当前工程师撤回流程:`ACCEPTED -> PENDING`
- 当前取消流程:`PENDING/ACCEPTED -> CANCELLED`
- `PENDING` 表示任务已发布,等待本院工程师接收
非法流转会返回 `409` 冲突错误(中文消息)。
## 3. 角色权限
- 系统管理员/医院管理员/医生/主任/组长:发布任务时必须直接指定接收工程师,并且只能取消自己创建的任务
- 工程师:不能再执行“接收”动作,只能完成指派给自己的任务
- 系统管理员/医院管理员/医生/主任/组长:发布任务时不再指定工程师,只能取消自己创建的任务
- 工程师:可接收本院 `PENDING` 任务;接收后只能由接收工程师自己完成,或取消接收并退回 `PENDING`
- 其他角色:默认拒绝
补充:
- `GET /b/tasks/engineers`:返回当前角色可选的接收工程师列表,系统管理员可按医院筛选。
- `GET /b/tasks/engineers`:返回当前角色可见的医院工程师列表,系统管理员可按医院筛选。
- `GET /b/tasks`:返回当前角色可见的调压记录列表,系统管理员可按医院筛选。
- `POST /b/tasks/cancel` 现支持可选 `reason` 字段,便于前端保留取消原因输入。
- 当前取消原因仅透传到事件层,数据库暂未持久化该字段。
- 如果当前设备已经存在 `PENDING / ACCEPTED` 调压任务,则禁止再次发布;同一患者的其他设备不受影响。
## 4. 记录列表
@ -35,6 +37,7 @@
- 手术名称
- 设备信息
- 旧压力 / 目标压力 / 当前压力(均为字符串挡位标签)
- 完成凭证(图片/视频)
- 创建人 / 接收人 / 发布时间
## 5. 事件触发
@ -42,6 +45,7 @@
状态变化后会发出事件:
- `task.published`
- `task.accepted`
- `task.completed`
- `task.cancelled`
@ -52,8 +56,9 @@
`completeTask` 在单事务中执行:
1. 更新任务状态为 `COMPLETED`
2. 读取 `TaskItem.targetPressure`
3. 批量更新关联 `Device.currentPressure`
2. 校验至少上传 1 条图片或视频凭证
3. 读取 `TaskItem.targetPressure`
4. 批量更新关联 `Device.currentPressure`
确保任务状态与设备压力一致性。
@ -61,3 +66,4 @@
- `publishTask` 只负责生成任务和目标挡位,不会立刻修改设备当前压力。
- 只有工程师完成任务后,目标挡位才会回写到设备实例。
- 完成任务时必须上传至少一张图片或一个视频,凭证会保存到 `Task.completionMaterials`

View File

@ -33,7 +33,7 @@
## 3. 接口
- `POST /b/uploads`
- 角色:`SYSTEM_ADMIN / HOSPITAL_ADMIN / DIRECTOR / LEADER / DOCTOR`
- 角色:`SYSTEM_ADMIN / HOSPITAL_ADMIN / DIRECTOR / LEADER / DOCTOR / ENGINEER`
- 表单字段:
- `file`:二进制文件
- `hospitalId`:仅 `SYSTEM_ADMIN` 上传时必填
@ -50,6 +50,7 @@
- 患者手术表单中的“术前 CT 影像/资料”支持直接上传,上传成功后自动回填 `type/name/url`
- 设备表单中的“植入物标签”支持直接上传图片,上传成功后自动回填 `labelImageUrl`
- 工程师完成调压任务时,可直接上传图片或视频作为完成凭证。
- 患者详情页会直接预览术前图片、视频和设备标签。
- 单独新增“影像库”页面,按图片/视频/文件分页查看所有上传资产。
- 页面访问权限仅 `SYSTEM_ADMIN / HOSPITAL_ADMIN`

View File

@ -0,0 +1,25 @@
ALTER TABLE "ImplantCatalog"
ADD COLUMN IF NOT EXISTS "isValve" BOOLEAN NOT NULL DEFAULT true;
ALTER TABLE "Device"
ADD COLUMN IF NOT EXISTS "isValve" BOOLEAN NOT NULL DEFAULT true;
UPDATE "ImplantCatalog"
SET "isPressureAdjustable" = CASE
WHEN "isValve" THEN true
ELSE false
END
WHERE "isPressureAdjustable" IS DISTINCT FROM CASE
WHEN "isValve" THEN true
ELSE false
END;
UPDATE "Device"
SET "isPressureAdjustable" = CASE
WHEN "isValve" THEN true
ELSE false
END
WHERE "isPressureAdjustable" IS DISTINCT FROM CASE
WHEN "isValve" THEN true
ELSE false
END;

View File

@ -0,0 +1,2 @@
ALTER TABLE "Task"
ADD COLUMN IF NOT EXISTS "completionMaterials" JSONB;

View File

@ -180,6 +180,8 @@ model ImplantCatalog {
modelCode String @unique
manufacturer String
name String
// 是否为阀门;关闭时表示管子/附件,不提供压力挡位。
isValve Boolean @default(true)
// 可调压器械的可选挡位,由系统管理员维护。
pressureLevels String[] @default([])
isPressureAdjustable Boolean @default(true)
@ -235,6 +237,7 @@ model Device {
implantModel String?
implantManufacturer String?
implantName String?
isValve Boolean @default(true)
isPressureAdjustable Boolean @default(true)
// 二次手术后旧设备可标记弃用,但历史调压任务仍需保留。
isAbandoned Boolean @default(false)
@ -258,16 +261,18 @@ model Device {
// 主任务表:记录调压任务主单。
model Task {
id Int @id @default(autoincrement())
status TaskStatus @default(PENDING)
creatorId Int
engineerId Int?
hospitalId Int
createdAt DateTime @default(now())
creator User @relation("TaskCreator", fields: [creatorId], references: [id])
engineer User? @relation("TaskEngineer", fields: [engineerId], references: [id])
hospital Hospital @relation(fields: [hospitalId], references: [id])
items TaskItem[]
id Int @id @default(autoincrement())
status TaskStatus @default(PENDING)
creatorId Int
engineerId Int?
hospitalId Int
createdAt DateTime @default(now())
// 工程师完成任务时上传的图片/视频凭证。
completionMaterials Json?
creator User @relation("TaskCreator", fields: [creatorId], references: [id])
engineer User? @relation("TaskEngineer", fields: [engineerId], references: [id])
hospital Hospital @relation(fields: [hospitalId], references: [id])
items TaskItem[]
@@index([hospitalId, status, createdAt])
}

View File

@ -65,15 +65,18 @@ export const MESSAGES = {
ITEMS_REQUIRED: '任务明细 items 不能为空',
DEVICE_NOT_FOUND: '存在设备不在当前医院或设备不存在',
DEVICE_MULTI_HOSPITAL: '同一批调压任务中的设备必须属于同一家医院',
DUPLICATE_DEVICE_OPEN_TASK: '该设备已有待处理调压任务,请勿重复发布',
ENGINEER_REQUIRED: '接收工程师必选',
ENGINEER_INVALID: '工程师必须为当前医院有效工程师',
TASK_NOT_FOUND: '任务不存在或不属于当前医院',
ACCEPT_DISABLED: '当前流程不支持工程师接收,请由创建人直接指定接收工程师',
ACCEPT_ONLY_PENDING: '仅待指派任务可执行接收',
COMPLETE_ONLY_ACCEPTED: '仅已指派任务可执行完成',
CANCEL_ONLY_PENDING_ACCEPTED: '仅待指派/已指派任务可取消',
ENGINEER_ALREADY_ASSIGNED: '任务已指派给其他工程师',
ACCEPT_ONLY_PENDING: '仅待接收任务可执行接收',
COMPLETE_ONLY_ACCEPTED: '仅已接收任务可执行完成',
COMPLETE_MATERIALS_REQUIRED: '完成任务至少上传一张图片或一个视频',
COMPLETE_MATERIAL_TYPE_INVALID: '完成任务仅支持图片或视频凭证',
CANCEL_ONLY_PENDING_ACCEPTED: '仅待接收/已接收任务可取消',
ENGINEER_ALREADY_ASSIGNED: '任务已被其他工程师接收',
ENGINEER_ONLY_ASSIGNEE: '仅任务接收人可完成任务',
CANCEL_ONLY_ASSIGNEE: '仅任务接收人可取消接收',
CANCEL_ONLY_CREATOR: '仅任务创建者可取消任务',
ACTOR_ROLE_FORBIDDEN: '当前角色无权限执行该任务操作',
ACTOR_HOSPITAL_REQUIRED: '当前登录上下文缺少医院信息',
@ -113,6 +116,7 @@ export const MESSAGES = {
CATALOG_MODEL_DUPLICATE: '植入物型号编码已存在',
CATALOG_SCOPE_FORBIDDEN: '当前角色无权限维护该植入物型号',
CATALOG_DELETE_CONFLICT: '植入物型号已被患者手术引用,无法删除',
VALVE_PRESSURE_REQUIRED: '阀门类型至少需要配置一个压力挡位',
PRESSURE_LEVEL_INVALID: '压力值不在该植入物配置的挡位范围内',
DEVICE_NOT_ADJUSTABLE: '仅可调压设备允许创建调压任务',
},

View File

@ -25,6 +25,7 @@ const CATALOG_SELECT = {
modelCode: true,
manufacturer: true,
name: true,
isValve: true,
pressureLevels: true,
isPressureAdjustable: true,
notes: true,
@ -214,7 +215,8 @@ export class DevicesService {
*/
async createCatalog(actor: ActorContext, dto: CreateImplantCatalogDto) {
this.assertSystemAdmin(actor);
const isPressureAdjustable = dto.isPressureAdjustable ?? true;
const isValve = dto.isValve ?? true;
const isPressureAdjustable = isValve;
try {
return await this.prisma.implantCatalog.create({
@ -225,9 +227,10 @@ export class DevicesService {
'manufacturer',
),
name: this.normalizeRequiredString(dto.name, 'name'),
isValve,
pressureLevels: this.normalizePressureLevels(
dto.pressureLevels,
isPressureAdjustable,
isValve,
),
isPressureAdjustable,
notes:
@ -258,8 +261,8 @@ export class DevicesService {
) {
this.assertSystemAdmin(actor);
const current = await this.findWritableCatalog(id);
const nextIsPressureAdjustable =
dto.isPressureAdjustable ?? current.isPressureAdjustable;
const nextIsValve = dto.isValve ?? current.isValve;
const nextIsPressureAdjustable = nextIsValve;
const data: Prisma.ImplantCatalogUpdateInput = {};
if (dto.modelCode !== undefined) {
@ -274,16 +277,14 @@ export class DevicesService {
if (dto.name !== undefined) {
data.name = this.normalizeRequiredString(dto.name, 'name');
}
if (dto.isPressureAdjustable !== undefined) {
data.isPressureAdjustable = dto.isPressureAdjustable;
if (dto.isValve !== undefined) {
data.isValve = dto.isValve;
data.isPressureAdjustable = nextIsPressureAdjustable;
}
if (
dto.pressureLevels !== undefined ||
dto.isPressureAdjustable !== undefined
) {
if (dto.pressureLevels !== undefined || dto.isValve !== undefined) {
data.pressureLevels = this.normalizePressureLevels(
dto.pressureLevels ?? current.pressureLevels,
nextIsPressureAdjustable,
nextIsValve,
);
}
if (dto.notes !== undefined) {
@ -606,13 +607,21 @@ export class DevicesService {
*/
private normalizePressureLevels(
pressureLevels: unknown[] | undefined,
isPressureAdjustable: boolean,
isValve: boolean,
) {
if (!isPressureAdjustable) {
if (!isValve) {
return [];
}
return normalizePressureLabelList(pressureLevels, 'pressureLevels');
const normalized = normalizePressureLabelList(
pressureLevels,
'pressureLevels',
);
if (normalized.length === 0) {
throw new BadRequestException(MESSAGES.DEVICE.VALVE_PRESSURE_REQUIRED);
}
return normalized;
}
/**

View File

@ -34,6 +34,15 @@ export class CreateImplantCatalogDto {
@IsString({ message: 'name 必须是字符串' })
name!: string;
@ApiPropertyOptional({
description: '是否为阀门,关闭时表示管子或附件',
example: true,
})
@IsOptional()
@ToBoolean()
@IsBoolean({ message: 'isValve 必须是布尔值' })
isValve?: boolean;
@ApiPropertyOptional({
description: '可调压器械的挡位列表,按字符串挡位标签录入',
type: [String],
@ -45,15 +54,6 @@ export class CreateImplantCatalogDto {
@IsString({ each: true, message: 'pressureLevels 必须为字符串数组' })
pressureLevels?: string[];
@ApiPropertyOptional({
description: '是否支持调压,默认 true',
example: true,
})
@IsOptional()
@ToBoolean()
@IsBoolean({ message: 'isPressureAdjustable 必须是布尔值' })
isPressureAdjustable?: boolean;
@ApiPropertyOptional({
description: '植入物备注',
example: '适用于儿童脑积水病例',

View File

@ -23,7 +23,7 @@ export class TaskEventsListener {
) {}
/**
*
* openId
*/
@OnEvent('task.published', { async: true })
async onTaskPublished(payload: TaskEventPayload) {
@ -54,6 +54,14 @@ export class TaskEventsListener {
await this.dispatchTaskEvent('task.cancelled', payload);
}
/**
*
*/
@OnEvent('task.released', { async: true })
async onTaskReleased(payload: TaskEventPayload) {
await this.dispatchTaskEvent('task.released', payload);
}
/**
*
*/

View File

@ -25,6 +25,7 @@ const IMPLANT_CATALOG_SELECT = {
modelCode: true,
manufacturer: true,
name: true,
isValve: true,
pressureLevels: true,
isPressureAdjustable: true,
notes: true,
@ -43,6 +44,7 @@ const PATIENT_LIST_INCLUDE = {
implantModel: true,
implantManufacturer: true,
implantName: true,
isValve: true,
isPressureAdjustable: true,
},
orderBy: { id: 'desc' },
@ -627,7 +629,7 @@ export class BPatientsService {
}
const initialPressure =
device.initialPressure == null
!catalog.isValve || device.initialPressure == null
? null
: this.assertPressureLevelAllowed(
catalog,
@ -637,10 +639,10 @@ export class BPatientsService {
),
);
const fallbackPressureLevel =
catalog.isPressureAdjustable && catalog.pressureLevels.length > 0
catalog.isValve && catalog.pressureLevels.length > 0
? catalog.pressureLevels[0]
: '0';
const currentPressure = catalog.isPressureAdjustable
const currentPressure = catalog.isValve
? this.assertPressureLevelAllowed(
catalog,
initialPressure ?? fallbackPressureLevel,
@ -655,6 +657,7 @@ export class BPatientsService {
implantModel: catalog.modelCode,
implantManufacturer: catalog.manufacturer,
implantName: catalog.name,
isValve: catalog.isValve,
isPressureAdjustable: catalog.isPressureAdjustable,
isAbandoned: false,
shuntMode: this.normalizeRequiredString(device.shuntMode, 'shuntMode'),
@ -662,10 +665,15 @@ export class BPatientsService {
device.proximalPunctureAreas,
'proximalPunctureAreas',
),
valvePlacementSites: this.normalizeStringArray(
device.valvePlacementSites,
'valvePlacementSites',
),
valvePlacementSites: catalog.isValve
? this.normalizeStringArray(
device.valvePlacementSites,
'valvePlacementSites',
)
: this.normalizeOptionalStringArray(
device.valvePlacementSites,
'valvePlacementSites',
),
distalShuntDirection: this.normalizeRequiredString(
device.distalShuntDirection,
'distalShuntDirection',
@ -780,12 +788,14 @@ export class BPatientsService {
*/
private assertPressureLevelAllowed(
catalog: {
isValve: boolean;
isPressureAdjustable: boolean;
pressureLevels: string[];
},
pressure: string,
) {
if (
catalog.isValve &&
catalog.isPressureAdjustable &&
Array.isArray(catalog.pressureLevels) &&
catalog.pressureLevels.length > 0 &&
@ -930,6 +940,21 @@ export class BPatientsService {
);
}
private normalizeOptionalStringArray(value: unknown, fieldName: string) {
if (value == null) {
return [];
}
if (!Array.isArray(value) || value.length === 0) {
return [];
}
return Array.from(
new Set(
value.map((item) => this.normalizeRequiredString(item, fieldName)),
),
);
}
private normalizePreOpMaterials(
materials: CreatePatientSurgeryDto['preOpMaterials'],
): Prisma.InputJsonArray {

View File

@ -42,6 +42,7 @@ export class CPatientsService {
modelCode: true,
manufacturer: true,
name: true,
isValve: true,
pressureLevels: true,
isPressureAdjustable: true,
notes: true,
@ -67,6 +68,7 @@ export class CPatientsService {
modelCode: true,
manufacturer: true,
name: true,
isValve: true,
pressureLevels: true,
isPressureAdjustable: true,
notes: true,
@ -117,6 +119,7 @@ export class CPatientsService {
implantModel: device.implantModel,
implantManufacturer: device.implantManufacturer,
implantName: device.implantName,
isValve: device.isValve,
isPressureAdjustable: device.isPressureAdjustable,
shuntMode: device.shuntMode,
distalShuntDirection: device.distalShuntDirection,
@ -154,6 +157,7 @@ export class CPatientsService {
implantModel: device.implantModel,
implantManufacturer: device.implantManufacturer,
implantName: device.implantName,
isValve: device.isValve,
isPressureAdjustable: device.isPressureAdjustable,
},
surgery: device.surgery

View File

@ -41,16 +41,16 @@ export class CreateSurgeryDeviceDto {
@IsString({ each: true, message: 'proximalPunctureAreas 必须为字符串数组' })
proximalPunctureAreas!: string[];
@ApiProperty({
description: '阀门植入部位,最多 2 个',
@ApiPropertyOptional({
description: '阀门植入部位,阀门型植入物最多 2 个',
type: [String],
example: ['耳后', '胸前'],
})
@IsOptional()
@IsArray({ message: 'valvePlacementSites 必须是数组' })
@ArrayMinSize(1, { message: 'valvePlacementSites 至少选择 1 项' })
@ArrayMaxSize(2, { message: 'valvePlacementSites 最多选择 2 项' })
@IsString({ each: true, message: 'valvePlacementSites 必须为字符串数组' })
valvePlacementSites!: string[];
valvePlacementSites?: string[];
@ApiProperty({
description: '远端分流方向',

View File

@ -30,7 +30,7 @@ export class BTasksController {
constructor(private readonly taskService: TaskService) {}
/**
*
*
*/
@Get('engineers')
@Roles(
@ -40,7 +40,7 @@ export class BTasksController {
Role.DIRECTOR,
Role.LEADER,
)
@ApiOperation({ summary: '查询可选接收工程师列表' })
@ApiOperation({ summary: '查询医院工程师列表' })
@ApiQuery({
name: 'hospitalId',
required: false,
@ -97,11 +97,11 @@ export class BTasksController {
}
/**
*
*
*/
@Post('accept')
@Roles(Role.ENGINEER)
@ApiOperation({ summary: '接收任务(已停用' })
@ApiOperation({ summary: '接收任务(ENGINEER' })
accept(@CurrentActor() actor: ActorContext, @Body() dto: AcceptTaskDto) {
return this.taskService.acceptTask(actor, dto);
}
@ -117,7 +117,7 @@ export class BTasksController {
}
/**
* ////
* ////
*/
@Post('cancel')
@Roles(
@ -126,9 +126,11 @@ export class BTasksController {
Role.DOCTOR,
Role.DIRECTOR,
Role.LEADER,
Role.ENGINEER,
)
@ApiOperation({
summary: '取消任务SYSTEM_ADMIN/HOSPITAL_ADMIN/DOCTOR/DIRECTOR/LEADER',
summary:
'取消任务SYSTEM_ADMIN/HOSPITAL_ADMIN/DOCTOR/DIRECTOR/LEADER/ENGINEER',
})
cancel(@CurrentActor() actor: ActorContext, @Body() dto: CancelTaskDto) {
return this.taskService.cancelTask(actor, dto);

View File

@ -1,6 +1,7 @@
import { ApiProperty } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import { IsInt, Min } from 'class-validator';
import { ArrayMinSize, IsArray, IsInt, Min, ValidateNested } from 'class-validator';
import { TaskCompletionMaterialDto } from './task-completion-material.dto.js';
/**
* DTO
@ -11,4 +12,14 @@ export class CompleteTaskDto {
@IsInt({ message: 'taskId 必须是整数' })
@Min(1, { message: 'taskId 必须大于 0' })
taskId!: number;
@ApiProperty({
type: [TaskCompletionMaterialDto],
description: '完成任务时上传的图片/视频凭证',
})
@IsArray({ message: 'completionMaterials 必须是数组' })
@ArrayMinSize(1, { message: 'completionMaterials 至少上传 1 项' })
@ValidateNested({ each: true })
@Type(() => TaskCompletionMaterialDto)
completionMaterials!: TaskCompletionMaterialDto[];
}

View File

@ -28,12 +28,6 @@ export class PublishTaskItemDto {
* DTO
*/
export class PublishTaskDto {
@ApiProperty({ description: '接收工程师 ID', example: 2 })
@Type(() => Number)
@IsInt({ message: 'engineerId 必须是整数' })
@Min(1, { message: 'engineerId 必须大于 0' })
engineerId!: number;
@ApiProperty({ type: [PublishTaskItemDto], description: '任务明细列表' })
@IsArray({ message: 'items 必须是数组' })
@ArrayMinSize(1, { message: 'items 至少包含一条明细' })

View File

@ -0,0 +1,42 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import { IsIn, IsInt, IsOptional, IsString, Min } from 'class-validator';
/**
* DTO
*/
export class TaskCompletionMaterialDto {
@ApiProperty({
description: '上传资产 ID',
example: 1,
})
@Type(() => Number)
@IsInt({ message: 'assetId 必须是整数' })
@Min(1, { message: 'assetId 必须大于 0' })
assetId!: number;
@ApiProperty({
description: '资料类型,仅支持图片或视频',
enum: ['IMAGE', 'VIDEO'],
example: 'IMAGE',
})
@IsIn(['IMAGE', 'VIDEO'], {
message: 'type 必须是 IMAGE 或 VIDEO',
})
type!: 'IMAGE' | 'VIDEO';
@ApiProperty({
description: '资料访问地址',
example: '/uploads/2026/03/20/20260320123000-proof.webp',
})
@IsString({ message: 'url 必须是字符串' })
url!: string;
@ApiPropertyOptional({
description: '资料名称',
example: '调压完成照片',
})
@IsOptional()
@IsString({ message: 'name 必须是字符串' })
name?: string;
}

View File

@ -7,7 +7,12 @@ import {
} from '@nestjs/common';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { Prisma } from '../generated/prisma/client.js';
import { DeviceStatus, Role, TaskStatus } from '../generated/prisma/enums.js';
import {
DeviceStatus,
Role,
TaskStatus,
UploadAssetType,
} from '../generated/prisma/enums.js';
import { PrismaService } from '../prisma.service.js';
import type { ActorContext } from '../common/actor-context.js';
import { PublishTaskDto } from './dto/publish-task.dto.js';
@ -29,7 +34,7 @@ export class TaskService {
) {}
/**
*
*
*/
async findAssignableEngineers(
actor: ActorContext,
@ -97,6 +102,7 @@ export class TaskService {
id: true,
status: true,
createdAt: true,
completionMaterials: true,
hospital: {
select: {
id: true,
@ -156,6 +162,9 @@ export class TaskService {
taskId: item.task.id,
status: item.task.status,
createdAt: item.task.createdAt,
completionMaterials: Array.isArray(item.task.completionMaterials)
? item.task.completionMaterials
: [],
hospital: item.task.hospital,
creator: item.task.creator,
engineer: item.task.engineer,
@ -175,7 +184,7 @@ export class TaskService {
}
/**
*
*
*/
async publishTask(actor: ActorContext, dto: PublishTaskDto) {
this.assertRole(actor, [
@ -238,18 +247,7 @@ export class TaskService {
actor,
devices.map((device) => device.patient.hospitalId),
);
const engineer = await this.prisma.user.findFirst({
where: {
id: dto.engineerId,
role: Role.ENGINEER,
hospitalId,
},
select: { id: true },
});
if (!engineer) {
throw new BadRequestException(MESSAGES.TASK.ENGINEER_INVALID);
}
await this.assertNoDuplicateOpenTaskForDevices(deviceIds);
const pressureByDeviceId = new Map(
devices.map((device) => [device.id, device.currentPressure] as const),
@ -280,9 +278,8 @@ export class TaskService {
const task = await this.prisma.task.create({
data: {
status: TaskStatus.ACCEPTED,
status: TaskStatus.PENDING,
creatorId: actor.id,
engineerId: engineer.id,
hospitalId,
items: {
create: dto.items.map((item) => ({
@ -306,10 +303,77 @@ export class TaskService {
}
/**
*
*
*/
async acceptTask(_actor: ActorContext, _dto: AcceptTaskDto) {
throw new ForbiddenException(MESSAGES.TASK.ACCEPT_DISABLED);
async acceptTask(actor: ActorContext, dto: AcceptTaskDto) {
this.assertRole(actor, [Role.ENGINEER]);
const hospitalId = this.requireHospitalId(actor);
const task = await this.prisma.task.findFirst({
where: {
id: dto.taskId,
hospitalId,
},
select: {
id: true,
status: true,
engineerId: true,
},
});
if (!task) {
throw new NotFoundException(MESSAGES.TASK.TASK_NOT_FOUND);
}
if (task.status !== TaskStatus.PENDING) {
if (task.engineerId && task.engineerId !== actor.id) {
throw new ConflictException(MESSAGES.TASK.ENGINEER_ALREADY_ASSIGNED);
}
throw new ConflictException(MESSAGES.TASK.ACCEPT_ONLY_PENDING);
}
const claimResult = await this.prisma.task.updateMany({
where: {
id: task.id,
hospitalId,
status: TaskStatus.PENDING,
engineerId: null,
},
data: {
status: TaskStatus.ACCEPTED,
engineerId: actor.id,
},
});
if (claimResult.count !== 1) {
const latestTask = await this.prisma.task.findUnique({
where: { id: task.id },
select: {
status: true,
engineerId: true,
},
});
if (latestTask?.engineerId && latestTask.engineerId !== actor.id) {
throw new ConflictException(MESSAGES.TASK.ENGINEER_ALREADY_ASSIGNED);
}
throw new ConflictException(MESSAGES.TASK.ACCEPT_ONLY_PENDING);
}
const acceptedTask = await this.prisma.task.findUnique({
where: { id: task.id },
include: { items: true },
});
if (!acceptedTask) {
throw new NotFoundException(MESSAGES.TASK.TASK_NOT_FOUND);
}
await this.eventEmitter.emitAsync('task.accepted', {
taskId: acceptedTask.id,
hospitalId: acceptedTask.hospitalId,
actorId: actor.id,
status: acceptedTask.status,
});
return acceptedTask;
}
/**
@ -318,6 +382,10 @@ export class TaskService {
async completeTask(actor: ActorContext, dto: CompleteTaskDto) {
this.assertRole(actor, [Role.ENGINEER]);
const hospitalId = this.requireHospitalId(actor);
const completionMaterials = await this.normalizeCompletionMaterials(
hospitalId,
dto.completionMaterials,
);
const task = await this.prisma.task.findFirst({
where: {
@ -342,7 +410,10 @@ export class TaskService {
const completedTask = await this.prisma.$transaction(async (tx) => {
const nextTask = await tx.task.update({
where: { id: task.id },
data: { status: TaskStatus.COMPLETED },
data: {
status: TaskStatus.COMPLETED,
completionMaterials,
},
include: { items: true },
});
@ -369,7 +440,9 @@ export class TaskService {
}
/**
* PENDING/ACCEPTED
*
* 1. PENDING/ACCEPTED CANCELLED
* 2. 退 PENDING
*/
async cancelTask(actor: ActorContext, dto: CancelTaskDto) {
this.assertRole(actor, [
@ -378,6 +451,7 @@ export class TaskService {
Role.DOCTOR,
Role.DIRECTOR,
Role.LEADER,
Role.ENGINEER,
]);
const scopedHospitalId = this.resolveScopedHospitalId(actor);
@ -390,6 +464,7 @@ export class TaskService {
id: true,
status: true,
creatorId: true,
engineerId: true,
hospitalId: true,
},
});
@ -397,7 +472,33 @@ export class TaskService {
if (!task) {
throw new NotFoundException(MESSAGES.TASK.TASK_NOT_FOUND);
}
if (task.creatorId !== actor.id) {
if (actor.role === Role.ENGINEER) {
if (task.engineerId !== actor.id) {
throw new ForbiddenException(MESSAGES.TASK.CANCEL_ONLY_ASSIGNEE);
}
if (task.status !== TaskStatus.ACCEPTED) {
throw new ConflictException(MESSAGES.TASK.CANCEL_ONLY_PENDING_ACCEPTED);
}
const releasedTask = await this.prisma.task.update({
where: { id: task.id },
data: {
status: TaskStatus.PENDING,
engineerId: null,
},
include: { items: true },
});
await this.eventEmitter.emitAsync('task.released', {
taskId: releasedTask.id,
hospitalId: releasedTask.hospitalId,
actorId: actor.id,
status: releasedTask.status,
reason: dto.reason?.trim() || null,
});
return releasedTask;
} else if (task.creatorId !== actor.id) {
throw new ForbiddenException(MESSAGES.TASK.CANCEL_ONLY_CREATOR);
}
if (
@ -581,6 +682,74 @@ export class TaskService {
return normalizePressureLabel(value, 'targetPressure');
}
/**
* /
*/
private async normalizeCompletionMaterials(
hospitalId: number,
materials: CompleteTaskDto['completionMaterials'],
): Promise<Prisma.InputJsonArray> {
if (!Array.isArray(materials) || materials.length === 0) {
throw new BadRequestException(MESSAGES.TASK.COMPLETE_MATERIALS_REQUIRED);
}
const assetIds = Array.from(
new Set(
materials.map((material) => {
const assetId = Number(material.assetId);
if (!Number.isInteger(assetId) || assetId <= 0) {
throw new BadRequestException('assetId 必须是整数');
}
return assetId;
}),
),
);
const assets = await this.prisma.uploadAsset.findMany({
where: {
id: { in: assetIds },
hospitalId,
type: { in: [UploadAssetType.IMAGE, UploadAssetType.VIDEO] },
},
select: {
id: true,
type: true,
url: true,
originalName: true,
},
});
if (assets.length !== assetIds.length) {
throw new BadRequestException(
MESSAGES.TASK.COMPLETE_MATERIAL_TYPE_INVALID,
);
}
const assetMap = new Map(assets.map((asset) => [asset.id, asset]));
return materials.map((material) => {
const asset = assetMap.get(material.assetId);
if (
!asset ||
(asset.type !== UploadAssetType.IMAGE &&
asset.type !== UploadAssetType.VIDEO)
) {
throw new BadRequestException(
MESSAGES.TASK.COMPLETE_MATERIAL_TYPE_INVALID,
);
}
return {
assetId: asset.id,
type: asset.type,
url: asset.url,
name:
typeof material.name === 'string' && material.name.trim()
? material.name.trim()
: asset.originalName,
};
}) as Prisma.InputJsonArray;
}
/**
*
*/
@ -590,4 +759,31 @@ export class TaskService {
}
return actor.hospitalId;
}
/**
* /
*/
private async assertNoDuplicateOpenTaskForDevices(deviceIds: number[]) {
if (deviceIds.length === 0) {
return;
}
const duplicateOpenTask = await this.prisma.taskItem.findFirst({
where: {
task: {
status: {
in: [TaskStatus.PENDING, TaskStatus.ACCEPTED],
},
},
deviceId: {
in: deviceIds,
},
},
select: { id: true },
});
if (duplicateOpenTask) {
throw new ConflictException(MESSAGES.TASK.DUPLICATE_DEVICE_OPEN_TASK);
}
}
}

View File

@ -26,7 +26,10 @@ import type { ActorContext } from '../../common/actor-context.js';
import { Role } from '../../generated/prisma/enums.js';
import { MESSAGES } from '../../common/messages.js';
import { UploadAssetQueryDto } from '../dto/upload-asset-query.dto.js';
import { ensureUploadDirectories, resolveUploadTempDir } from '../upload-path.util.js';
import {
ensureUploadDirectories,
resolveUploadTempDir,
} from '../upload-path.util.js';
import { UploadsService } from '../uploads.service.js';
import { diskStorage } from 'multer';
import { extname } from 'node:path';
@ -65,6 +68,7 @@ export class BUploadsController {
Role.DIRECTOR,
Role.LEADER,
Role.DOCTOR,
Role.ENGINEER,
)
@UseInterceptors(
FileInterceptor('file', {

View File

@ -112,7 +112,12 @@ export async function ensureE2EFixtures(
await bootstrapFixturesViaApi(app);
}
return loadSeedFixtures(prisma);
try {
return await loadSeedFixtures(prisma);
} catch (error) {
await repairFixturesViaApi(app, prisma);
return loadSeedFixtures(prisma);
}
}
async function bootstrapFixturesViaApi(app: INestApplication) {
@ -138,14 +143,19 @@ async function bootstrapFixturesViaApi(app: INestApplication) {
{ name: FIXTURE_NAMES.hospitalB },
);
const hospitalAdminA = await createWithToken(server, systemAdminToken, '/users', {
name: 'Seed Hospital Admin A',
phone: E2E_SEED_CREDENTIALS[Role.HOSPITAL_ADMIN].phone,
password: E2E_SEED_PASSWORD,
role: Role.HOSPITAL_ADMIN,
hospitalId: hospitalA.id,
openId: OPEN_IDS.hospitalAdminA,
});
const hospitalAdminA = await createWithToken(
server,
systemAdminToken,
'/users',
{
name: 'Seed Hospital Admin A',
phone: E2E_SEED_CREDENTIALS[Role.HOSPITAL_ADMIN].phone,
password: E2E_SEED_PASSWORD,
role: Role.HOSPITAL_ADMIN,
hospitalId: hospitalA.id,
openId: OPEN_IDS.hospitalAdminA,
},
);
await createWithToken(server, systemAdminToken, '/users', {
name: 'Seed Hospital Admin B',
phone: EXTRA_PHONES.hospitalAdminB,
@ -254,15 +264,20 @@ async function bootstrapFixturesViaApi(app: INestApplication) {
},
);
const directorA = await createWithToken(server, hospitalAdminAToken, '/users', {
name: 'Seed Director A',
phone: E2E_SEED_CREDENTIALS[Role.DIRECTOR].phone,
password: E2E_SEED_PASSWORD,
role: Role.DIRECTOR,
hospitalId: hospitalA.id,
departmentId: departmentA1.id,
openId: OPEN_IDS.directorA,
});
const directorA = await createWithToken(
server,
hospitalAdminAToken,
'/users',
{
name: 'Seed Director A',
phone: E2E_SEED_CREDENTIALS[Role.DIRECTOR].phone,
password: E2E_SEED_PASSWORD,
role: Role.DIRECTOR,
hospitalId: hospitalA.id,
departmentId: departmentA1.id,
openId: OPEN_IDS.directorA,
},
);
await createWithToken(server, hospitalAdminAToken, '/users', {
name: 'Seed Leader A',
phone: E2E_SEED_CREDENTIALS[Role.LEADER].phone,
@ -283,26 +298,36 @@ async function bootstrapFixturesViaApi(app: INestApplication) {
groupId: groupA1.id,
openId: OPEN_IDS.doctorA,
});
const doctorA2 = await createWithToken(server, hospitalAdminAToken, '/users', {
name: 'Seed Doctor A2',
phone: EXTRA_PHONES.doctorA2,
password: E2E_SEED_PASSWORD,
role: Role.DOCTOR,
hospitalId: hospitalA.id,
departmentId: departmentA1.id,
groupId: groupA1.id,
openId: OPEN_IDS.doctorA2,
});
const doctorA3 = await createWithToken(server, hospitalAdminAToken, '/users', {
name: 'Seed Doctor A3',
phone: EXTRA_PHONES.doctorA3,
password: E2E_SEED_PASSWORD,
role: Role.DOCTOR,
hospitalId: hospitalA.id,
departmentId: departmentA2.id,
groupId: groupA2.id,
openId: OPEN_IDS.doctorA3,
});
const doctorA2 = await createWithToken(
server,
hospitalAdminAToken,
'/users',
{
name: 'Seed Doctor A2',
phone: EXTRA_PHONES.doctorA2,
password: E2E_SEED_PASSWORD,
role: Role.DOCTOR,
hospitalId: hospitalA.id,
departmentId: departmentA1.id,
groupId: groupA1.id,
openId: OPEN_IDS.doctorA2,
},
);
const doctorA3 = await createWithToken(
server,
hospitalAdminAToken,
'/users',
{
name: 'Seed Doctor A3',
phone: EXTRA_PHONES.doctorA3,
password: E2E_SEED_PASSWORD,
role: Role.DOCTOR,
hospitalId: hospitalA.id,
departmentId: departmentA2.id,
groupId: groupA2.id,
openId: OPEN_IDS.doctorA3,
},
);
const doctorB = await createWithToken(server, hospitalAdminBToken, '/users', {
name: 'Seed Doctor B',
phone: EXTRA_PHONES.doctorB,
@ -324,8 +349,8 @@ async function bootstrapFixturesViaApi(app: INestApplication) {
modelCode: FIXTURE_NAMES.adjustableCatalog,
manufacturer: 'Seed MedTech',
name: 'Seed 可调压分流阀',
isValve: true,
pressureLevels: ['0.5', '1', '1.5'],
isPressureAdjustable: true,
notes: 'Seed 全局可调压目录样例',
},
);
@ -333,8 +358,8 @@ async function bootstrapFixturesViaApi(app: INestApplication) {
modelCode: FIXTURE_NAMES.highPressureCatalog,
manufacturer: 'Seed MedTech',
name: 'Seed 高压挡位阀',
isValve: true,
pressureLevels: ['10', '20', '30'],
isPressureAdjustable: true,
notes: 'Seed 高压挡位目录样例',
});
@ -438,33 +463,38 @@ async function bootstrapFixturesViaApi(app: INestApplication) {
},
);
const patientA2 = await createWithToken(server, doctorA2Token, '/b/patients', {
name: 'Seed Patient A2',
inpatientNo: 'ZYH-A-0002',
projectName: '脑积水随访项目-A',
phone: '13800002002',
idCard: '110101199002020022',
doctorId: doctorA2.id,
initialSurgery: {
surgeryDate: '2025-12-15T08:00:00.000Z',
surgeryName: '脑室腹腔分流术',
preOpPressure: 20,
primaryDisease: '肿瘤相关脑积水',
hydrocephalusTypes: ['梗阻性'],
devices: [
{
implantCatalogId: adjustableCatalog.id,
shuntMode: 'VPS',
proximalPunctureAreas: ['枕角'],
valvePlacementSites: ['胸前'],
distalShuntDirection: '腹腔',
initialPressure: '1',
implantNotes: 'Seed A2 当前在用设备',
labelImageUrl: 'https://seed.example.com/labels/a2-002.jpg',
},
],
const patientA2 = await createWithToken(
server,
doctorA2Token,
'/b/patients',
{
name: 'Seed Patient A2',
inpatientNo: 'ZYH-A-0002',
projectName: '脑积水随访项目-A',
phone: '13800002002',
idCard: '110101199002020022',
doctorId: doctorA2.id,
initialSurgery: {
surgeryDate: '2025-12-15T08:00:00.000Z',
surgeryName: '脑室腹腔分流术',
preOpPressure: 20,
primaryDisease: '肿瘤相关脑积水',
hydrocephalusTypes: ['梗阻性'],
devices: [
{
implantCatalogId: adjustableCatalog.id,
shuntMode: 'VPS',
proximalPunctureAreas: ['枕角'],
valvePlacementSites: ['胸前'],
distalShuntDirection: '腹腔',
initialPressure: '1',
implantNotes: 'Seed A2 当前在用设备',
labelImageUrl: 'https://seed.example.com/labels/a2-002.jpg',
},
],
},
},
});
);
await createWithToken(server, doctorA3Token, '/b/patients', {
name: 'Seed Patient A3',
@ -533,7 +563,6 @@ async function bootstrapFixturesViaApi(app: INestApplication) {
doctorAToken,
'/b/tasks/publish',
{
engineerId: engineerA.id,
items: [
{
deviceId: deviceA1Id,
@ -543,12 +572,15 @@ async function bootstrapFixturesViaApi(app: INestApplication) {
},
);
await createWithToken(server, engineerAToken, '/b/tasks/accept', {
taskId: publishedA.id,
});
await createWithToken(server, engineerAToken, '/b/tasks/complete', {
taskId: publishedA.id,
});
await createWithToken(server, doctorBToken, '/b/tasks/publish', {
engineerId: engineerB.id,
items: [
{
deviceId: deviceB1Id,
@ -561,6 +593,325 @@ async function bootstrapFixturesViaApi(app: INestApplication) {
void patientA2;
}
async function repairFixturesViaApi(
app: INestApplication,
prisma: PrismaService,
) {
const server = app.getHttpServer();
const systemAdminToken = await loginByCredential(server, {
phone: E2E_SEED_CREDENTIALS[Role.SYSTEM_ADMIN].phone,
password: E2E_SEED_PASSWORD,
role: Role.SYSTEM_ADMIN,
});
const hospitalAdminA = await requireUserScope(
prisma,
OPEN_IDS.hospitalAdminA,
);
const doctorA = await requireUserScope(prisma, OPEN_IDS.doctorA);
const doctorA2 = await requireUserScope(prisma, OPEN_IDS.doctorA2);
const doctorA3 = await requireUserScope(prisma, OPEN_IDS.doctorA3);
const doctorB = await requireUserScope(prisma, OPEN_IDS.doctorB);
if (
hospitalAdminA.hospitalId == null ||
doctorA.hospitalId == null ||
doctorB.hospitalId == null ||
doctorA.id == null ||
doctorA2.id == null ||
doctorA3.id == null ||
doctorB.id == null
) {
throw new NotFoundException('Seed user scope is incomplete');
}
const adjustableCatalog = await ensureCatalogViaApi(
prisma,
server,
systemAdminToken,
{
modelCode: FIXTURE_NAMES.adjustableCatalog,
manufacturer: 'Seed MedTech',
name: 'Seed 可调压分流阀',
isValve: true,
pressureLevels: ['0.5', '1', '1.5'],
notes: 'Seed 全局可调压目录样例',
},
);
await ensureCatalogViaApi(prisma, server, systemAdminToken, {
modelCode: FIXTURE_NAMES.highPressureCatalog,
manufacturer: 'Seed MedTech',
name: 'Seed 高压挡位阀',
isValve: true,
pressureLevels: ['10', '20', '30'],
notes: 'Seed 高压挡位目录样例',
});
const doctorAToken = await loginByCredential(server, {
phone: E2E_SEED_CREDENTIALS[Role.DOCTOR].phone,
password: E2E_SEED_PASSWORD,
role: Role.DOCTOR,
hospitalId: doctorA.hospitalId,
});
const doctorA2Token = await loginByCredential(server, {
phone: EXTRA_PHONES.doctorA2,
password: E2E_SEED_PASSWORD,
role: Role.DOCTOR,
hospitalId: doctorA.hospitalId,
});
const doctorA3Token = await loginByCredential(server, {
phone: EXTRA_PHONES.doctorA3,
password: E2E_SEED_PASSWORD,
role: Role.DOCTOR,
hospitalId: doctorA.hospitalId,
});
const doctorBToken = await loginByCredential(server, {
phone: EXTRA_PHONES.doctorB,
password: E2E_SEED_PASSWORD,
role: Role.DOCTOR,
hospitalId: doctorB.hospitalId,
});
const engineerAToken = await loginByCredential(server, {
phone: E2E_SEED_CREDENTIALS[Role.ENGINEER].phone,
password: E2E_SEED_PASSWORD,
role: Role.ENGINEER,
hospitalId: doctorA.hospitalId,
});
let patientA1Id = await findPatientId(
prisma,
doctorA.hospitalId,
SHARED_PATIENT_IDENTITY.phone,
SHARED_PATIENT_IDENTITY.idCard,
);
if (!patientA1Id) {
const patientA1 = await createWithToken(
server,
doctorAToken,
'/b/patients',
{
name: 'Seed Patient A1',
inpatientNo: 'ZYH-A-0001',
projectName: '脑积水随访项目-A',
phone: SHARED_PATIENT_IDENTITY.phone,
idCard: SHARED_PATIENT_IDENTITY.idCard,
doctorId: doctorA.id,
initialSurgery: {
surgeryDate: '2024-06-01T08:00:00.000Z',
surgeryName: '首次脑室腹腔分流术',
preOpPressure: 24,
primaryDisease: '先天性脑积水',
hydrocephalusTypes: ['交通性'],
notes: '首台手术',
devices: [
{
implantCatalogId: adjustableCatalog.id,
shuntMode: 'VPS',
proximalPunctureAreas: ['额角'],
valvePlacementSites: ['耳后'],
distalShuntDirection: '腹腔',
initialPressure: '1',
implantNotes: 'Seed A1 弃用历史设备',
labelImageUrl: 'https://seed.example.com/labels/a1-004.jpg',
},
],
},
},
);
const oldDeviceId = patientA1.devices?.[0]?.id;
if (!oldDeviceId) {
throw new Error('failed to create seed old device');
}
await createWithToken(
server,
doctorAToken,
`/b/patients/${patientA1.id}/surgeries`,
{
surgeryDate: '2025-09-10T08:00:00.000Z',
surgeryName: '分流系统翻修术',
preOpPressure: 18,
primaryDisease: '分流功能障碍',
hydrocephalusTypes: ['交通性', '高压性'],
previousShuntSurgeryDate: '2024-06-01T08:00:00.000Z',
preOpMaterials: [
{
type: 'IMAGE',
url: 'https://seed.example.com/a1-ct-preop.png',
name: 'Seed A1 术前 CT',
},
],
notes: '二次手术,保留原设备历史',
abandonedDeviceIds: [oldDeviceId],
devices: [
{
implantCatalogId: adjustableCatalog.id,
shuntMode: 'VPS',
proximalPunctureAreas: ['额角'],
valvePlacementSites: ['耳后'],
distalShuntDirection: '腹腔',
initialPressure: '1',
implantNotes: 'Seed A1 当前在用设备',
labelImageUrl: 'https://seed.example.com/labels/a1-001.jpg',
},
],
},
);
patientA1Id = patientA1.id;
}
if (
!(await findPatientId(
prisma,
doctorA.hospitalId,
'13800002002',
'110101199002020022',
))
) {
await createWithToken(server, doctorA2Token, '/b/patients', {
name: 'Seed Patient A2',
inpatientNo: 'ZYH-A-0002',
projectName: '脑积水随访项目-A',
phone: '13800002002',
idCard: '110101199002020022',
doctorId: doctorA2.id,
initialSurgery: {
surgeryDate: '2025-12-15T08:00:00.000Z',
surgeryName: '脑室腹腔分流术',
preOpPressure: 20,
primaryDisease: '肿瘤相关脑积水',
hydrocephalusTypes: ['梗阻性'],
devices: [
{
implantCatalogId: adjustableCatalog.id,
shuntMode: 'VPS',
proximalPunctureAreas: ['枕角'],
valvePlacementSites: ['胸前'],
distalShuntDirection: '腹腔',
initialPressure: '1',
implantNotes: 'Seed A2 当前在用设备',
labelImageUrl: 'https://seed.example.com/labels/a2-002.jpg',
},
],
},
});
}
if (
!(await findPatientId(
prisma,
doctorA.hospitalId,
'13800002003',
'110101199003030033',
))
) {
await createWithToken(server, doctorA3Token, '/b/patients', {
name: 'Seed Patient A3',
inpatientNo: 'ZYH-A-0003',
projectName: '脑积水随访项目-A',
phone: '13800002003',
idCard: '110101199003030033',
doctorId: doctorA3.id,
initialSurgery: {
surgeryDate: '2025-11-20T08:00:00.000Z',
surgeryName: '脑室腹腔分流术',
preOpPressure: 21,
primaryDisease: '外伤后脑积水',
hydrocephalusTypes: ['交通性'],
devices: [
{
implantCatalogId: adjustableCatalog.id,
shuntMode: 'LPS',
proximalPunctureAreas: ['腰穿'],
valvePlacementSites: ['腰背部'],
distalShuntDirection: '腹腔',
initialPressure: '1',
implantNotes: 'Seed A3 当前在用设备',
labelImageUrl: 'https://seed.example.com/labels/a3-003.jpg',
},
],
},
});
}
if (
!(await findPatientId(
prisma,
doctorB.hospitalId,
SHARED_PATIENT_IDENTITY.phone,
SHARED_PATIENT_IDENTITY.idCard,
))
) {
await createWithToken(server, doctorBToken, '/b/patients', {
name: 'Seed Patient B1',
inpatientNo: 'ZYH-B-0001',
projectName: '脑积水随访项目-B',
phone: SHARED_PATIENT_IDENTITY.phone,
idCard: SHARED_PATIENT_IDENTITY.idCard,
doctorId: doctorB.id,
initialSurgery: {
surgeryDate: '2025-10-05T08:00:00.000Z',
surgeryName: '脑室腹腔分流术',
preOpPressure: 23,
primaryDisease: '出血后脑积水',
hydrocephalusTypes: ['高压性'],
devices: [
{
implantCatalogId: adjustableCatalog.id,
shuntMode: 'VPS',
proximalPunctureAreas: ['额角'],
valvePlacementSites: ['耳后'],
distalShuntDirection: '腹腔',
initialPressure: '1',
implantNotes: 'Seed B1 当前在用设备',
labelImageUrl: 'https://seed.example.com/labels/b1-001.jpg',
},
],
},
});
}
const deviceA1Id = await requireDeviceId(prisma, 'Seed A1 当前在用设备');
const deviceB1Id = await requireDeviceId(prisma, 'Seed B1 当前在用设备');
if (!(await hasTaskItemForDevice(prisma, deviceA1Id))) {
const publishedA = await createWithToken(
server,
doctorAToken,
'/b/tasks/publish',
{
items: [
{
deviceId: deviceA1Id,
targetPressure: '1.5',
},
],
},
);
await createWithToken(server, engineerAToken, '/b/tasks/accept', {
taskId: publishedA.id,
});
await createWithToken(server, engineerAToken, '/b/tasks/complete', {
taskId: publishedA.id,
});
}
if (!(await hasTaskItemForDevice(prisma, deviceB1Id))) {
await createWithToken(server, doctorBToken, '/b/tasks/publish', {
items: [
{
deviceId: deviceB1Id,
targetPressure: '1.5',
},
],
});
}
void patientA1Id;
}
async function bootstrapDictionaries(
server: ReturnType<INestApplication['getHttpServer']>,
systemAdminToken: string,
@ -693,6 +1044,56 @@ async function requireCatalogId(
return catalog.id;
}
async function findPatientId(
prisma: PrismaService,
hospitalId: number,
phone: string,
idCard: string,
) {
const patient = await prisma.patient.findFirst({
where: { hospitalId, phone, idCard },
select: { id: true },
});
return patient?.id ?? null;
}
async function hasTaskItemForDevice(prisma: PrismaService, deviceId: number) {
const taskItem = await prisma.taskItem.findFirst({
where: { deviceId },
select: { id: true },
});
return Boolean(taskItem);
}
async function ensureCatalogViaApi(
prisma: PrismaService,
server: ReturnType<INestApplication['getHttpServer']>,
systemAdminToken: string,
payload: {
modelCode: string;
manufacturer: string;
name: string;
isValve: boolean;
pressureLevels: string[];
notes: string;
},
) {
const existing = await prisma.implantCatalog.findFirst({
where: { modelCode: payload.modelCode },
select: { id: true },
});
if (existing) {
return existing;
}
return createWithToken(
server,
systemAdminToken,
'/b/devices/catalogs',
payload,
);
}
async function requireDeviceId(
prisma: PrismaService,
implantNotes: string,
@ -727,7 +1128,10 @@ export async function loadSeedFixtures(
prisma: PrismaService,
): Promise<E2ESeedFixtures> {
const systemAdmin = await requireUserScope(prisma, OPEN_IDS.systemAdmin);
const hospitalAdminA = await requireUserScope(prisma, OPEN_IDS.hospitalAdminA);
const hospitalAdminA = await requireUserScope(
prisma,
OPEN_IDS.hospitalAdminA,
);
const directorA = await requireUserScope(prisma, OPEN_IDS.directorA);
const leaderA = await requireUserScope(prisma, OPEN_IDS.leaderA);
const doctorA = await requireUserScope(prisma, OPEN_IDS.doctorA);

View File

@ -115,13 +115,18 @@ describe('BDevicesController (e2e)', () => {
modelCode: uniqueSeedValue('catalog').toUpperCase(),
manufacturer: 'Global Vendor',
name: '全局可调压阀',
isPressureAdjustable: true,
isValve: true,
pressureLevels: ['10.0', '20', '30.0'],
notes: '测试全局目录',
});
expectSuccessEnvelope(createResponse, 201);
expect(createResponse.body.data.pressureLevels).toEqual(['10', '20', '30']);
expect(createResponse.body.data.isValve).toBe(true);
expect(createResponse.body.data.pressureLevels).toEqual([
'10',
'20',
'30',
]);
const updateResponse = await request(ctx.app.getHttpServer())
.patch(`/b/devices/catalogs/${createResponse.body.data.id}`)
@ -133,6 +138,7 @@ describe('BDevicesController (e2e)', () => {
expectSuccessEnvelope(updateResponse, 200);
expect(updateResponse.body.data.name).toBe('全局可调压阀-更新版');
expect(updateResponse.body.data.isValve).toBe(true);
expect(updateResponse.body.data.pressureLevels).toEqual([
'0.5',
'1',
@ -147,6 +153,24 @@ describe('BDevicesController (e2e)', () => {
expect(deleteResponse.body.data.id).toBe(createResponse.body.data.id);
});
it('成功SYSTEM_ADMIN 可新增非阀门目录,且不会保存压力挡位', async () => {
const response = await request(ctx.app.getHttpServer())
.post('/b/devices/catalogs')
.set('Authorization', `Bearer ${ctx.tokens[Role.SYSTEM_ADMIN]}`)
.send({
modelCode: uniqueSeedValue('tube').toUpperCase(),
manufacturer: 'Global Vendor',
name: '腹腔管',
isValve: false,
pressureLevels: ['1', '2'],
});
expectSuccessEnvelope(response, 201);
expect(response.body.data.isValve).toBe(false);
expect(response.body.data.isPressureAdjustable).toBe(false);
expect(response.body.data.pressureLevels).toEqual([]);
});
it('角色矩阵:仅 SYSTEM_ADMIN 可维护全局植入物目录', async () => {
await assertRoleMatrix({
name: 'POST /b/devices/catalogs role matrix',
@ -167,7 +191,7 @@ describe('BDevicesController (e2e)', () => {
modelCode: uniqueSeedValue('catalog-role').toUpperCase(),
manufacturer: 'Role Matrix Vendor',
name: '角色矩阵目录',
isPressureAdjustable: true,
isValve: true,
pressureLevels: ['10', '20'],
}),
sendWithoutToken: async () =>

View File

@ -1,3 +1,4 @@
import sharp from 'sharp';
import request from 'supertest';
import { DeviceStatus, Role } from '../../../src/generated/prisma/enums.js';
import {
@ -22,15 +23,45 @@ function uniqueIdCard() {
describe('Patients Controllers (e2e)', () => {
let ctx: E2EContext;
let samplePngBuffer: Buffer;
beforeAll(async () => {
ctx = await createE2EContext();
samplePngBuffer = await sharp({
create: {
width: 24,
height: 24,
channels: 3,
background: { r: 20, g: 60, b: 120 },
},
})
.png()
.toBuffer();
});
afterAll(async () => {
await closeE2EContext(ctx);
});
async function uploadEngineerProof() {
const response = await request(ctx.app.getHttpServer())
.post('/b/uploads')
.set('Authorization', `Bearer ${ctx.tokens[Role.ENGINEER]}`)
.attach('file', samplePngBuffer, {
filename: 'patient-task-proof.png',
contentType: 'image/png',
});
expectSuccessEnvelope(response, 201);
return response.body.data as {
id: number;
type: 'IMAGE' | 'VIDEO';
url: string;
originalName?: string;
fileName?: string;
};
}
describe('GET /b/patients', () => {
it('成功:按角色返回正确可见性范围', async () => {
const systemAdminResponse = await request(ctx.app.getHttpServer())
@ -292,7 +323,6 @@ describe('Patients Controllers (e2e)', () => {
.post('/b/tasks/publish')
.set('Authorization', `Bearer ${ctx.tokens[Role.DOCTOR]}`)
.send({
engineerId: ctx.fixtures.users.engineerAId,
items: [
{
deviceId: oldDeviceId,
@ -302,10 +332,27 @@ describe('Patients Controllers (e2e)', () => {
});
expectSuccessEnvelope(publishResponse, 201);
const acceptResponse = await request(ctx.app.getHttpServer())
.post('/b/tasks/accept')
.set('Authorization', `Bearer ${ctx.tokens[Role.ENGINEER]}`)
.send({ taskId: publishResponse.body.data.id });
expectSuccessEnvelope(acceptResponse, 201);
const proof = await uploadEngineerProof();
const completeResponse = await request(ctx.app.getHttpServer())
.post('/b/tasks/complete')
.set('Authorization', `Bearer ${ctx.tokens[Role.ENGINEER]}`)
.send({ taskId: publishResponse.body.data.id });
.send({
taskId: publishResponse.body.data.id,
completionMaterials: [
{
assetId: proof.id,
type: proof.type,
url: proof.url,
name: proof.originalName || proof.fileName || '调压完成照片',
},
],
});
expectSuccessEnvelope(completeResponse, 201);
const surgeryResponse = await request(ctx.app.getHttpServer())

View File

@ -1,3 +1,4 @@
import sharp from 'sharp';
import request from 'supertest';
import { Role, TaskStatus } from '../../../src/generated/prisma/enums.js';
import {
@ -9,30 +10,54 @@ import { assertRoleMatrix } from '../helpers/e2e-matrix.helper.js';
import {
expectErrorEnvelope,
expectSuccessEnvelope,
uniquePhone,
uniqueSeedValue,
} from '../helpers/e2e-http.helper.js';
function uniqueIdCard() {
const suffix = `${Date.now()}${Math.floor(Math.random() * 1000)}`
.replace(/\D/g, '')
.slice(-4);
return `11010119990101${suffix.padStart(4, '0')}`;
}
describe('BTasksController (e2e)', () => {
let ctx: E2EContext;
let samplePngBuffer: Buffer;
let doctorBToken = '';
beforeAll(async () => {
ctx = await createE2EContext();
samplePngBuffer = await sharp({
create: {
width: 32,
height: 32,
channels: 3,
background: { r: 42, g: 78, b: 126 },
},
})
.png()
.toBuffer();
doctorBToken = await loginByUser(
ctx.fixtures.users.doctorBId,
Role.DOCTOR,
ctx.fixtures.hospitalBId,
);
});
afterAll(async () => {
await closeE2EContext(ctx);
});
async function publishAssignedTask(
async function publishPendingTask(
deviceId: number,
targetPressure: string,
actorToken = ctx.tokens[Role.DOCTOR],
engineerId = ctx.fixtures.users.engineerAId,
) {
const response = await request(ctx.app.getHttpServer())
.post('/b/tasks/publish')
.set('Authorization', `Bearer ${actorToken}`)
.send({
engineerId,
items: [
{
deviceId,
@ -45,11 +70,131 @@ describe('BTasksController (e2e)', () => {
return response.body.data as {
id: number;
status: TaskStatus;
engineerId: number;
engineerId: number | null;
hospitalId: number;
};
}
async function acceptPendingTask(
taskId: number,
actorToken = ctx.tokens[Role.ENGINEER],
) {
const response = await request(ctx.app.getHttpServer())
.post('/b/tasks/accept')
.set('Authorization', `Bearer ${actorToken}`)
.send({ taskId });
expectSuccessEnvelope(response, 201);
return response.body.data as {
id: number;
status: TaskStatus;
engineerId: number;
};
}
async function loginByUser(userId: number, role: Role, hospitalId: number) {
const user = await ctx.prisma.user.findUnique({
where: { id: userId },
select: { phone: true },
});
expect(user?.phone).toBeTruthy();
const response = await request(ctx.app.getHttpServer())
.post('/auth/login')
.send({
phone: user?.phone,
password: 'Seed@1234',
role,
hospitalId,
});
expectSuccessEnvelope(response, 201);
return response.body.data.accessToken as string;
}
async function createAdjustableDevices(options?: {
actorToken?: string;
doctorId?: number;
count?: number;
patientName?: string;
projectName?: string;
}) {
const {
actorToken = ctx.tokens[Role.DOCTOR],
doctorId = ctx.fixtures.users.doctorAId,
count = 1,
patientName = '调压测试患者',
projectName = '调压测试项目',
} = options ?? {};
const response = await request(ctx.app.getHttpServer())
.post('/b/patients')
.set('Authorization', `Bearer ${actorToken}`)
.send({
name: `${patientName}-${uniqueSeedValue('name')}`,
inpatientNo: uniqueSeedValue('zyh'),
projectName,
phone: uniquePhone(),
idCard: uniqueIdCard(),
doctorId,
initialSurgery: {
surgeryDate: '2026-03-20T08:00:00.000Z',
surgeryName: '调压测试手术',
preOpPressure: 18,
primaryDisease: '交通性脑积水',
hydrocephalusTypes: ['交通性'],
devices: Array.from({ length: count }, (_, index) => ({
implantCatalogId: ctx.fixtures.catalogs.adjustableValveId,
shuntMode: 'VPS',
proximalPunctureAreas: [index % 2 === 0 ? '额角' : '枕角'],
valvePlacementSites: [index % 2 === 0 ? '耳后' : '胸前'],
distalShuntDirection: '腹腔',
initialPressure: index % 2 === 0 ? '1' : '1.5',
implantNotes: uniqueSeedValue(`task-device-${index + 1}`),
})),
},
});
expectSuccessEnvelope(response, 201);
return response.body.data.devices as Array<{ id: number }>;
}
async function uploadEngineerProof(actorToken = ctx.tokens[Role.ENGINEER]) {
const response = await request(ctx.app.getHttpServer())
.post('/b/uploads')
.set('Authorization', `Bearer ${actorToken}`)
.attach('file', samplePngBuffer, {
filename: 'task-proof.png',
contentType: 'image/png',
});
expectSuccessEnvelope(response, 201);
return response.body.data as {
id: number;
type: 'IMAGE' | 'VIDEO';
url: string;
originalName?: string;
fileName?: string;
};
}
function buildCompletionPayload(
taskId: number,
asset: Awaited<ReturnType<typeof uploadEngineerProof>>,
) {
return {
taskId,
completionMaterials: [
{
assetId: asset.id,
type: asset.type,
url: asset.url,
name: asset.originalName || asset.fileName || '调压完成照片',
},
],
};
}
describe('GET /b/tasks/engineers', () => {
it('成功DOCTOR 可查看本院可选工程师', async () => {
const response = await request(ctx.app.getHttpServer())
@ -94,6 +239,24 @@ describe('BTasksController (e2e)', () => {
describe('GET /b/tasks', () => {
it('成功SYSTEM_ADMIN 可查看跨医院调压记录', async () => {
const [deviceA] = await createAdjustableDevices();
const [deviceB] = await createAdjustableDevices({
actorToken: doctorBToken,
doctorId: ctx.fixtures.users.doctorBId,
patientName: 'B院调压患者',
});
await publishPendingTask(
deviceA.id,
'1.5',
ctx.tokens[Role.SYSTEM_ADMIN],
);
await publishPendingTask(
deviceB.id,
'1.5',
ctx.tokens[Role.SYSTEM_ADMIN],
);
const response = await request(ctx.app.getHttpServer())
.get('/b/tasks')
.set('Authorization', `Bearer ${ctx.tokens[Role.SYSTEM_ADMIN]}`)
@ -106,12 +269,9 @@ describe('BTasksController (e2e)', () => {
response.body.data.list.every(
(item: {
creator?: { id?: number; name?: string };
engineer?: { id?: number; name?: string };
engineer?: { id?: number; name?: string } | null;
}) =>
Number.isInteger(item.creator?.id) &&
Boolean(item.creator?.name) &&
Number.isInteger(item.engineer?.id) &&
Boolean(item.engineer?.name),
Number.isInteger(item.creator?.id) && Boolean(item.creator?.name),
),
).toBe(true);
@ -158,63 +318,46 @@ describe('BTasksController (e2e)', () => {
});
describe('POST /b/tasks/publish', () => {
it('成功DOCTOR 发布任务时必须直接指定接收工程师', async () => {
it('成功DOCTOR 发布任务后进入待接收状态', async () => {
const [device] = await createAdjustableDevices();
const response = await request(ctx.app.getHttpServer())
.post('/b/tasks/publish')
.set('Authorization', `Bearer ${ctx.tokens[Role.DOCTOR]}`)
.send({
engineerId: ctx.fixtures.users.engineerAId,
items: [
{
deviceId: ctx.fixtures.devices.deviceA2Id,
deviceId: device.id,
targetPressure: '1.5',
},
],
});
expectSuccessEnvelope(response, 201);
expect(response.body.data.status).toBe(TaskStatus.ACCEPTED);
expect(response.body.data.engineerId).toBe(
ctx.fixtures.users.engineerAId,
);
expect(response.body.data.status).toBe(TaskStatus.PENDING);
expect(response.body.data.engineerId).toBeNull();
});
it('成功SYSTEM_ADMIN 可按设备自动归院发布任务', async () => {
const task = await publishAssignedTask(
ctx.fixtures.devices.deviceA1Id,
const [device] = await createAdjustableDevices();
const task = await publishPendingTask(
device.id,
'1.5',
ctx.tokens[Role.SYSTEM_ADMIN],
);
expect(task.status).toBe(TaskStatus.ACCEPTED);
expect(task.status).toBe(TaskStatus.PENDING);
expect(task.hospitalId).toBe(ctx.fixtures.hospitalAId);
});
it('失败:未指定接收工程师返回 400', async () => {
const response = await request(ctx.app.getHttpServer())
.post('/b/tasks/publish')
.set('Authorization', `Bearer ${ctx.tokens[Role.DOCTOR]}`)
.send({
items: [
{
deviceId: ctx.fixtures.devices.deviceA2Id,
targetPressure: '1.5',
},
],
});
expectErrorEnvelope(response, 400, 'engineerId 必须是整数');
});
it('失败:可调压设备使用非法挡位返回 400', async () => {
const [device] = await createAdjustableDevices();
const response = await request(ctx.app.getHttpServer())
.post('/b/tasks/publish')
.set('Authorization', `Bearer ${ctx.tokens[Role.DOCTOR]}`)
.send({
engineerId: ctx.fixtures.users.engineerAId,
items: [
{
deviceId: ctx.fixtures.devices.deviceA2Id,
deviceId: device.id,
targetPressure: '2',
},
],
@ -228,7 +371,6 @@ describe('BTasksController (e2e)', () => {
.post('/b/tasks/publish')
.set('Authorization', `Bearer ${ctx.tokens[Role.DOCTOR]}`)
.send({
engineerId: ctx.fixtures.users.engineerAId,
items: [
{
deviceId: ctx.fixtures.devices.deviceB1Id,
@ -240,6 +382,47 @@ describe('BTasksController (e2e)', () => {
expectErrorEnvelope(response, 404, '存在设备不在当前医院或设备不存在');
});
it('失败:已有待处理任务的设备不可重复发布', async () => {
const [device] = await createAdjustableDevices();
await publishPendingTask(device.id, '1.5');
const duplicateResponse = await request(ctx.app.getHttpServer())
.post('/b/tasks/publish')
.set('Authorization', `Bearer ${ctx.tokens[Role.DOCTOR]}`)
.send({
items: [
{
deviceId: device.id,
targetPressure: '1',
},
],
});
expectErrorEnvelope(duplicateResponse, 409, '该设备已有待处理调压任务');
});
it('成功:同一患者另一台设备无任务时仍可发布', async () => {
const createdDevices = await createAdjustableDevices({ count: 2 });
expect(createdDevices).toHaveLength(2);
await publishPendingTask(createdDevices[0].id, '1.5');
const response = await request(ctx.app.getHttpServer())
.post('/b/tasks/publish')
.set('Authorization', `Bearer ${ctx.tokens[Role.DOCTOR]}`)
.send({
items: [
{
deviceId: createdDevices[1].id,
targetPressure: '1',
},
],
});
expectSuccessEnvelope(response, 201);
expect(response.body.data.status).toBe(TaskStatus.PENDING);
});
it('角色矩阵:管理员/DOCTOR/DIRECTOR/LEADER 可进入业务,其余角色 403未登录 401', async () => {
await assertRoleMatrix({
name: 'POST /b/tasks/publish role matrix',
@ -264,25 +447,56 @@ describe('BTasksController (e2e)', () => {
});
describe('POST /b/tasks/accept', () => {
it('失败ENGINEER 接收接口已停用,返回 403', async () => {
const task = await publishAssignedTask(
ctx.fixtures.devices.deviceA2Id,
'1.5',
);
it('成功ENGINEER 可接收本院待处理任务', async () => {
const [device] = await createAdjustableDevices();
const task = await publishPendingTask(device.id, '1.5');
const acceptedTask = await acceptPendingTask(task.id);
expect(acceptedTask.status).toBe(TaskStatus.ACCEPTED);
expect(acceptedTask.engineerId).toBe(ctx.fixtures.users.engineerAId);
});
it('失败:跨院工程师不能接收任务', async () => {
const [device] = await createAdjustableDevices();
const task = await publishPendingTask(device.id, '1.5');
const engineerB = await ctx.prisma.user.findUnique({
where: { id: ctx.fixtures.users.engineerBId },
select: { phone: true },
});
expect(engineerB?.phone).toBeTruthy();
const loginResponse = await request(ctx.app.getHttpServer())
.post('/auth/login')
.send({
phone: engineerB?.phone,
password: 'Seed@1234',
role: Role.ENGINEER,
hospitalId: ctx.fixtures.hospitalBId,
});
expectSuccessEnvelope(loginResponse, 201);
const response = await request(ctx.app.getHttpServer())
.post('/b/tasks/accept')
.set('Authorization', `Bearer ${loginResponse.body.data.accessToken}`)
.send({ taskId: task.id });
expectErrorEnvelope(response, 404, '任务不存在或不属于当前医院');
});
it('状态机失败:已被接收的任务不可再次接收', async () => {
const [device] = await createAdjustableDevices();
const task = await publishPendingTask(device.id, '1.5');
await acceptPendingTask(task.id);
const response = await request(ctx.app.getHttpServer())
.post('/b/tasks/accept')
.set('Authorization', `Bearer ${ctx.tokens[Role.ENGINEER]}`)
.send({ taskId: task.id });
expectErrorEnvelope(
response,
403,
'当前流程不支持工程师接收,请由创建人直接指定接收工程师',
);
expectErrorEnvelope(response, 409, '仅待接收任务可执行接收');
});
it('角色矩阵:接收接口对所有角色都不可用,未登录 401', async () => {
it('角色矩阵:仅 ENGINEER 可进入业务,其他角色 403未登录 401', async () => {
await assertRoleMatrix({
name: 'POST /b/tasks/accept role matrix',
tokens: ctx.tokens,
@ -292,7 +506,7 @@ describe('BTasksController (e2e)', () => {
[Role.DIRECTOR]: 403,
[Role.LEADER]: 403,
[Role.DOCTOR]: 403,
[Role.ENGINEER]: 403,
[Role.ENGINEER]: 404,
},
sendAsRole: async (_role, token) =>
request(ctx.app.getHttpServer())
@ -308,58 +522,83 @@ describe('BTasksController (e2e)', () => {
});
describe('POST /b/tasks/complete', () => {
it('成功ENGINEER 可直接完成已指派任务并同步设备压力', async () => {
it('成功ENGINEER 可完成自己已接收的任务并同步设备压力', async () => {
const targetPressure = '1.5';
const task = await publishAssignedTask(
ctx.fixtures.devices.deviceA1Id,
targetPressure,
);
const [device] = await createAdjustableDevices();
const task = await publishPendingTask(device.id, targetPressure);
await acceptPendingTask(task.id);
const proof = await uploadEngineerProof();
const completeResponse = await request(ctx.app.getHttpServer())
.post('/b/tasks/complete')
.set('Authorization', `Bearer ${ctx.tokens[Role.ENGINEER]}`)
.send({ taskId: task.id });
.send(buildCompletionPayload(task.id, proof));
expectSuccessEnvelope(completeResponse, 201);
expect(completeResponse.body.data.status).toBe(TaskStatus.COMPLETED);
expect(completeResponse.body.data.completionMaterials).toEqual([
expect.objectContaining({
assetId: proof.id,
type: 'IMAGE',
url: proof.url,
}),
]);
const device = await ctx.prisma.device.findUnique({
where: { id: ctx.fixtures.devices.deviceA1Id },
const updatedDevice = await ctx.prisma.device.findUnique({
where: { id: device.id },
select: { currentPressure: true },
});
expect(device?.currentPressure).toBe(targetPressure);
expect(updatedDevice?.currentPressure).toBe(targetPressure);
});
it('失败:完成不存在任务返回 404', async () => {
const proof = await uploadEngineerProof();
const response = await request(ctx.app.getHttpServer())
.post('/b/tasks/complete')
.set('Authorization', `Bearer ${ctx.tokens[Role.ENGINEER]}`)
.send({ taskId: 99999999 });
.send(buildCompletionPayload(99999999, proof));
expectErrorEnvelope(response, 404, '任务不存在或不属于当前医院');
});
it('失败:未上传完成凭证返回 400', async () => {
const [device] = await createAdjustableDevices();
const task = await publishPendingTask(device.id, '1.5');
await acceptPendingTask(task.id);
const response = await request(ctx.app.getHttpServer())
.post('/b/tasks/complete')
.set('Authorization', `Bearer ${ctx.tokens[Role.ENGINEER]}`)
.send({
taskId: task.id,
completionMaterials: [],
});
expectErrorEnvelope(response, 400, 'completionMaterials 至少上传 1 项');
});
it('状态机失败:重复完成返回 409', async () => {
const task = await publishAssignedTask(
ctx.fixtures.devices.deviceA2Id,
'1',
);
const [device] = await createAdjustableDevices();
const task = await publishPendingTask(device.id, '1');
await acceptPendingTask(task.id);
const proof = await uploadEngineerProof();
const firstComplete = await request(ctx.app.getHttpServer())
.post('/b/tasks/complete')
.set('Authorization', `Bearer ${ctx.tokens[Role.ENGINEER]}`)
.send({ taskId: task.id });
.send(buildCompletionPayload(task.id, proof));
expectSuccessEnvelope(firstComplete, 201);
const secondComplete = await request(ctx.app.getHttpServer())
.post('/b/tasks/complete')
.set('Authorization', `Bearer ${ctx.tokens[Role.ENGINEER]}`)
.send({ taskId: task.id });
.send(buildCompletionPayload(task.id, proof));
expectErrorEnvelope(secondComplete, 409, '仅已指派任务可执行完成');
expectErrorEnvelope(secondComplete, 409, '仅已接收任务可执行完成');
});
it('角色矩阵:仅 ENGINEER 可进入业务,其他角色 403未登录 401', async () => {
const proof = await uploadEngineerProof();
await assertRoleMatrix({
name: 'POST /b/tasks/complete role matrix',
tokens: ctx.tokens,
@ -375,21 +614,19 @@ describe('BTasksController (e2e)', () => {
request(ctx.app.getHttpServer())
.post('/b/tasks/complete')
.set('Authorization', `Bearer ${token}`)
.send({ taskId: 99999999 }),
.send(buildCompletionPayload(99999999, proof)),
sendWithoutToken: async () =>
request(ctx.app.getHttpServer())
.post('/b/tasks/complete')
.send({ taskId: 99999999 }),
.send(buildCompletionPayload(99999999, proof)),
});
});
});
describe('POST /b/tasks/cancel', () => {
it('成功DOCTOR 可取消自己创建的已指派任务', async () => {
const task = await publishAssignedTask(
ctx.fixtures.devices.deviceA3Id,
'1.5',
);
it('成功DOCTOR 可取消自己创建的待接收任务', async () => {
const [device] = await createAdjustableDevices();
const task = await publishPendingTask(device.id, '1.5');
const response = await request(ctx.app.getHttpServer())
.post('/b/tasks/cancel')
@ -400,6 +637,29 @@ describe('BTasksController (e2e)', () => {
expect(response.body.data.status).toBe(TaskStatus.CANCELLED);
});
it('成功ENGINEER 可取消接收并退回待接收状态', async () => {
const [device] = await createAdjustableDevices();
const task = await publishPendingTask(device.id, '1.5');
await acceptPendingTask(task.id);
const response = await request(ctx.app.getHttpServer())
.post('/b/tasks/cancel')
.set('Authorization', `Bearer ${ctx.tokens[Role.ENGINEER]}`)
.send({ taskId: task.id, reason: '患者临时取消' });
expectSuccessEnvelope(response, 201);
expect(response.body.data.status).toBe(TaskStatus.PENDING);
expect(response.body.data.engineerId).toBeNull();
const secondAcceptResponse = await request(ctx.app.getHttpServer())
.post('/b/tasks/accept')
.set('Authorization', `Bearer ${ctx.tokens[Role.ENGINEER]}`)
.send({ taskId: task.id });
expectSuccessEnvelope(secondAcceptResponse, 201);
expect(secondAcceptResponse.body.data.status).toBe(TaskStatus.ACCEPTED);
});
it('失败:取消不存在任务返回 404', async () => {
const response = await request(ctx.app.getHttpServer())
.post('/b/tasks/cancel')
@ -410,15 +670,15 @@ describe('BTasksController (e2e)', () => {
});
it('状态机失败:已完成任务不可取消返回 409', async () => {
const task = await publishAssignedTask(
ctx.fixtures.devices.deviceA2Id,
'1.5',
);
const [device] = await createAdjustableDevices();
const task = await publishPendingTask(device.id, '1.5');
await acceptPendingTask(task.id);
const proof = await uploadEngineerProof();
const completeResponse = await request(ctx.app.getHttpServer())
.post('/b/tasks/complete')
.set('Authorization', `Bearer ${ctx.tokens[Role.ENGINEER]}`)
.send({ taskId: task.id });
.send(buildCompletionPayload(task.id, proof));
expectSuccessEnvelope(completeResponse, 201);
const cancelResponse = await request(ctx.app.getHttpServer())
@ -426,7 +686,7 @@ describe('BTasksController (e2e)', () => {
.set('Authorization', `Bearer ${ctx.tokens[Role.DOCTOR]}`)
.send({ taskId: task.id });
expectErrorEnvelope(cancelResponse, 409, '仅待指派/已指派任务可取消');
expectErrorEnvelope(cancelResponse, 409, '仅待接收/已接收任务可取消');
});
it('角色矩阵:管理员/DOCTOR/DIRECTOR/LEADER 可进入业务,其余角色 403未登录 401', async () => {
@ -439,7 +699,7 @@ describe('BTasksController (e2e)', () => {
[Role.DIRECTOR]: 404,
[Role.LEADER]: 404,
[Role.DOCTOR]: 404,
[Role.ENGINEER]: 403,
[Role.ENGINEER]: 404,
},
sendAsRole: async (_role, token) =>
request(ctx.app.getHttpServer())

View File

@ -97,7 +97,11 @@ describe('BUploadsController (e2e)', () => {
contentType: 'image/png',
});
expectErrorEnvelope(response, 400, '系统管理员上传文件时必须显式指定 hospitalId');
expectErrorEnvelope(
response,
400,
'系统管理员上传文件时必须显式指定 hospitalId',
);
});
it('失败DIRECTOR 查询影像库返回 403', async () => {
@ -116,7 +120,7 @@ describe('BUploadsController (e2e)', () => {
expectErrorEnvelope(response, 403, '无权限执行当前操作');
});
it('角色矩阵:上传允许系统/医院/主任/组长/医生,工程师 403,未登录 401', async () => {
it('角色矩阵:上传允许系统/医院/主任/组长/医生/工程师,未登录 401', async () => {
await assertRoleMatrix({
name: 'POST /b/uploads role matrix',
tokens: ctx.tokens,
@ -126,7 +130,7 @@ describe('BUploadsController (e2e)', () => {
[Role.DIRECTOR]: 201,
[Role.LEADER]: 201,
[Role.DOCTOR]: 201,
[Role.ENGINEER]: 403,
[Role.ENGINEER]: 201,
},
sendAsRole: async (role, token) => {
const req = request(ctx.app.getHttpServer())
@ -173,7 +177,8 @@ describe('BUploadsController (e2e)', () => {
}
return req;
},
sendWithoutToken: async () => request(ctx.app.getHttpServer()).get('/b/uploads'),
sendWithoutToken: async () =>
request(ctx.app.getHttpServer()).get('/b/uploads'),
});
});
});

View File

@ -12,6 +12,10 @@ export const publishTask = (data) => {
return request.post('/b/tasks/publish', data);
};
export const acceptTask = (data) => {
return request.post('/b/tasks/accept', data);
};
export const completeTask = (data) => {
return request.post('/b/tasks/complete', data);
};

View File

@ -53,9 +53,19 @@
<el-table-column prop="modelCode" label="型号编码" min-width="180" />
<el-table-column prop="manufacturer" label="厂家" min-width="180" />
<el-table-column prop="name" label="名称" min-width="180" />
<el-table-column label="类型" width="110" align="center">
<template #default="{ row }">
<el-tag :type="row.isValve === false ? 'info' : 'success'">
{{ row.isValve === false ? '管子' : '阀门' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="挡位" min-width="220">
<template #default="{ row }">
<div v-if="row.pressureLevels?.length" class="pressure-tag-list">
<div
v-if="row.isValve !== false && row.pressureLevels?.length"
class="pressure-tag-list"
>
<el-tag
v-for="level in row.pressureLevels"
:key="`${row.id}-${level}`"
@ -127,9 +137,19 @@
/>
</el-form-item>
</el-col>
<el-col :xs="24" :md="12">
<el-form-item label="阀门">
<el-switch
v-model="form.isValve"
active-text="是"
inactive-text="否"
@change="handleValveToggle"
/>
</el-form-item>
</el-col>
</el-row>
<el-form-item label="压力挡位">
<el-form-item v-if="form.isValve" label="压力挡位">
<div class="pressure-level-panel">
<div
v-for="(level, index) in form.pressureLevels"
@ -159,6 +179,12 @@
</div>
</div>
</el-form-item>
<el-alert
v-else
type="info"
:closable="false"
title="当前目录项为管子或附件,不需要维护压力挡位。"
/>
<el-form-item label="备注">
<el-input
@ -223,6 +249,7 @@ function createDefaultForm() {
modelCode: '',
manufacturer: '',
name: '',
isValve: true,
pressureLevels: [''],
notes: '',
};
@ -281,9 +308,7 @@ async function fetchData() {
const res = await getImplantCatalogs({
keyword: searchForm.keyword || undefined,
});
tableData.value = (Array.isArray(res) ? res : []).filter(
(item) => item.isPressureAdjustable !== false,
);
tableData.value = Array.isArray(res) ? res : [];
} finally {
loading.value = false;
}
@ -300,11 +325,23 @@ function resetForm() {
form.modelCode = next.modelCode;
form.manufacturer = next.manufacturer;
form.name = next.name;
form.isValve = next.isValve;
form.pressureLevels = next.pressureLevels;
form.notes = next.notes;
currentId.value = null;
}
function handleValveToggle(value) {
if (!value) {
form.pressureLevels = [''];
return;
}
if (!Array.isArray(form.pressureLevels) || form.pressureLevels.length === 0) {
form.pressureLevels = [''];
}
}
function addPressureLevel() {
form.pressureLevels.push('');
}
@ -330,8 +367,11 @@ function openEditDialog(row) {
form.modelCode = row.modelCode || '';
form.manufacturer = row.manufacturer || '';
form.name = row.name || '';
form.isValve = row.isValve !== false;
form.pressureLevels =
Array.isArray(row.pressureLevels) && row.pressureLevels.length > 0
row.isValve !== false &&
Array.isArray(row.pressureLevels) &&
row.pressureLevels.length > 0
? [...row.pressureLevels]
: [''];
form.notes = row.notes || '';
@ -350,11 +390,15 @@ async function handleSubmit() {
}
const normalizedLevels = normalizePressureLevels(form.pressureLevels);
if (normalizedLevels.length !== form.pressureLevels.filter((level) => String(level ?? '').trim()).length) {
if (
form.isValve &&
normalizedLevels.length !==
form.pressureLevels.filter((level) => String(level ?? '').trim()).length
) {
ElMessage.warning('挡位格式不合法,请输入数字或一位小数字符串');
return;
}
if (normalizedLevels.length === 0) {
if (form.isValve && normalizedLevels.length === 0) {
ElMessage.warning('请至少录入一个挡位');
return;
}
@ -365,8 +409,8 @@ async function handleSubmit() {
modelCode: form.modelCode,
manufacturer: form.manufacturer,
name: form.name,
isPressureAdjustable: true,
pressureLevels: normalizedLevels,
isValve: form.isValve,
pressureLevels: form.isValve ? normalizedLevels : [],
notes: form.notes || undefined,
};

View File

@ -323,7 +323,7 @@
type="info"
:closable="false"
class="context-alert"
title="这里只选择目标挡位;当前压力会在调压任务完成后自动刷新。"
title="这里只选择目标挡位;发布后由本院工程师接收,当前压力会在任务完成后自动刷新。"
/>
<el-descriptions :column="1" border size="small">
@ -358,20 +358,6 @@
/>
</el-select>
</el-form-item>
<el-form-item label="接收人">
<el-select
v-model="adjustForm.engineerId"
placeholder="请选择接收工程师"
style="width: 100%"
>
<el-option
v-for="engineer in engineerOptions"
:key="engineer.id"
:label="formatEngineerLabel(engineer)"
:value="engineer.id"
/>
</el-select>
</el-form-item>
<el-form-item label="目标挡位">
<el-select
v-model="adjustForm.targetPressure"
@ -521,7 +507,9 @@
>
<div class="material-preview-name">
{{
material.name || material.type || `资料${index + 1}`
material.name ||
material.type ||
`资料${index + 1}`
}}
</div>
<el-image
@ -574,6 +562,13 @@
</div>
</div>
<div class="device-detail-tags">
<el-tag
:type="
device.isValve === false ? 'info' : 'success'
"
>
{{ device.isValve === false ? '管子' : '阀门' }}
</el-tag>
<el-tag
v-if="device.isPressureAdjustable !== false"
type="success"
@ -608,13 +603,25 @@
{{ formatList(device.proximalPunctureAreas) }}
</el-descriptions-item>
<el-descriptions-item label="阀门植入部位">
{{ formatList(device.valvePlacementSites) }}
{{
device.isValve === false
? '-'
: formatList(device.valvePlacementSites)
}}
</el-descriptions-item>
<el-descriptions-item label="初始压力">
{{ formatValue(device.initialPressure) }}
{{
device.isValve === false
? '-'
: formatValue(device.initialPressure)
}}
</el-descriptions-item>
<el-descriptions-item label="当前压力">
{{ formatValue(device.currentPressure) }}
{{
device.isValve === false
? '-'
: formatValue(device.currentPressure)
}}
</el-descriptions-item>
<el-descriptions-item label="植入物备注" :span="2">
{{ device.implantNotes || '-' }}
@ -639,42 +646,83 @@
<el-empty v-else description="当前患者尚未录入手术信息" />
</el-tab-pane>
<el-tab-pane label="生命周期" name="lifecycle">
<el-table
:data="detailLifecycle"
border
stripe
max-height="520"
empty-text="暂无手术或调压事件"
>
<el-table-column label="时间" width="180">
<template #default="{ row }">
{{ formatDateTime(row.occurredAt) }}
</template>
</el-table-column>
<el-table-column label="类型" width="110" align="center">
<template #default="{ row }">
<el-tag :type="getLifecycleEventTagType(row.eventType)">
{{ getLifecycleEventLabel(row.eventType) }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="事件说明" min-width="340">
<template #default="{ row }">
{{ formatLifecycleSummary(row) }}
</template>
</el-table-column>
<el-table-column label="补充信息" min-width="240">
<template #default="{ row }">
{{ formatLifecycleMeta(row) }}
</template>
</el-table-column>
<el-table-column label="医院" min-width="150">
<template #default="{ row }">
{{ row.hospital?.name || '-' }}
</template>
</el-table-column>
</el-table>
<el-tab-pane label="调压记录" name="adjustments">
<div class="adjust-records-panel">
<div
v-if="detailAdjustDeviceOptions.length > 1"
class="adjust-records-toolbar"
>
<div class="adjust-records-toolbar-title">
当前患者有多台可调压设备请切换设备分别查看调压记录
</div>
<el-select
v-model="detailAdjustDeviceId"
placeholder="请选择调压设备"
class="adjust-records-filter"
>
<el-option
v-for="device in detailAdjustDeviceOptions"
:key="device.id"
:label="device.label"
:value="device.id"
/>
</el-select>
</div>
<el-table
:data="detailAdjustRecords"
border
stripe
max-height="520"
empty-text="暂无调压记录"
>
<el-table-column label="时间" width="180">
<template #default="{ row }">
{{ formatDateTime(row.occurredAt) }}
</template>
</el-table-column>
<el-table-column label="调压设备" min-width="260">
<template #default="{ row }">
<div>
{{
row.device?.implantName ||
row.device?.implantModel ||
'未命名设备'
}}
</div>
<div class="sub-text">
{{ row.device?.implantModel || '-' }}
</div>
</template>
</el-table-column>
<el-table-column
label="压力变化"
min-width="160"
align="center"
>
<template #default="{ row }">
{{ formatAdjustRecordPressureChange(row) }}
</template>
</el-table-column>
<el-table-column label="任务状态" width="120" align="center">
<template #default="{ row }">
<el-tag :type="getTaskStatusTagType(row.task?.status)">
{{ getTaskStatusLabel(row.task?.status) }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="关联手术" min-width="180">
<template #default="{ row }">
{{ row.surgery?.surgeryName || '-' }}
</template>
</el-table-column>
<el-table-column label="医院" min-width="150">
<template #default="{ row }">
{{ row.hospital?.name || '-' }}
</template>
</el-table-column>
</el-table>
</div>
</el-tab-pane>
</el-tabs>
</template>
@ -712,7 +760,7 @@ import {
} from '../../api/patients';
import { getImplantCatalogs } from '../../api/devices';
import { getDictionaries } from '../../api/dictionaries';
import { getTaskEngineers, publishTask } from '../../api/tasks';
import { publishTask } from '../../api/tasks';
import {
getDepartments,
getGroups,
@ -724,10 +772,6 @@ import {
} from '../../constants/medical-dictionaries';
import { useUserStore } from '../../store/user';
import SurgeryFormSection from './components/SurgeryFormSection.vue';
import {
LIFECYCLE_EVENT_LABELS,
LIFECYCLE_EVENT_TAG_TYPES,
} from './patient-form-options';
const userStore = useUserStore();
const route = useRoute();
@ -768,7 +812,6 @@ const hospitals = ref([]);
const departments = ref([]);
const groups = ref([]);
const doctorOptions = ref([]);
const engineerOptions = ref([]);
const implantCatalogOptions = ref([]);
const medicalDictionaryOptions = ref(createEmptyMedicalDictionaryOptions());
@ -794,6 +837,7 @@ const detailDialogVisible = ref(false);
const detailTab = ref('profile');
const detailPatient = ref(null);
const detailLifecycle = ref([]);
const detailAdjustDeviceId = ref(null);
const patientForm = reactive({
name: '',
@ -808,7 +852,6 @@ const initialSurgeryForm = ref(createSurgeryForm());
const appendSurgeryForm = ref(createSurgeryForm());
const adjustForm = reactive({
deviceId: null,
engineerId: null,
targetPressure: null,
});
@ -830,6 +873,44 @@ const currentAdjustDevice = computed(() => {
);
});
const TASK_STATUS_LABELS = {
PENDING: '待接收',
ACCEPTED: '已接收',
COMPLETED: '已完成',
CANCELLED: '已取消',
};
const TASK_STATUS_TAG_TYPES = {
PENDING: 'warning',
ACCEPTED: 'primary',
COMPLETED: 'success',
CANCELLED: 'info',
};
const detailAdjustDeviceOptions = computed(() => {
return buildDetailAdjustDeviceOptions(
detailPatient.value,
detailLifecycle.value,
);
});
const detailAdjustRecords = computed(() => {
const records = Array.isArray(detailLifecycle.value)
? detailLifecycle.value
: [];
const adjustmentRecords = records.filter(
(item) => item.eventType === 'TASK_PRESSURE_ADJUSTMENT',
);
if (!detailAdjustDeviceId.value) {
return adjustmentRecords;
}
return adjustmentRecords.filter(
(item) => item.device?.id === detailAdjustDeviceId.value,
);
});
const patientRules = {
name: [{ required: true, message: '请输入患者姓名', trigger: 'blur' }],
phone: [
@ -981,13 +1062,17 @@ function validateSurgeryForm(form) {
if (!normalizeStringArray(device.proximalPunctureAreas, 2).length) {
return `设备 ${index + 1} 请填写近端穿刺区域`;
}
if (!normalizeStringArray(device.valvePlacementSites, 2).length) {
if (
catalog.isValve !== false &&
!normalizeStringArray(device.valvePlacementSites, 2).length
) {
return `设备 ${index + 1} 请填写阀门植入部位`;
}
if (!String(device.distalShuntDirection || '').trim()) {
return `设备 ${index + 1} 请选择远端分流方向`;
}
if (
catalog.isValve !== false &&
catalog.isPressureAdjustable &&
resolveCatalogPressureLevels(device.implantCatalogId).length > 0
) {
@ -1041,13 +1126,15 @@ function buildSurgeryPayload(form) {
device.proximalPunctureAreas,
2,
),
valvePlacementSites: normalizeStringArray(
device.valvePlacementSites,
2,
),
valvePlacementSites: normalizeStringArray(device.valvePlacementSites, 2)
.length
? normalizeStringArray(device.valvePlacementSites, 2)
: undefined,
distalShuntDirection: String(device.distalShuntDirection || '').trim(),
initialPressure:
normalizePressureLabel(device.initialPressure) || undefined,
resolveCatalog(device.implantCatalogId)?.isValve === false
? undefined
: normalizePressureLabel(device.initialPressure) || undefined,
implantNotes: normalizeOptionalString(device.implantNotes),
labelImageUrl: normalizeOptionalString(device.labelImageUrl),
};
@ -1167,12 +1254,6 @@ function formatAdjustDeviceLabel(device) {
].join(' ');
}
function formatEngineerLabel(engineer) {
return [engineer.name || '-', engineer.phone || '-']
.filter(Boolean)
.join(' ');
}
function formatDateTime(value) {
if (!value) {
return '-';
@ -1194,35 +1275,17 @@ function formatValue(value) {
return value == null || value === '' ? '-' : value;
}
function getLifecycleEventLabel(type) {
return LIFECYCLE_EVENT_LABELS[type] || type;
function getTaskStatusLabel(status) {
return TASK_STATUS_LABELS[status] || status || '-';
}
function getLifecycleEventTagType(type) {
return LIFECYCLE_EVENT_TAG_TYPES[type] || 'info';
function getTaskStatusTagType(status) {
return TASK_STATUS_TAG_TYPES[status] || 'info';
}
function formatLifecycleSummary(event) {
if (event.eventType === 'SURGERY') {
return `${event.surgery?.surgeryName || '-'} 主刀 ${
event.surgery?.surgeonName || '-'
} 植入设备 ${event.devices?.length || 0} `;
}
return `${event.device?.implantName || event.device?.implantModel || '设备'} 压力 ${
event.taskItem?.oldPressure ?? '-'
} -> ${event.taskItem?.targetPressure ?? '-'}`;
}
function formatLifecycleMeta(event) {
if (event.eventType === 'SURGERY') {
return `原发病 ${event.surgery?.primaryDisease || '-'} 脑积水类型 ${formatList(
event.surgery?.hydrocephalusTypes,
)}`;
}
return `任务状态 ${event.task?.status || '-'} 手术 ${
event.surgery?.surgeryName || '-'
function formatAdjustRecordPressureChange(event) {
return `${event.taskItem?.oldPressure ?? '-'} -> ${
event.taskItem?.targetPressure ?? '-'
}`;
}
@ -1312,9 +1375,7 @@ async function fetchOrgNodesForDoctorTree(
async function fetchImplantCatalogOptions() {
const res = await getImplantCatalogs();
implantCatalogOptions.value = (Array.isArray(res) ? res : []).filter(
(item) => item.isPressureAdjustable !== false,
);
implantCatalogOptions.value = Array.isArray(res) ? res : [];
}
async function fetchMedicalDictionaryOptions() {
@ -1322,16 +1383,6 @@ async function fetchMedicalDictionaryOptions() {
medicalDictionaryOptions.value = groupMedicalDictionaryItems(res);
}
async function loadAssignableEngineers(hospitalId) {
const params = {};
if (hospitalId) {
params.hospitalId = hospitalId;
}
const res = await getTaskEngineers(params);
engineerOptions.value = Array.isArray(res) ? res : [];
}
async function fetchData() {
if (isSystemAdmin.value && !searchForm.hospitalId) {
allPatients.value = [];
@ -1599,11 +1650,45 @@ function canAdjustDevice(device) {
return canPublishAdjustTask.value && isAdjustableDeviceAvailable(device);
}
function buildDetailAdjustDeviceOptions(patient, lifecycle) {
const adjustableRecords = Array.isArray(lifecycle)
? lifecycle.filter((item) => item.eventType === 'TASK_PRESSURE_ADJUSTMENT')
: [];
const recordDeviceIds = new Set(
adjustableRecords.map((item) => item.device?.id).filter(Boolean),
);
const patientDevices = Array.isArray(patient?.devices) ? patient.devices : [];
const options = patientDevices
.filter(
(device) =>
device?.isPressureAdjustable !== false &&
(device?.status === 'ACTIVE' || recordDeviceIds.has(device.id)),
)
.map((device) => ({
id: device.id,
label: formatAdjustDeviceLabel(device),
}));
if (options.length > 0) {
return options;
}
return adjustableRecords
.map((item) => item.device)
.filter(Boolean)
.filter(
(device, index, list) =>
list.findIndex((item) => item?.id === device?.id) === index,
)
.map((device) => ({
id: device.id,
label: formatAdjustDeviceLabel(device),
}));
}
function resetAdjustDialog() {
currentAdjustPatient.value = null;
engineerOptions.value = [];
adjustForm.deviceId = null;
adjustForm.engineerId = null;
adjustForm.targetPressure = null;
}
@ -1632,15 +1717,8 @@ async function openPatientAdjustDialog(row) {
return;
}
await loadAssignableEngineers(detail.hospital?.id || detail.hospitalId);
if (!engineerOptions.value.length) {
ElMessage.warning('当前医院下暂无可指派的工程师');
return;
}
currentAdjustPatient.value = detail;
adjustForm.deviceId = adjustableDevices[0].id;
adjustForm.engineerId = engineerOptions.value[0].id;
handleAdjustDeviceChange(adjustForm.deviceId);
adjustDialogVisible.value = true;
}
@ -1651,34 +1729,26 @@ async function openAdjustDialog(device) {
return;
}
await loadAssignableEngineers(patient.hospital?.id || patient.hospitalId);
if (!engineerOptions.value.length) {
ElMessage.warning('当前医院下暂无可指派的工程师');
return;
}
currentAdjustPatient.value = patient;
adjustForm.deviceId = device.id;
adjustForm.engineerId = engineerOptions.value[0].id;
handleAdjustDeviceChange(device.id);
adjustDialogVisible.value = true;
}
async function handleSubmitAdjustTask() {
if (!currentAdjustDevice.value || !adjustForm.engineerId) {
ElMessage.warning('请选择接收人和目标挡位');
if (!currentAdjustDevice.value) {
ElMessage.warning('请选择调压设备和目标挡位');
return;
}
if (adjustForm.targetPressure == null) {
ElMessage.warning('请选择接收人和目标挡位');
ElMessage.warning('请选择调压设备和目标挡位');
return;
}
adjustSubmitLoading.value = true;
try {
await publishTask({
engineerId: adjustForm.engineerId,
items: [
{
deviceId: adjustForm.deviceId,
@ -1687,7 +1757,7 @@ async function handleSubmitAdjustTask() {
],
});
ElMessage.success('调压任务已创建,完成后当前压力会自动刷新');
ElMessage.success('调压任务已发布,待本院工程师接收');
adjustDialogVisible.value = false;
if (detailPatient.value?.id === currentAdjustPatient.value?.id) {
@ -1696,7 +1766,7 @@ async function handleSubmitAdjustTask() {
phone: detailPatient.value.phone,
idCard: detailPatient.value.idCard,
});
detailTab.value = 'lifecycle';
detailTab.value = 'adjustments';
}
} finally {
adjustSubmitLoading.value = false;
@ -1723,6 +1793,7 @@ async function openDetailDialog(row) {
detailTab.value = 'profile';
detailPatient.value = null;
detailLifecycle.value = [];
detailAdjustDeviceId.value = null;
try {
const detailPromise = getPatientById(row.id);
@ -1746,6 +1817,11 @@ async function openDetailDialog(row) {
detailLifecycle.value = fullLifecycle.filter(
(item) => item.patient?.id === detail.id,
);
const deviceOptions = buildDetailAdjustDeviceOptions(
detail,
detailLifecycle.value,
);
detailAdjustDeviceId.value = deviceOptions[0]?.id || null;
} finally {
detailLoading.value = false;
}
@ -1988,6 +2064,32 @@ onMounted(async () => {
object-fit: contain;
}
.adjust-records-panel {
display: grid;
gap: 14px;
}
.adjust-records-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 14px 16px;
border: 1px solid #d7e6f5;
border-radius: 16px;
background: linear-gradient(180deg, #ffffff 0%, #f8fbff 100%);
}
.adjust-records-toolbar-title {
color: #64748b;
font-size: 13px;
}
.adjust-records-filter {
width: 360px;
max-width: 100%;
}
.adjust-dialog-body {
display: grid;
gap: 16px;
@ -2000,7 +2102,8 @@ onMounted(async () => {
@media (max-width: 768px) {
.section-card-head,
.surgery-card-head,
.device-detail-head {
.device-detail-head,
.adjust-records-toolbar {
flex-direction: column;
align-items: flex-start;
}
@ -2008,5 +2111,9 @@ onMounted(async () => {
.surgery-card-tags {
justify-content: flex-start;
}
.adjust-records-filter {
width: 100%;
}
}
</style>

View File

@ -273,13 +273,24 @@
/>
<el-tag
v-if="resolveCatalog(device.implantCatalogId)"
type="success"
:type="
resolveCatalog(device.implantCatalogId)?.isValve === false
? 'info'
: 'success'
"
>
调压设备
{{
resolveCatalog(device.implantCatalogId)?.isValve === false
? '管子'
: '阀门'
}}
</el-tag>
</div>
<div
v-if="resolvePressureLevels(device.implantCatalogId).length"
v-if="
resolveCatalog(device.implantCatalogId)?.isValve !== false &&
resolvePressureLevels(device.implantCatalogId).length
"
class="pressure-level-hint"
>
挡位
@ -342,7 +353,11 @@
</el-select>
</el-form-item>
</el-col>
<el-col :xs="24" :md="12">
<el-col
v-if="resolveCatalog(device.implantCatalogId)?.isValve === true"
:xs="24"
:md="12"
>
<el-form-item label="阀门植入部位">
<el-select
v-model="device.valvePlacementSites"
@ -363,7 +378,11 @@
</el-select>
</el-form-item>
</el-col>
<el-col :xs="24" :md="12">
<el-col
v-if="resolveCatalog(device.implantCatalogId)?.isValve === true"
:xs="24"
:md="12"
>
<el-form-item label="初始压力">
<el-select
v-if="resolvePressureLevels(device.implantCatalogId).length"
@ -381,15 +400,6 @@
:value="level"
/>
</el-select>
<el-input
v-else
v-model="device.initialPressure"
:disabled="
!resolveCatalog(device.implantCatalogId)?.isPressureAdjustable
"
placeholder="可为空"
style="width: 100%"
/>
<div class="field-hint">
当前压力创建后默认继承初始压力后续以调压任务完成结果为准
</div>
@ -554,6 +564,12 @@ const formatCatalogLabel = (catalog) => {
const handleCatalogChange = (device) => {
const catalog = resolveCatalog(device.implantCatalogId);
if (catalog?.isValve === false) {
device.valvePlacementSites = [];
device.initialPressure = '';
return;
}
if (!catalog?.isPressureAdjustable) {
device.initialPressure = '';
return;

View File

@ -54,7 +54,7 @@
<el-alert
type="info"
:closable="false"
title="发布调压任务请到患者页面选择患者并指定接收人后发起;本页仅用于查看调压记录。"
:title="pageAlertTitle"
class="page-alert"
/>
@ -140,6 +140,92 @@
{{ formatDateTime(row.createdAt) }}
</template>
</el-table-column>
<el-table-column label="完成凭证" min-width="260">
<template #default="{ row }">
<div
v-if="
Array.isArray(row.completionMaterials) &&
row.completionMaterials.length > 0
"
class="completion-preview-list"
>
<div
v-for="(material, index) in row.completionMaterials"
:key="material.assetId || material.url || index"
class="completion-preview-card"
>
<div class="completion-preview-name">
{{ material.name || material.type || '完成凭证' }}
</div>
<el-image
v-if="isImageMaterial(material)"
:src="material.url"
:preview-src-list="[material.url]"
preview-teleported
fit="contain"
class="completion-preview-image"
/>
<video
v-else-if="isVideoMaterial(material)"
:src="material.url"
class="completion-preview-video"
controls
preload="metadata"
/>
<a
v-else-if="material.url"
:href="material.url"
target="_blank"
rel="noreferrer"
class="completion-preview-link"
>
查看文件
</a>
</div>
</div>
<span v-else>-</span>
</template>
</el-table-column>
<el-table-column
v-if="isEngineer"
label="操作"
width="220"
fixed="right"
align="center"
>
<template #default="{ row }">
<el-button
v-if="canAccept(row)"
size="small"
type="primary"
:loading="actionTaskId === row.taskId && actionType === 'accept'"
@click="handleAccept(row)"
>
接收
</el-button>
<el-button
v-if="canCancel(row)"
size="small"
type="danger"
plain
:loading="actionTaskId === row.taskId && actionType === 'cancel'"
@click="handleCancel(row)"
>
取消接收
</el-button>
<el-button
v-if="canComplete(row)"
size="small"
type="success"
@click="openCompleteDialog(row)"
>
完成
</el-button>
<span v-if="!canAccept(row) && !canCancel(row) && !canComplete(row)"
>-</span
>
</template>
</el-table-column>
</el-table>
<div class="pagination-container">
@ -155,29 +241,148 @@
/>
</div>
</el-card>
<el-dialog
v-model="completeDialogVisible"
title="完成调压任务"
width="720px"
destroy-on-close
@closed="resetCompleteDialog"
>
<el-alert
type="info"
:closable="false"
title="完成前请先上传至少一张图片或一个视频凭证,完成后系统才会更新当前压力。"
/>
<div v-if="completeDialogRow" class="complete-summary-card">
<div class="complete-summary-line">
<span class="summary-label">患者</span>
<span>{{ completeDialogRow.patient?.name || '-' }}</span>
</div>
<div class="complete-summary-line">
<span class="summary-label">设备</span>
<span>
{{
completeDialogRow.device?.implantName ||
completeDialogRow.device?.implantModel ||
'-'
}}
</span>
</div>
<div class="complete-summary-line">
<span class="summary-label">目标挡位</span>
<span>{{ formatPressureChange(completeDialogRow) }}</span>
</div>
</div>
<div class="complete-upload-actions">
<AssetUploadButton
button-text="上传图片或视频"
button-type="primary"
accept="image/*,video/*"
:hospital-id="uploadHospitalId"
@uploaded="handleCompletionUploaded"
/>
</div>
<div v-if="completionMaterials.length === 0" class="empty-hint">
请先上传完成凭证
</div>
<div v-else class="completion-preview-list dialog-preview-list">
<div
v-for="(material, index) in completionMaterials"
:key="material.assetId || material.url || index"
class="completion-preview-card dialog-preview-card"
>
<div class="completion-preview-name">
{{ material.name || material.type || '完成凭证' }}
</div>
<el-image
v-if="isImageMaterial(material)"
:src="material.url"
:preview-src-list="[material.url]"
preview-teleported
fit="contain"
class="completion-preview-image"
/>
<video
v-else-if="isVideoMaterial(material)"
:src="material.url"
class="completion-preview-video"
controls
preload="metadata"
/>
<a
v-else-if="material.url"
:href="material.url"
target="_blank"
rel="noreferrer"
class="completion-preview-link"
>
查看文件
</a>
<el-button
type="danger"
plain
size="small"
@click="removeCompletionMaterial(index)"
>
删除
</el-button>
</div>
</div>
<template #footer>
<el-button @click="completeDialogVisible = false">关闭</el-button>
<el-button
type="primary"
:loading="completeSubmitting"
@click="submitComplete"
>
确认完成
</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { computed, onMounted, reactive, ref } from 'vue';
import { getTasks } from '../../api/tasks';
import { ElMessage, ElMessageBox } from 'element-plus';
import AssetUploadButton from '../../components/AssetUploadButton.vue';
import {
acceptTask,
cancelTask,
completeTask,
getTasks,
} from '../../api/tasks';
import { getHospitals } from '../../api/organization';
import { useUserStore } from '../../store/user';
const userStore = useUserStore();
const isSystemAdmin = computed(() => userStore.role === 'SYSTEM_ADMIN');
const isEngineer = computed(() => userStore.role === 'ENGINEER');
const currentUserId = computed(() => userStore.userInfo?.id || null);
const uploadHospitalId = computed(() => userStore.userInfo?.hospitalId || null);
const pageAlertTitle = computed(() =>
isEngineer.value
? '待接收任务可在本页直接接收;已接收任务可取消接收或上传图片/视频后完成。'
: '调压任务请到患者页面发布;工程师在本页接收、取消和完成,本页同时用于查看调压记录。',
);
const statusOptions = [
{ label: '待指派', value: 'PENDING' },
{ label: '已指派', value: 'ACCEPTED' },
{ label: '待接收', value: 'PENDING' },
{ label: '已接收', value: 'ACCEPTED' },
{ label: '已完成', value: 'COMPLETED' },
{ label: '已取消', value: 'CANCELLED' },
];
const statusTextMap = {
PENDING: '待指派',
ACCEPTED: '已指派',
PENDING: '待接收',
ACCEPTED: '已接收',
COMPLETED: '已完成',
CANCELLED: '已取消',
};
@ -190,11 +395,17 @@ const statusTagTypeMap = {
};
const loading = ref(false);
const actionTaskId = ref(null);
const actionType = ref('');
const hospitals = ref([]);
const tableData = ref([]);
const total = ref(0);
const page = ref(1);
const pageSize = ref(20);
const completeDialogVisible = ref(false);
const completeSubmitting = ref(false);
const completeDialogRow = ref(null);
const completionMaterials = ref([]);
const searchForm = reactive({
hospitalId: null,
@ -223,6 +434,43 @@ function formatPressureChange(row) {
return `${formatValue(row.oldPressure)} -> ${formatValue(row.targetPressure)}`;
}
function canAccept(row) {
return isEngineer.value && row?.status === 'PENDING';
}
function canComplete(row) {
return (
isEngineer.value &&
row?.status === 'ACCEPTED' &&
row?.engineer?.id === currentUserId.value
);
}
function canCancel(row) {
return (
isEngineer.value &&
row?.status === 'ACCEPTED' &&
row?.engineer?.id === currentUserId.value
);
}
function isImageMaterial(material) {
return material?.type === 'IMAGE';
}
function isVideoMaterial(material) {
return material?.type === 'VIDEO';
}
function normalizeCompletionMaterial(asset) {
return {
assetId: asset.id,
type: asset.type,
url: asset.url,
name: asset.originalName || asset.fileName || asset.name || '完成凭证',
};
}
async function fetchHospitalsForAdmin() {
if (!isSystemAdmin.value) {
return;
@ -277,6 +525,113 @@ async function handlePageChange() {
await fetchData();
}
async function handleAccept(row) {
actionTaskId.value = row.taskId;
actionType.value = 'accept';
try {
await acceptTask({ taskId: row.taskId });
ElMessage.success('任务已接收');
await fetchData();
} finally {
actionTaskId.value = null;
actionType.value = '';
}
}
function openCompleteDialog(row) {
completeDialogRow.value = row;
completionMaterials.value = [];
completeDialogVisible.value = true;
}
function resetCompleteDialog() {
completeDialogRow.value = null;
completionMaterials.value = [];
completeSubmitting.value = false;
}
function handleCompletionUploaded(asset) {
if (!isImageMaterial(asset) && !isVideoMaterial(asset)) {
ElMessage.error('完成任务仅支持图片或视频凭证');
return;
}
const material = normalizeCompletionMaterial(asset);
const existed = completionMaterials.value.some(
(item) => item.assetId === material.assetId,
);
if (existed) {
ElMessage.warning('该凭证已添加');
return;
}
completionMaterials.value = [...completionMaterials.value, material];
}
function removeCompletionMaterial(index) {
completionMaterials.value.splice(index, 1);
}
async function handleCancel(row) {
try {
await ElMessageBox.confirm(
'取消接收后,任务会退回待接收状态,其他同院工程师可重新接收,是否继续?',
'取消接收',
{
type: 'warning',
confirmButtonText: '确认取消接收',
cancelButtonText: '返回',
},
);
} catch {
return;
}
actionTaskId.value = row.taskId;
actionType.value = 'cancel';
try {
await cancelTask({ taskId: row.taskId });
ElMessage.success('任务已退回待接收');
await fetchData();
} finally {
actionTaskId.value = null;
actionType.value = '';
}
}
async function submitComplete() {
if (!completeDialogRow.value) {
return;
}
if (completionMaterials.value.length === 0) {
ElMessage.error('请先上传至少一张图片或一个视频');
return;
}
actionTaskId.value = completeDialogRow.value.taskId;
actionType.value = 'complete';
completeSubmitting.value = true;
try {
await completeTask({
taskId: completeDialogRow.value.taskId,
completionMaterials: completionMaterials.value.map((item) => ({
assetId: item.assetId,
type: item.type,
url: item.url,
name: item.name,
})),
});
ElMessage.success('任务已完成');
completeDialogVisible.value = false;
resetCompleteDialog();
await fetchData();
} finally {
actionTaskId.value = null;
actionType.value = '';
completeSubmitting.value = false;
}
}
onMounted(async () => {
await fetchHospitalsForAdmin();
await fetchData();
@ -312,6 +667,88 @@ onMounted(async () => {
font-size: 12px;
}
.empty-hint {
padding: 24px 0;
color: var(--el-text-color-secondary);
text-align: center;
}
.complete-summary-card {
margin-top: 16px;
padding: 16px;
border-radius: 14px;
background: #f8fafc;
}
.complete-summary-line {
display: flex;
gap: 12px;
align-items: center;
}
.complete-summary-line + .complete-summary-line {
margin-top: 10px;
}
.summary-label {
width: 72px;
color: var(--el-text-color-secondary);
}
.complete-upload-actions {
margin: 16px 0;
}
.completion-preview-list {
display: flex;
flex-wrap: wrap;
gap: 12px;
}
.completion-preview-card {
width: 112px;
}
.dialog-preview-list {
margin-top: 16px;
}
.dialog-preview-card {
width: 140px;
}
.completion-preview-name {
margin-bottom: 8px;
font-size: 12px;
color: var(--el-text-color-secondary);
word-break: break-all;
}
.completion-preview-image,
.completion-preview-video {
width: 100%;
height: 96px;
border-radius: 12px;
background: #eef2f7;
}
.completion-preview-image {
overflow: hidden;
}
.completion-preview-image :deep(.el-image__inner) {
object-fit: contain;
}
.completion-preview-video {
object-fit: contain;
}
.completion-preview-link {
display: inline-block;
color: var(--el-color-primary);
}
.pagination-container {
display: flex;
justify-content: flex-end;