From c4718a391262f5a7e995a259108d65a9146403eb Mon Sep 17 00:00:00 2001 From: yelan Date: Thu, 24 Sep 2026 19:52:28 +0800 Subject: [PATCH] =?UTF-8?q?feat(=E7=AD=BE=E7=BD=B2=E8=AE=B0=E5=BD=95):=20?= =?UTF-8?q?=E6=96=B0=E5=A2=9E=E7=AD=BE=E7=BD=B2=E8=AE=B0=E5=BD=95=E6=9F=A5?= =?UTF-8?q?=E8=AF=A2=E9=A1=B5=E5=B9=B6=E5=A4=8D=E7=94=A8=E7=AD=BE=E7=BD=B2?= =?UTF-8?q?=E4=BB=BB=E5=8A=A1=E6=8E=A5=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- clinical-web/.env.example | 2 + clinical-web/README.md | 26 +- clinical-web/src/api/workbench/records.ts | 675 ++++++++++++++++++ clinical-web/src/api/workbench/signing.ts | 4 + clinical-web/src/api/workbench/types.ts | 35 +- .../signing}/SigningDocumentInteractive.vue | 0 .../signing}/SigningDocumentPreview.vue | 0 .../signing}/TaskAuditTimeline.vue | 0 clinical-web/src/utils/mock-flags.ts | 6 + clinical-web/src/utils/signing-document.ts | 69 +- .../src/views/management/MenuResourceView.vue | 1 + .../components/SigningRecordDetailDialog.vue | 404 +++++++++++ .../components/SigningRecordFilterBar.vue | 221 ++++++ .../records/components/SigningRecordTable.vue | 340 +++++++++ .../src/views/workbench/records/index.vue | 598 ++++++++++++++++ .../src/views/workbench/records/types.ts | 42 ++ .../signing/components/SigningTaskDetail.vue | 6 +- 17 files changed, 2401 insertions(+), 28 deletions(-) create mode 100644 clinical-web/src/api/workbench/records.ts rename clinical-web/src/{views/workbench/signing/components => components/signing}/SigningDocumentInteractive.vue (100%) rename clinical-web/src/{views/workbench/signing/components => components/signing}/SigningDocumentPreview.vue (100%) rename clinical-web/src/{views/workbench/signing/components => components/signing}/TaskAuditTimeline.vue (100%) create mode 100644 clinical-web/src/views/workbench/records/components/SigningRecordDetailDialog.vue create mode 100644 clinical-web/src/views/workbench/records/components/SigningRecordFilterBar.vue create mode 100644 clinical-web/src/views/workbench/records/components/SigningRecordTable.vue create mode 100644 clinical-web/src/views/workbench/records/index.vue create mode 100644 clinical-web/src/views/workbench/records/types.ts diff --git a/clinical-web/.env.example b/clinical-web/.env.example index 27edbbf..86fe0fe 100644 --- a/clinical-web/.env.example +++ b/clinical-web/.env.example @@ -11,6 +11,8 @@ VITE_API_BASE_URL=/api # 「接口报错」或「数据不对」,排查成本很高。 # VITE_MOCK_WORKBENCH_HOME=false # VITE_MOCK_SIGNING=true +# 签署记录查询:后端暂无历史记录检索接口,缺省走演示数据 +# VITE_MOCK_SIGN_RECORDS=true # VITE_MOCK_DOCUMENTS=false # VITE_MOCK_DOCUMENT_PERMISSIONS=false # VITE_MOCK_REPORTS=false diff --git a/clinical-web/README.md b/clinical-web/README.md index 2b2d3e3..d077d61 100644 --- a/clinical-web/README.md +++ b/clinical-web/README.md @@ -84,7 +84,7 @@ TanStack Vue Query、PDF 预览、报表图表、自动化测试和签字板适 ## 当前状态 -已完成 Vite 基础初始化、路由、Pinia、原型主题 CSS、登录页、首页、签署工作台和文档库管理页面。角色权限与系统设置中的菜单设置已接入 MEDISIGN RBAC 接口;导航、路由和页面操作按授权资源树控制,角色数据范围在角色配置中维护,员工页面只负责绑定角色。组织架构页按机构→院区→科室分组展示员工列表,新增院区默认归属“新华院区”,支持节点筛选、员工维护和组织节点维护。系统参数卡片仍保留 Mock,真实签字板/线上签名回调和打印服务仍需设备或后端能力。 +已完成 Vite 基础初始化、路由、Pinia、原型主题 CSS、登录页、首页、签署工作台、签署记录查询和文档库管理页面。角色权限与系统设置中的菜单设置已接入 MEDISIGN RBAC 接口;导航、路由和页面操作按授权资源树控制,角色数据范围在角色配置中维护,员工页面只负责绑定角色。组织架构页按机构→院区→科室分组展示员工列表,新增院区默认归属“新华院区”,支持节点筛选、员工维护和组织节点维护。系统参数卡片仍保留 Mock,真实签字板/线上签名回调和打印服务仍需设备或后端能力。 文档库管理的真实模式已打通「模板 → 版本 → 正文」三段: @@ -94,7 +94,9 @@ TanStack Vue Query、PDF 预览、报表图表、自动化测试和签字板适 签署工作台的真实模式同样依赖正文:`GET /api/v1/templates/available-versions` 只返回 `contentSha256`,签署端用这个哈希当缓存键去换回正文快照(同一哈希命中同一份正文,版本更新后哈希变化即自动失效),避免版本更新后仍按旧正文签署。取不到正文时任务详情如实提示并给出占位版式,不退化成「看起来像真文书」的假纸样。 -自测与演示:显式开启 `VITE_MOCK_DOCUMENTS=true` 或 `VITE_MOCK_SIGNING=true` 时,文档库和签署任务的演示数据会镜像到 localStorage(见 `utils/mock-storage.ts`),刷新页面后仍可继续,不必每次重建文书、重发签署;文档库页头的「重置演示数据」可清空这些本地数据回到初始状态(该按钮只在演示模式下出现)。文档库缺省使用真实接口,签署工作台暂时缺省使用演示数据;持久化只针对演示数据,不涉及登录态与真实患者信息。本地建议在 `.env.development.local` 里显式写出开关,不要依赖缺省值。 +自测与演示:显式开启 `VITE_MOCK_DOCUMENTS=true` 或 `VITE_MOCK_SIGNING=true` 时,文档库和签署任务的演示数据会镜像到 localStorage(见 `utils/mock-storage.ts`),刷新页面后仍可继续,不必每次重建文书、重发签署;文档库页头的「重置演示数据」可清空这些本地数据回到初始状态(该按钮只在演示模式下出现)。文档库缺省使用真实接口,签署工作台与签署记录查询暂时缺省使用演示数据;持久化只针对演示数据,不涉及登录态与真实患者信息。本地建议在 `.env.development.local` 里显式写出开关,不要依赖缺省值。 + +签署记录查询的演示数据不走 localStorage:它按「距今天数」现场生成(见 `api/workbench/records.ts` 的 `MOCK_RECORD_SEEDS`),每次打开都落在今日/近 3 天/近 7 天/近 30 天这些窗口内,演示时筛选条件不会因为数据过期而全部落空。 ## 当前路由结构 @@ -105,6 +107,7 @@ TanStack Vue Query、PDF 预览、报表图表、自动化测试和签字板适 ClinicalLayout(实际导航和页面路由由授权菜单资源树提供) ├─ /dashboard 首页 ├─ /sign/workbench 签署工作台 +├─ /sign/records 签署记录查询 ├─ /system/org 组织架构 / 员工角色绑定 ├─ /system/role 角色权限与数据范围 ├─ /doc/library 文档库管理 @@ -119,6 +122,9 @@ ClinicalLayout(实际导航和页面路由由授权菜单资源树提供) 跨页面复用的业务组件和 composable 不放在具体 View 目录中: - `components/signing/NewSigningTaskDialog.vue`:新增签署任务公共弹窗,供签署工作台和文档库管理复用; +- `components/signing/SigningDocumentInteractive.vue`:签署态文书的交互化渲染(填写项回显、签名图回填、勾选收集),签署工作台详情与签署记录详情共用; +- `components/signing/SigningDocumentPreview.vue`:正文取不到时的占位版式,不冒充真文书; +- `components/signing/TaskAuditTimeline.vue`:操作留痕时间线,签署工作台详情与签署记录详情共用; - `composables/useSigningTaskForm.ts`:封装患者定位、就诊选择、模板选择、签署方式、校验和任务提交状态; - `utils/signing-document.ts`:把文书定稿 HTML 变换为签署态 HTML 的渲染管线(占位符填充、签名域、勾选与行内选项识别),签署工作台的任务详情和文档库「签署预览」共用同一份实现; - `utils/signing-editor-marks.ts`:编辑区里把占位符与勾选框渲染成带类型角标的胶囊,只改渲染不改数据(见下「文书编写约定」)。 @@ -218,10 +224,24 @@ API 模块与页面按业务域对应。工作台页面使用 `api/workbench` 首页真实模式已接入 GET /api/v1/workbench/overview;接口口径、字段映射和验收标准见 [docs/workbench-home-api.md](./docs/workbench-home-api.md)。 +## 签署记录查询 + +`views/workbench/records` 是「工作台 → 签署记录查询」(`/sign/records`,菜单资源 `sign/record/index`)的实现:工作台只保留当天的签署操作,历史记录的检索、核对与导出统一放在这里。页面只读——已签署的正式文书不可直接修改,需要追加内容时回签署工作台新建签署任务。 + +**接口现状与边界**(后端还没有「按条件检索历史签署记录」的专用接口): + +- 真实模式复用 `GET /api/v1/sign-tasks` 的筛选能力(见 `api/workbench/records.ts` 的 `toTaskQuery`),映射口径与签署工作台保持一致,不另造一套; +- 因此**「文档类别」在真实模式下不可筛**,筛选条会禁用该字段并给出原因(title 提示),而不是让它静默失效——一个「选了不生效」的筛选项比没有这个筛选项更误导人; +- 类别字段签署任务接口不返回,列表与详情都按空值展示,只有演示数据带类别; +- 导出是前端按当前筛选条件取全量后生成 CSV(UTF-8 带 BOM、CRLF 换行,Excel 直接打开不乱码)。**后端提供导出任务后应改为服务端导出**:导出范围与导出人都要写进审计日志,这一步在浏览器里做不到; +- 详情弹窗复用签署工作台的文书渲染与留痕组件(`components/signing/`),签署后 PDF 优先取服务端产物(`sign-artifacts` 里的 `SIGNED_PDF`);演示环境没有文件服务时,按页面上当前渲染结果现场生成一份。 + +演示数据缺省开启(`VITE_MOCK_SIGN_RECORDS`),且与签署工作台**分开开关**:两者共用一个开关会导致「关掉工作台的演示数据」把记录页一起带进空态。 + API 请求和响应类型按业务域放在 `api/workbench/types.ts`、`api/management/types.ts`;页面展示和交互类型放在对应 View 目录的 `types.ts`;全局复用类型放在 `types/common.ts`。页面提交模型会在 API 边界转换为后端 DTO,不向后端发送患者快照、文档名称或明文手机号等页面字段。 开发环境默认使用 `/api` 作为同源接口前缀,Vite 会将 `/api` 转发到 `https://ipad.shenynet.com`,因此浏览器不会直接跨域请求后端。代理配置位于 `vite.config.ts`,不改写 `/api/v1/...` 路径。修改代理或环境变量后需要重启 Vite 开发服务。 -生产环境不会使用 Vite 的开发代理,需要在 Nginx 或其他网关中配置同样的 `/api` 反向代理。Mock 开关按业务模块(对齐菜单)拆分,缺省只有文档库和签署工作台使用演示数据,其余模块直接调用真实后端;各开关的环境变量名与缺省值见 `src/utils/mock-flags.ts`,需要临时切换时在本地 `.env.*.local` 里覆盖即可。使用本地开发代理时,`VITE_API_BASE_URL` 应填写 `/api`;如果改为直连后端,则需要后端配置允许当前前端源的 CORS。 +生产环境不会使用 Vite 的开发代理,需要在 Nginx 或其他网关中配置同样的 `/api` 反向代理。Mock 开关按业务模块(对齐菜单)拆分,缺省只有文档库之外的签署侧(签署工作台 `VITE_MOCK_SIGNING`、签署记录查询 `VITE_MOCK_SIGN_RECORDS`)使用演示数据,其余模块直接调用真实后端;各开关的环境变量名与缺省值见 `src/utils/mock-flags.ts`,需要临时切换时在本地 `.env.*.local` 里覆盖即可。使用本地开发代理时,`VITE_API_BASE_URL` 应填写 `/api`;如果改为直连后端,则需要后端配置允许当前前端源的 CORS。 当前尚未接入的签署能力包括手写板设备桥接、线上签署页面/签名回调和打印服务。签署投递、一次性 Token 消费和真实 PNG 上传 API 已完成封装,但页面不能用 Canvas 演示数据冒充真实签名;需要接入设备适配器或患者 H5 回调后再启用。短信初次发送接口需要完整手机号,而患者查询只返回脱敏手机号,因此真实模式会要求当前操作人员确认完整投递地址;生产环境也可以改为由后端根据患者 ID 解析投递地址。签署工作台缺省即使用演示数据(`VITE_MOCK_SIGNING`),需要走真实投递时把它设为 `false`。 diff --git a/clinical-web/src/api/workbench/records.ts b/clinical-web/src/api/workbench/records.ts new file mode 100644 index 0000000..f868105 --- /dev/null +++ b/clinical-web/src/api/workbench/records.ts @@ -0,0 +1,675 @@ +/** + * 签署记录查询(工作台 · 签署记录查询)。 + * + * 这里回答的是「历史签署记录」,与签署工作台那份「当前待办」不是同一个视角: + * 工作台按当天操作组织,记录页按条件检索全量历史,因此两边各有一份演示数据 —— + * 工作台的种子固定在某一天,直接拿它当历史记录,会让「今日 / 近 3 天」这些筛选 + * 在演示里长期空着,看不出筛选到底有没有生效。两份种子的字段口径完全一致 + * (都是 SigningRecord),真实模式下读的也是同一个接口,所以不存在两套业务语义。 + * + * 真实模式复用已有的签署任务列表接口(GET /v1/sign-tasks):签署记录就是签署任务的 + * 历史视图,后端目前没有单独的记录检索接口,前端不凭空造一个不存在的地址。 + * 已知差异:后端该接口不支持按「文档类别」筛选,记录页会在真实模式下禁用这一项 + * (见 SigningRecordFilterBar 的 categoryEnabled),不做静默忽略。 + */ + +import { DEMO_SIGNATURE_DATA_URL } from '@/utils/demo-signature' +import { mockFlags } from '@/utils/mock-flags' +import { buildFieldSpanHtml } from '@/utils/signing-fields' + +import { getSigningTaskDetail, getSigningTaskEvents, getSigningTasks } from './signing' +import type { + PatientSex, + SigningApiOptions, + SigningFieldKind, + SigningMethod, + SigningRecord, + SigningRecordListResponse, + SigningRecordQuery, + SigningTaskEvent, + SigningTaskQuery, + SigningTaskRecord, + SigningTaskStatus, + VisitType, + WorkbenchCampus, +} from './types' + +const useMockData = mockFlags.signRecords + +export const isSigningRecordsMockEnabled = useMockData + +/** 签署记录的演示数据量上限:导出时逐页取回,避免一条筛选条件意外拉回整个库 */ +const MAX_PAGES = 25 +/** 导出/批量取回时单页条数 */ +const BULK_PAGE_SIZE = 200 + +/* ========================================================================= + 演示数据:文书目录 + ========================================================================= */ + +/** + * 演示文书目录。 + * + * 记录引用的是「签署当时那一版」文书,可能与当前文库不同(模板停用、改版、归档都会 + * 让历史记录指向一份现在查不到的文书),所以记录自带文书名与类别,不靠当前文库反查。 + */ +interface MockDocumentDef { + id: string + name: string + category: string + department: string + /** 该文书的风险告知条款(演示正文用) */ + items: string[] +} + +const MOCK_DOCUMENTS: MockDocumentDef[] = [ + { + id: 'mock-doc-cardiac', + name: '心脏介入手术知情同意书', + category: '知情同意书', + department: '心内科', + items: [ + '术中、术后可能发生心律失常、心肌缺血甚至心肌梗死。', + '穿刺部位可能出现血肿、假性动脉瘤或动静脉瘘。', + '可能发生造影剂过敏反应,严重者可出现过敏性休克。', + '存在麻醉意外、血管破裂、血栓形成与栓塞等风险。', + '术后需按医嘱卧床、服药并按时复诊,擅自停药可能危及生命。', + ], + }, + { + id: 'mock-doc-mri', + name: 'MRI 磁共振检查知情同意书', + category: '知情同意书', + department: '医学影像科', + items: [ + '体内有心脏起搏器、金属植入物、动脉瘤夹者不得进入检查室。', + '检查中机器噪声较大,请配合佩戴耳塞并保持体位不动。', + '使用对比剂时可能出现恶心、皮疹等不良反应,极少数可发生严重过敏。', + '危重患者、不能配合者可能无法完成检查或需改期。', + '检查结果由诊断医师出具,最终以正式报告为准。', + ], + }, + { + id: 'mock-doc-endoscopy', + name: '内镜检查知情同意书', + category: '知情同意书', + department: '消化内科', + items: [ + '检查过程中可能出现咽喉不适、恶心、腹胀或轻度腹痛。', + '极少数情况下可能发生出血、穿孔等并发症,需进一步处理。', + '麻醉或镇静下检查存在呼吸抑制、误吸等风险。', + '检查中可能根据所见情况取活检或行息肉切除。', + '若检查前准备不充分,可能影响观察效果或需要重新检查。', + ], + }, + { + id: 'mock-doc-narcotic', + name: '麻醉药品、精神药品知情同意书', + category: '知情同意书', + department: '麻醉科·精神科', + items: [ + '麻精药品具有成瘾性(依赖性)风险,须严格按照医嘱使用。', + '不得套开、转借或转售麻精药品,转售涉嫌毒品犯罪。', + '需按医嘱定期复诊,长期使用者至少每 3 个月复诊一次。', + '过期、未使用的药品及废贴、空安瓿应交回医疗机构销毁。', + '可能出现恶心、呕吐、便秘、头晕、嗜睡、呼吸抑制等不良反应。', + ], + }, + { + id: 'mock-doc-surgery', + name: '手术麻醉风险告知书', + category: '风险告知书', + department: '普外科', + items: [ + '麻醉过程中可能出现呼吸循环波动、药物过敏或困难气道。', + '术中可能因病情变化扩大手术范围或改变手术方式。', + '术后可能发生出血、感染、切口愈合不良或深静脉血栓。', + '合并基础疾病者发生并发症的风险相应升高。', + '术后需配合镇痛、早期活动与康复训练。', + ], + }, + { + id: 'mock-doc-blood', + name: '输血治疗风险告知书', + category: '风险告知书', + department: '血液科', + items: [ + '可能出现发热、寒战、皮疹等输血反应。', + '极少数情况下可能发生溶血反应或输血相关急性肺损伤。', + '虽经严格筛查,仍存在窗口期病原体传播的极小概率。', + '大量输血可能引起凝血功能异常、电解质紊乱。', + '输血过程中请勿擅自离开病区,出现不适请立即告知医护人员。', + ], + }, + { + id: 'mock-doc-admission', + name: '住院须知及告知书', + category: '入院告知', + department: '医务处', + items: [ + '住院期间请遵守病区管理要求,不得擅自离院或外宿。', + '请如实告知既往病史、过敏史与用药情况。', + '贵重物品请自行妥善保管,避免带入大量现金。', + '医疗费用按医保政策与医院规定结算,欠费可能影响治疗安排。', + '诊疗方案可能根据病情变化调整,调整前会与患者或家属沟通。', + ], + }, + { + id: 'mock-doc-emergency', + name: '入院告知书(急诊留观)', + category: '入院告知', + department: '急诊科', + items: [ + '留观期间病情可能随时变化,需配合医护人员密切观察。', + '留观不等同于住院,如需住院将另行办理手续。', + '部分检查结果出具需要时间,请耐心等待并保持联系方式畅通。', + '如病情加重需转入抢救室或重症监护病房,将另行告知。', + '离院需经医师评估同意,擅自离院可能延误诊治。', + ], + }, +] + +/** 科室所属院区:演示数据按科室归属派生院区,不必逐条重复写一遍 */ +const MOCK_DEPARTMENT_CAMPUS: Record = { + 心内科: '本部院区', + 消化内科: '本部院区', + '麻醉科·精神科': '本部院区', + 医务处: '本部院区', + 医学影像科: '东院区', + 急诊科: '东院区', + 普外科: '西院区', + 血液科: '西院区', +} + +const MOCK_DOCUMENT_BY_ID = new Map(MOCK_DOCUMENTS.map((document) => [document.id, document])) + +/** 演示科室清单(筛选下拉用):只列演示数据里出现过的科室,选了必定有结果 */ +export const mockRecordDepartments: string[] = [...new Set(MOCK_DOCUMENTS.map((d) => d.department))] + +/** 演示文档类别清单(筛选下拉用) */ +export const mockRecordCategories: string[] = [ + ...new Set(MOCK_DOCUMENTS.map((document) => document.category)), +] + +/** + * 演示正文。 + * + * 记录页的「查看」要能打开当时的文书,所以演示记录必须带正文快照。 + * 正文由文书定义现场生成(含结构化占位符,签名位与签署日期都能按记录取值回显), + * 不是渲染时兜底拼出来的假纸样 —— 那种兜底只在正文真的取不到时才允许出现。 + * + * 惰性构建:buildFieldSpanHtml 依赖 DOM,模块在 Node 侧被 import 时不该直接抛错。 + */ +let mockDocumentHtmlCache: Map | null = null + +function buildMockDocumentHtml(document: MockDocumentDef): string { + const field = (id: string, label: string, kind: SigningFieldKind = 'text', required = false) => + buildFieldSpanHtml({ id, label, kind, required, options: [] }) + + return [ + `

${document.name}

`, + '

一、患者与就诊信息

', + '', + ``, + ``, + ``, + ``, + '
患者姓名${field('patient', '患者姓名', 'text', true)}性别${field('sex', '性别')}
年龄${field('age', '年龄')}住院号/门诊号${field('visit', '就诊号')}
', + '

二、风险告知与注意事项

', + `
    ${document.items.map((item) => `
  1. ${item}
  2. `).join('')}
`, + '

医务人员已就上述诊疗方案、医疗风险、替代方案及可能出现的不良后果向患者本人(或授权委托人)充分说明,患者已完全理解并表示同意。

', + '

三、签字确认

', + '', + ``, + ``, + ``, + '
患者(或授权委托人)签名:${field('signature', '患者签名', 'signature', true)}签署日期:${field('signedDate', '签署日期', 'date', true)}
经治医师签名:${field('doctor', '医师签名', 'signature')}
', + '

注:本知情同意书采用电子方式签署,签名图片与文书原件一并留存,可随时查验。

', + ].join('') +} + +function getMockDocumentHtml(documentId: string): string { + mockDocumentHtmlCache ??= new Map( + MOCK_DOCUMENTS.map((document) => [document.id, buildMockDocumentHtml(document)]), + ) + + return mockDocumentHtmlCache.get(documentId) ?? '' +} + +/* ========================================================================= + 演示数据:记录种子 + ========================================================================= */ + +/** + * 一条演示记录的种子。 + * + * 只写「人 + 事」:患者 ID、就诊号、院区、失效时间这些都能由这几项派生出来 + * (见 buildMockRecord),逐条手写既冗长又容易写错。 + * `days` 是距今天数(0 = 今天),演示数据因此始终落在「今日 / 近 3 天 / 近 7 天」窗口内, + * 不会像写死日期那样,过几天就全部落到筛选范围之外。 + */ +interface MockRecordSeed { + patient: string + sex: PatientSex + age: number + visitType: VisitType + /** MOCK_DOCUMENTS 的 id */ + doc: string + status: SigningTaskStatus + method: SigningMethod + days: number + /** 发起时刻 HH:mm */ + at: string + /** 签署用时(分钟):只有已签署的记录才有 */ + minutes?: number + source?: string + voidReason?: string +} + +const MOCK_RECORD_SEEDS: MockRecordSeed[] = [ + // 今天 + { patient: '赵美英', sex: '女', age: 58, visitType: '住院', doc: 'mock-doc-cardiac', status: 'signed', method: 'pad', days: 0, at: '09:12', minutes: 7 }, + { patient: '李淑芬', sex: '女', age: 67, visitType: '门诊', doc: 'mock-doc-mri', status: 'signed', method: 'sms', days: 0, at: '10:05', minutes: 4 }, + { patient: '周雅琴', sex: '女', age: 29, visitType: '门诊', doc: 'mock-doc-endoscopy', status: 'pending', method: 'sms', days: 0, at: '11:20' }, + // 昨天 + { patient: '孙浩然', sex: '男', age: 54, visitType: '住院', doc: 'mock-doc-narcotic', status: 'signed', method: 'pad', days: 1, at: '08:40', minutes: 6, source: '工作台发起' }, + { patient: '王建国', sex: '男', age: 71, visitType: '住院', doc: 'mock-doc-blood', status: 'signed', method: 'sms', days: 1, at: '14:15', minutes: 9 }, + { patient: '陈志强', sex: '男', age: 61, visitType: '住院', doc: 'mock-doc-admission', status: 'void', method: 'pad', days: 1, at: '16:02', source: '工作台发起', voidReason: '文书选择错误,已重新发起' }, + // 近 3 天 + { patient: '吴桂芳', sex: '女', age: 73, visitType: '住院', doc: 'mock-doc-surgery', status: 'signed', method: 'pad', days: 2, at: '09:35', minutes: 5 }, + { patient: '刘伟', sex: '男', age: 45, visitType: '门诊', doc: 'mock-doc-cardiac', status: 'signed', method: 'sms', days: 2, at: '15:48', minutes: 3, source: '工作台发起' }, + { patient: '郑海燕', sex: '女', age: 38, visitType: '门诊', doc: 'mock-doc-endoscopy', status: 'expired', method: 'sms', days: 3, at: '10:22' }, + { patient: '黄志明', sex: '男', age: 66, visitType: '住院', doc: 'mock-doc-mri', status: 'signed', method: 'pad', days: 3, at: '13:07', minutes: 8 }, + // 近 7 天 + { patient: '徐丽华', sex: '女', age: 50, visitType: '住院', doc: 'mock-doc-blood', status: 'signed', method: 'pad', days: 4, at: '11:40', minutes: 6 }, + { patient: '马晓东', sex: '男', age: 33, visitType: '其他', doc: 'mock-doc-emergency', status: 'failed', method: 'pad', days: 5, at: '20:18', source: '工作台发起' }, + { patient: '何秀英', sex: '女', age: 79, visitType: '住院', doc: 'mock-doc-surgery', status: 'signed', method: 'sms', days: 6, at: '09:05', minutes: 12 }, + { patient: '林俊杰', sex: '男', age: 41, visitType: '门诊', doc: 'mock-doc-narcotic', status: 'signing', method: 'pad', days: 6, at: '14:52' }, + // 近 30 天 + { patient: '罗美玲', sex: '女', age: 62, visitType: '门诊', doc: 'mock-doc-admission', status: 'expired', method: 'sms', days: 11, at: '17:30' }, + { patient: '高文斌', sex: '男', age: 57, visitType: '住院', doc: 'mock-doc-cardiac', status: 'signed', method: 'pad', days: 14, at: '10:10', minutes: 7 }, + { patient: '邓小燕', sex: '女', age: 36, visitType: '门诊', doc: 'mock-doc-mri', status: 'void', method: 'sms', days: 19, at: '08:55', voidReason: '患者拒绝签署,任务作废' }, + { patient: '苏国华', sex: '男', age: 68, visitType: '住院', doc: 'mock-doc-endoscopy', status: 'signed', method: 'pad', days: 26, at: '15:25', minutes: 6 }, +] + +function pad(value: number) { + return String(value).padStart(2, '0') +} + +function formatDateTime(date: Date) { + return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad( + date.getHours(), + )}:${pad(date.getMinutes())}` +} + +/** 演示数据的就诊号前缀:与医院里门诊 / 住院 / 体检的编号习惯一致 */ +const VISIT_NO_PREFIX: Record = { + 门诊: 'MZ', + 住院: 'ZY', + 体检: 'TJ', + 其他: 'JZ', +} + +function buildMockRecord(seed: MockRecordSeed, index: number): SigningRecord { + const document = MOCK_DOCUMENT_BY_ID.get(seed.doc) + + if (!document) { + throw new Error(`演示签署记录引用了不存在的文书:${seed.doc}`) + } + + const createdAtDate = new Date() + createdAtDate.setDate(createdAtDate.getDate() - seed.days) + const [hour, minute] = seed.at.split(':').map(Number) + createdAtDate.setHours(hour, minute, 0, 0) + + const createdAt = formatDateTime(createdAtDate) + const signedDate = new Date(createdAtDate) + signedDate.setMinutes(signedDate.getMinutes() + (seed.minutes ?? 0)) + const signedAt = seed.status === 'signed' ? formatDateTime(signedDate) : undefined + + // 已超时的记录按 24 小时失效,其余按 48 小时;失效时间只做展示,不参与筛选 + const expireHours = seed.status === 'expired' ? 24 : 48 + const expiresAtDate = new Date(createdAtDate) + expiresAtDate.setHours(expiresAtDate.getHours() + expireHours) + + const dateKey = `${createdAtDate.getFullYear()}${pad(createdAtDate.getMonth() + 1)}${pad( + createdAtDate.getDate(), + )}` + + return { + id: `SR${dateKey}${pad(index + 1)}`, + templateVersionId: `${seed.doc}-v1`, + campus: MOCK_DEPARTMENT_CAMPUS[document.department] ?? '本部院区', + patientId: `P2026${10001 + index}`, + patientName: seed.patient, + sex: seed.sex, + age: seed.age, + visitNo: `${VISIT_NO_PREFIX[seed.visitType]}${dateKey}${pad(index + 1)}`, + visitType: seed.visitType, + visitDate: `${createdAtDate.getFullYear()}-${pad(createdAtDate.getMonth() + 1)}-${pad( + createdAtDate.getDate(), + )}`, + documentId: document.id, + documentName: document.name, + category: document.category, + department: document.department, + signerName: seed.patient, + method: seed.method, + status: seed.status, + source: seed.source ?? '电子病历', + createdAt, + updatedAt: signedAt ?? createdAt, + signedAt, + expiresAt: formatDateTime(expiresAtDate), + voidReason: seed.voidReason, + // 已签署的记录带上签名图,否则「列表说已签署、打开没有签名」自相矛盾 + signatureDataUrl: seed.status === 'signed' ? DEMO_SIGNATURE_DATA_URL : undefined, + documentContentHtml: getMockDocumentHtml(document.id), + } +} + +/** 演示记录:种子派生一次,进程内复用(只读,不需要落盘) */ +let mockRecordsCache: SigningRecord[] | null = null + +function getMockRecords(): SigningRecord[] { + mockRecordsCache ??= MOCK_RECORD_SEEDS.map(buildMockRecord) + return mockRecordsCache +} + +function cloneRecord(record: SigningRecord): SigningRecord { + return { + ...record, + selectedItems: record.selectedItems ? [...record.selectedItems] : record.selectedItems, + fieldAnswers: record.fieldAnswers ? { ...record.fieldAnswers } : record.fieldAnswers, + } +} + +/* ========================================================================= + 查询 + ========================================================================= */ + +function parseRecordTime(value: string | undefined) { + if (!value) { + return null + } + + const date = new Date(value.replace(' ', 'T')) + return Number.isNaN(date.getTime()) ? null : date +} + +/** 时间窗口:`to` 为开区间上界(不含),只有「今日 / 昨日」需要 */ +function resolveDateWindow(range: SigningRecordQuery['dateRange']): { + from: Date + to: Date +} | null { + const todayStart = new Date() + todayStart.setHours(0, 0, 0, 0) + const tomorrowStart = new Date(todayStart) + tomorrowStart.setDate(tomorrowStart.getDate() + 1) + + const daysBack = (days: number) => { + const from = new Date(todayStart) + from.setDate(from.getDate() - days) + return from + } + + switch (range) { + case 'today': + return { from: todayStart, to: tomorrowStart } + case 'yesterday': + return { from: daysBack(1), to: todayStart } + case '3d': + return { from: daysBack(2), to: tomorrowStart } + case '7d': + return { from: daysBack(6), to: tomorrowStart } + case '30d': + return { from: daysBack(29), to: tomorrowStart } + default: + return null + } +} + +/** + * 记录查询条件 → 签署任务查询条件。 + * 签署记录就是签署任务的历史视图,真实模式直接复用工作台的列表接口, + * 因此这里只做字段映射;后端不支持的条件(文档类别)在页面层已禁用,不会走到这里。 + */ +function toTaskQuery(query: SigningRecordQuery): SigningTaskQuery { + return { + page: query.page, + pageSize: query.pageSize, + keyword: query.keyword, + status: query.status, + dateRange: query.dateRange, + campus: query.campus, + method: query.method, + department: query.department, + } +} + +function toRecord(task: SigningTaskRecord): SigningRecord { + // 任务接口不返回文档类别,这里如实留空,由页面沿用列表里带出的那一份 + return { ...task, category: '' } +} + +export async function getSigningRecords( + query: SigningRecordQuery, + options: SigningApiOptions = {}, +): Promise { + if (!useMockData) { + const page = await getSigningTasks(toTaskQuery(query), options) + + return { + records: page.records.map(toRecord), + total: page.total, + page: page.page, + pageSize: page.pageSize, + } + } + + const window = resolveDateWindow(query.dateRange) + const keyword = query.keyword?.trim().toLowerCase() + const filtered = getMockRecords().filter((record) => { + if (window) { + const createdAt = parseRecordTime(record.createdAt ?? record.updatedAt) + if (!createdAt || createdAt < window.from || createdAt >= window.to) { + return false + } + } + + if (query.status && query.status !== 'all' && record.status !== query.status) { + return false + } + + if (query.campus && query.campus !== 'all' && record.campus !== query.campus) { + return false + } + + if (query.method && query.method !== 'all' && record.method !== query.method) { + return false + } + + if (query.department && record.department !== query.department) { + return false + } + + if (query.category && record.category !== query.category) { + return false + } + + if (keyword) { + const haystack = [ + record.id, + record.patientName, + record.patientId, + record.visitNo, + record.documentName, + ] + .join(' ') + .toLowerCase() + + if (!haystack.includes(keyword)) { + return false + } + } + + return true + }) + + const sorted = filtered.sort( + (left, right) => + (parseRecordTime(right.createdAt)?.getTime() ?? 0) - + (parseRecordTime(left.createdAt)?.getTime() ?? 0), + ) + const page = Math.max(query.page, 1) + const pageSize = Math.max(query.pageSize, 1) + const start = (page - 1) * pageSize + + return { + records: sorted.slice(start, start + pageSize).map(cloneRecord), + total: sorted.length, + page, + pageSize, + } +} + +/** + * 逐页取回当前筛选条件下的全部记录,供导出使用。 + * 单次查询上限见 MAX_PAGES:宁可在演示/导出时截断并提示,也不要一条筛选条件 + * 意外把整个库拉进浏览器内存。 + */ +export async function getAllSigningRecords( + query: SigningRecordQuery, + options: SigningApiOptions = {}, +): Promise { + const firstPage = await getSigningRecords( + { ...query, page: 1, pageSize: BULK_PAGE_SIZE }, + options, + ) + const pageCount = Math.min( + Math.max(Math.ceil(firstPage.total / firstPage.pageSize), 1), + MAX_PAGES, + ) + + if (pageCount === 1) { + return firstPage.records + } + + const rest = await Promise.all( + Array.from({ length: pageCount - 1 }, (_, index) => + getSigningRecords({ ...query, page: index + 2, pageSize: BULK_PAGE_SIZE }, options), + ), + ) + + return [firstPage, ...rest].flatMap((page) => page.records) +} + +/** 记录详情:真实模式按任务接口取回正文快照与固化信息 */ +export async function getSigningRecordDetail( + id: string, + options: SigningApiOptions = {}, +): Promise { + if (!useMockData) { + const task = await getSigningTaskDetail(id, options) + return task ? toRecord(task) : null + } + + const record = getMockRecords().find((item) => item.id === id) + return record ? cloneRecord(record) : null +} + +/* ========================================================================= + 操作留痕 + ========================================================================= */ + +/** + * 演示留痕:按记录状态推导一条时间线。 + * + * 与签署工作台的演示留痕同口径(创建 → 发送 → 签署 / 作废 → 结束), + * 但时间点一律取记录自身固化的时间,不掺"现在"—— 历史记录的操作时间 + * 如果每次刷新都在变,就没法用来核对签署链路了。 + */ +function buildMockEvents(record: SigningRecord): SigningTaskEvent[] { + const channel = record.method === 'pad' ? '手写板' : '短信' + const createdAt = record.createdAt ?? record.updatedAt + const events: SigningTaskEvent[] = [ + { + id: `${record.id}-created`, + time: createdAt, + text: `张文静创建签署任务(${channel})`, + eventType: 'TASK_CREATED', + channel: record.method === 'pad' ? 'PAD' : 'SMS', + }, + ] + + if (record.method === 'sms') { + events.push({ + id: `${record.id}-sent`, + time: createdAt, + text: `已向 ${record.patientName} 预留手机号发送签署短信`, + eventType: 'SEND_PREPARED', + channel: 'SMS', + attemptNo: 1, + }) + } + + if (record.status === 'signing') { + events.push({ + id: `${record.id}-signing`, + time: record.updatedAt, + text: '已打开签名采集,等待患者完成签名', + eventType: 'SIGNING_STARTED', + channel: 'PAD', + }) + } + + if (record.signedAt) { + events.push({ + id: `${record.id}-signed`, + time: record.signedAt, + text: `患者 ${record.signerName} 完成签名,系统已合成文书并双留存`, + eventType: 'SIGNED', + channel: record.method === 'pad' ? 'PAD' : 'SMS', + }) + } + + if (record.status === 'expired') { + events.push({ + id: `${record.id}-expired`, + time: record.expiresAt, + text: '签署链接已过期,任务自动结束(可重新发起)', + eventType: 'EXPIRED', + }) + } + + if (record.status === 'failed') { + events.push({ + id: `${record.id}-failed`, + time: record.updatedAt, + text: '签名合成失败,请核对签名图与文书模板后重新发起', + eventType: 'FAILED', + reason: '签名合成失败', + }) + } + + if (record.status === 'void') { + events.push({ + id: `${record.id}-voided`, + time: record.updatedAt, + text: `任务已作废${record.voidReason ? `,原因:${record.voidReason}` : ''}`, + eventType: 'VOIDED', + reason: record.voidReason, + }) + } + + return events +} + +export async function getSigningRecordEvents(id: string): Promise { + if (!useMockData) { + return getSigningTaskEvents(id) + } + + const record = getMockRecords().find((item) => item.id === id) + return record ? buildMockEvents(record) : [] +} diff --git a/clinical-web/src/api/workbench/signing.ts b/clinical-web/src/api/workbench/signing.ts index 361c430..1b4e993 100644 --- a/clinical-web/src/api/workbench/signing.ts +++ b/clinical-web/src/api/workbench/signing.ts @@ -366,6 +366,8 @@ function getRangeStart(range: SigningTaskQuery['dateRange']) { date.setDate(date.getDate() - 2) } else if (range === '7d') { date.setDate(date.getDate() - 6) + } else if (range === '30d') { + date.setDate(date.getDate() - 29) } return date @@ -740,6 +742,8 @@ function toBackendTaskQuery(query: SigningTaskQuery, options: SigningApiOptions start.setDate(start.getDate() - 2) } else if (query.dateRange === '7d') { start.setDate(start.getDate() - 6) + } else if (query.dateRange === '30d') { + start.setDate(start.getDate() - 29) } if (query.dateRange && query.dateRange !== 'all') { diff --git a/clinical-web/src/api/workbench/types.ts b/clinical-web/src/api/workbench/types.ts index 3bab7a6..55cfe84 100644 --- a/clinical-web/src/api/workbench/types.ts +++ b/clinical-web/src/api/workbench/types.ts @@ -171,7 +171,12 @@ export interface SigningTaskQuery extends PageQuery { visitType?: VisitType | 'all' } -export type SigningDateRange = 'today' | 'yesterday' | '3d' | '7d' | 'all' +/** + * 时间范围筛选。 + * `30d` 只被「签署记录查询」用到(历史记录跨度比工作台大), + * 但两侧共用同一个联合类型与同一套区间换算,避免各写一份后口径漂移。 + */ +export type SigningDateRange = 'today' | 'yesterday' | '3d' | '7d' | '30d' | 'all' /** * 文书填写项的控件类型。 @@ -256,6 +261,34 @@ export interface SigningTaskRecord extends WorkbenchTask { export type SigningTaskListResponse = PageResult +/** + * 签署记录:签署任务的历史全量视图。 + * + * 字段沿用签署任务(同一份数据的另一种视角),额外带出「文档类别」: + * 历史记录可能引用已经停用甚至归档的模板版本,类别必须随记录一起固化下来, + * 不能靠当前文库反查——模板一下架,历史记录就会丢掉自己的分类。 + */ +export interface SigningRecord extends SigningTaskRecord { + category: string +} + +/** + * 签署记录查询条件。 + * 与工作台查询重叠但不等价:工作台按「当天待办」组织,这里按历史检索组织, + * 因此用「文档类别」替代了工作台的「具体文档」,关键词也扩到任务号。 + */ +export interface SigningRecordQuery extends PageQuery { + keyword?: string + status?: SigningTaskStatus | 'all' + dateRange?: SigningDateRange + campus?: WorkbenchCampus | 'all' + method?: SigningMethod | 'all' + department?: string + category?: string +} + +export type SigningRecordListResponse = PageResult + /** 页面提交模型。真实接口请求会在 API 边界转换为 CreateSigningTaskRequest。 */ export interface CreateSigningTaskInput { campus: WorkbenchCampus diff --git a/clinical-web/src/views/workbench/signing/components/SigningDocumentInteractive.vue b/clinical-web/src/components/signing/SigningDocumentInteractive.vue similarity index 100% rename from clinical-web/src/views/workbench/signing/components/SigningDocumentInteractive.vue rename to clinical-web/src/components/signing/SigningDocumentInteractive.vue diff --git a/clinical-web/src/views/workbench/signing/components/SigningDocumentPreview.vue b/clinical-web/src/components/signing/SigningDocumentPreview.vue similarity index 100% rename from clinical-web/src/views/workbench/signing/components/SigningDocumentPreview.vue rename to clinical-web/src/components/signing/SigningDocumentPreview.vue diff --git a/clinical-web/src/views/workbench/signing/components/TaskAuditTimeline.vue b/clinical-web/src/components/signing/TaskAuditTimeline.vue similarity index 100% rename from clinical-web/src/views/workbench/signing/components/TaskAuditTimeline.vue rename to clinical-web/src/components/signing/TaskAuditTimeline.vue diff --git a/clinical-web/src/utils/mock-flags.ts b/clinical-web/src/utils/mock-flags.ts index 26d8964..36cee09 100644 --- a/clinical-web/src/utils/mock-flags.ts +++ b/clinical-web/src/utils/mock-flags.ts @@ -31,6 +31,12 @@ export const mockFlags = { workbenchHome: resolveMockFlag(import.meta.env.VITE_MOCK_WORKBENCH_HOME, false), /** 签署工作台:签署任务、签署产物,以及仅用于签署留痕的审计日志 */ signing: resolveMockFlag(import.meta.env.VITE_MOCK_SIGNING, true), + /** + * 签署记录查询:历史签署记录检索。 + * 缺省开着:后端还没有「按条件检索历史签署记录」的接口(签署记录查询是独立菜单, + * 不与签署工作台共用一个开关,否则关掉工作台演示数据会把记录页一起带进空态)。 + */ + signRecords: resolveMockFlag(import.meta.env.VITE_MOCK_SIGN_RECORDS, true), /** 文档库管理:文书库与文书模板接口 */ documents: resolveMockFlag(import.meta.env.VITE_MOCK_DOCUMENTS, false), /** 文档权限:模板权限配置与权限矩阵 */ diff --git a/clinical-web/src/utils/signing-document.ts b/clinical-web/src/utils/signing-document.ts index d44ad7c..a0f8587 100644 --- a/clinical-web/src/utils/signing-document.ts +++ b/clinical-web/src/utils/signing-document.ts @@ -880,6 +880,19 @@ function readHostText(host: HTMLElement): string { * 两者都放在本文件里,属性名与判据只有一份——签署端不该自己写 data-sign-* 选择器。 */ export function applyAnswerToFieldHost(host: HTMLElement, value: SigningFieldValue): void { + // 签名位的取值是图片 dataURL,必须写成 。 + // 少了这一分支它会掉到下面的文本分支,把整串 base64 当正文塞进宿主, + // 页面上就会出现一长串 data:image/png;base64,… 而不是签名图 —— + // 记录页「查看」与签署工作台详情读的都是这条路径,两边会一起中招。 + if (host.getAttribute(FIELD_KIND_ATTR) === 'signature') { + // 空值不用动:渲染阶段已经落了「未签署」提示,这里没有可写的东西 + if (typeof value === 'string' && value) { + writeSignatureToHost(host, value, host.getAttribute(FIELD_ATTR) ?? '') + } + + return + } + const items = Array.from(host.querySelectorAll(`.${OPTION_CLASS}`)) const multiAttr = host.getAttribute(FIELD_MULTI_ATTR) @@ -988,7 +1001,7 @@ export function applySelectedItemsToRoot(root: HTMLElement, items: string[]): vo } /** - * 把签名图写进指定签名位,并摘掉它的采集入口。返回是否真的发生了改动。 + * 把签名图写进**已经拿到手的**签名位宿主,并摘掉它的采集入口。返回是否真的发生了写入。 * * 只改这一个宿主节点,不重建整篇文书 —— 整篇重建会换掉 v-html 的 DOM, * 操作人员正在输入的填写项会连同光标一起丢。写完后由调用方回读 DOM @@ -996,6 +1009,39 @@ export function applySelectedItemsToRoot(root: HTMLElement, items: string[]): vo * * 签名图只作为 写进渲染结果,不进模板正文: * 它是 base64 大字符串,进正文会让模板哈希随签名变化,缓存键跟着失效。 + * + * 宿主级实现,供两条路径共用:applySignatureToRoot(只拿到根,按字段键找宿主) + * 与 applyAnswerToFieldHost(宿主就在手上)。签名图的 DOM 形态只能有一份定义, + * 否则「点出来的签名」与「读回来的签名」会长得不一样。 + */ +function writeSignatureToHost(host: HTMLElement, dataUrl: string, altFallback = ''): boolean { + // 同一张图重复写入没有意义,返回 false 也让调用方少跑一轮回读 + if (host.querySelector(`img.${SIGNATURE_IMAGE_CLASS}`)?.getAttribute('src') === dataUrl) { + return false + } + + const label = host.getAttribute(FIELD_LABEL_ATTR) ?? '' + const image = host.ownerDocument.createElement('img') + image.className = SIGNATURE_IMAGE_CLASS + image.src = dataUrl + image.alt = label || altFallback + // 签名位填充后只剩签名图:原型不保留「未签署」提示,也不再有点击提示 + host.replaceChildren(image) + host.title = label + + // 采集入口用完即摘:留着会让「还能再点一次」的观感落空 + host.classList.remove(ACTIONABLE_CLASS, 'sign-field--missing') + host.removeAttribute(FIELD_ACTION_ATTR) + host.removeAttribute('role') + host.removeAttribute('tabindex') + + return true +} + +/** + * 按字段键在根节点里找到签名位,把签名图写进去。返回是否真的发生了改动。 + * + * 与 applyAnswerToFieldHost 共用 writeSignatureToHost:签名图的 DOM 形态只有一份定义。 */ export function applySignatureToRoot(root: HTMLElement, fieldId: string, dataUrl: string): boolean { if (!fieldId || !dataUrl) { @@ -1008,26 +1054,7 @@ export function applySignatureToRoot(root: HTMLElement, fieldId: string, dataUrl return false } - // 同一张图重复写入没有意义,返回 false 也让调用方少跑一轮回读 - if (host.querySelector(`img.${SIGNATURE_IMAGE_CLASS}`)?.getAttribute('src') === dataUrl) { - return false - } - - const image = root.ownerDocument.createElement('img') - image.className = SIGNATURE_IMAGE_CLASS - image.src = dataUrl - image.alt = host.getAttribute(FIELD_LABEL_ATTR) ?? fieldId - // 签名位填充后只剩签名图:原型不保留「未签署」提示,也不再有点击提示 - host.replaceChildren(image) - host.title = host.getAttribute(FIELD_LABEL_ATTR) ?? '' - - // 采集入口用完即摘:留着会让「还能再点一次」的观感落空 - host.classList.remove(ACTIONABLE_CLASS, 'sign-field--missing') - host.removeAttribute(FIELD_ACTION_ATTR) - host.removeAttribute('role') - host.removeAttribute('tabindex') - - return true + return writeSignatureToHost(host, dataUrl, fieldId) } /** diff --git a/clinical-web/src/views/management/MenuResourceView.vue b/clinical-web/src/views/management/MenuResourceView.vue index e288140..8782f62 100644 --- a/clinical-web/src/views/management/MenuResourceView.vue +++ b/clinical-web/src/views/management/MenuResourceView.vue @@ -12,6 +12,7 @@ const loginStore = useLoginStore() const componentRegistry: Record = { 'dashboard/index': defineAsyncComponent(() => import('@/views/workbench/home/index.vue')), 'sign/workbench/index': defineAsyncComponent(() => import('@/views/workbench/signing/index.vue')), + 'sign/record/index': defineAsyncComponent(() => import('@/views/workbench/records/index.vue')), 'system/org/index': defineAsyncComponent(() => import('@/views/management/users/index.vue')), 'system/role/index': defineAsyncComponent(() => import('@/views/management/roles/index.vue')), 'doc/library/index': defineAsyncComponent(() => import('@/views/management/documents/index.vue')), diff --git a/clinical-web/src/views/workbench/records/components/SigningRecordDetailDialog.vue b/clinical-web/src/views/workbench/records/components/SigningRecordDetailDialog.vue new file mode 100644 index 0000000..a579b20 --- /dev/null +++ b/clinical-web/src/views/workbench/records/components/SigningRecordDetailDialog.vue @@ -0,0 +1,404 @@ + + + + + diff --git a/clinical-web/src/views/workbench/records/components/SigningRecordFilterBar.vue b/clinical-web/src/views/workbench/records/components/SigningRecordFilterBar.vue new file mode 100644 index 0000000..1633447 --- /dev/null +++ b/clinical-web/src/views/workbench/records/components/SigningRecordFilterBar.vue @@ -0,0 +1,221 @@ + + + + + diff --git a/clinical-web/src/views/workbench/records/components/SigningRecordTable.vue b/clinical-web/src/views/workbench/records/components/SigningRecordTable.vue new file mode 100644 index 0000000..4edf57d --- /dev/null +++ b/clinical-web/src/views/workbench/records/components/SigningRecordTable.vue @@ -0,0 +1,340 @@ + + + + + diff --git a/clinical-web/src/views/workbench/records/index.vue b/clinical-web/src/views/workbench/records/index.vue new file mode 100644 index 0000000..84a7d72 --- /dev/null +++ b/clinical-web/src/views/workbench/records/index.vue @@ -0,0 +1,598 @@ + + + + + diff --git a/clinical-web/src/views/workbench/records/types.ts b/clinical-web/src/views/workbench/records/types.ts new file mode 100644 index 0000000..b7dfe0f --- /dev/null +++ b/clinical-web/src/views/workbench/records/types.ts @@ -0,0 +1,42 @@ +import type { + SigningDateRange, + SigningMethod, + SigningRecord, + SigningTaskStatus, + WorkbenchCampus, +} from '@/api/workbench/types' + +export type SigningRecordFilterStatus = 'all' | SigningTaskStatus + +/** + * 页面筛选表单。 + * 与查询条件的差别只有一处:把「未选择」显式写成 `all` / 空串, + * 让下拉与输入框直接 v-model 到具体值,不出现 undefined 造成的空选项。 + */ +export interface SigningRecordFilterForm { + dateRange: SigningDateRange + campus: WorkbenchCampus | 'all' + department: string + category: string + method: SigningMethod | 'all' + status: SigningRecordFilterStatus + keyword: string +} + +export interface SigningRecordFilterOption { + label: string + value: T +} + +/** 表格行就是记录本身:记录页没有「选中后展开」的二级结构,不需要再包一层 */ +export type SigningRecordRow = SigningRecord + +/** + * 导出列定义。 + * 表头文字与取值函数放在一起:导出文件里的字段一旦和页面表格两处各写一份, + * 改了页面忘了改导出,是这类页面最常见的不一致。 + */ +export interface SigningRecordExportColumn { + label: string + value: (record: SigningRecord) => string +} diff --git a/clinical-web/src/views/workbench/signing/components/SigningTaskDetail.vue b/clinical-web/src/views/workbench/signing/components/SigningTaskDetail.vue index b9653d6..0da227e 100644 --- a/clinical-web/src/views/workbench/signing/components/SigningTaskDetail.vue +++ b/clinical-web/src/views/workbench/signing/components/SigningTaskDetail.vue @@ -9,10 +9,10 @@ import type { } from '@/api/workbench/types' import type { SigningFieldProgress, SigningSignatureRequest } from '@/utils/signing-document' -import SigningDocumentInteractive from './SigningDocumentInteractive.vue' -import SigningDocumentPreview from './SigningDocumentPreview.vue' +import SigningDocumentInteractive from '@/components/signing/SigningDocumentInteractive.vue' +import SigningDocumentPreview from '@/components/signing/SigningDocumentPreview.vue' +import TaskAuditTimeline from '@/components/signing/TaskAuditTimeline.vue' import SigningArtifactList from './SigningArtifactList.vue' -import TaskAuditTimeline from './TaskAuditTimeline.vue' import type { SigningTaskAction } from '../types' defineProps<{