feat(签署记录): 新增签署记录查询页并复用签署任务接口
This commit is contained in:
@@ -11,6 +11,8 @@ VITE_API_BASE_URL=/api
|
|||||||
# 「接口报错」或「数据不对」,排查成本很高。
|
# 「接口报错」或「数据不对」,排查成本很高。
|
||||||
# VITE_MOCK_WORKBENCH_HOME=false
|
# VITE_MOCK_WORKBENCH_HOME=false
|
||||||
# VITE_MOCK_SIGNING=true
|
# VITE_MOCK_SIGNING=true
|
||||||
|
# 签署记录查询:后端暂无历史记录检索接口,缺省走演示数据
|
||||||
|
# VITE_MOCK_SIGN_RECORDS=true
|
||||||
# VITE_MOCK_DOCUMENTS=false
|
# VITE_MOCK_DOCUMENTS=false
|
||||||
# VITE_MOCK_DOCUMENT_PERMISSIONS=false
|
# VITE_MOCK_DOCUMENT_PERMISSIONS=false
|
||||||
# VITE_MOCK_REPORTS=false
|
# VITE_MOCK_REPORTS=false
|
||||||
|
|||||||
+23
-3
@@ -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`,签署端用这个哈希当缓存键去换回正文快照(同一哈希命中同一份正文,版本更新后哈希变化即自动失效),避免版本更新后仍按旧正文签署。取不到正文时任务详情如实提示并给出占位版式,不退化成「看起来像真文书」的假纸样。
|
签署工作台的真实模式同样依赖正文:`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(实际导航和页面路由由授权菜单资源树提供)
|
ClinicalLayout(实际导航和页面路由由授权菜单资源树提供)
|
||||||
├─ /dashboard 首页
|
├─ /dashboard 首页
|
||||||
├─ /sign/workbench 签署工作台
|
├─ /sign/workbench 签署工作台
|
||||||
|
├─ /sign/records 签署记录查询
|
||||||
├─ /system/org 组织架构 / 员工角色绑定
|
├─ /system/org 组织架构 / 员工角色绑定
|
||||||
├─ /system/role 角色权限与数据范围
|
├─ /system/role 角色权限与数据范围
|
||||||
├─ /doc/library 文档库管理
|
├─ /doc/library 文档库管理
|
||||||
@@ -119,6 +122,9 @@ ClinicalLayout(实际导航和页面路由由授权菜单资源树提供)
|
|||||||
跨页面复用的业务组件和 composable 不放在具体 View 目录中:
|
跨页面复用的业务组件和 composable 不放在具体 View 目录中:
|
||||||
|
|
||||||
- `components/signing/NewSigningTaskDialog.vue`:新增签署任务公共弹窗,供签署工作台和文档库管理复用;
|
- `components/signing/NewSigningTaskDialog.vue`:新增签署任务公共弹窗,供签署工作台和文档库管理复用;
|
||||||
|
- `components/signing/SigningDocumentInteractive.vue`:签署态文书的交互化渲染(填写项回显、签名图回填、勾选收集),签署工作台详情与签署记录详情共用;
|
||||||
|
- `components/signing/SigningDocumentPreview.vue`:正文取不到时的占位版式,不冒充真文书;
|
||||||
|
- `components/signing/TaskAuditTimeline.vue`:操作留痕时间线,签署工作台详情与签署记录详情共用;
|
||||||
- `composables/useSigningTaskForm.ts`:封装患者定位、就诊选择、模板选择、签署方式、校验和任务提交状态;
|
- `composables/useSigningTaskForm.ts`:封装患者定位、就诊选择、模板选择、签署方式、校验和任务提交状态;
|
||||||
- `utils/signing-document.ts`:把文书定稿 HTML 变换为签署态 HTML 的渲染管线(占位符填充、签名域、勾选与行内选项识别),签署工作台的任务详情和文档库「签署预览」共用同一份实现;
|
- `utils/signing-document.ts`:把文书定稿 HTML 变换为签署态 HTML 的渲染管线(占位符填充、签名域、勾选与行内选项识别),签署工作台的任务详情和文档库「签署预览」共用同一份实现;
|
||||||
- `utils/signing-editor-marks.ts`:编辑区里把占位符与勾选框渲染成带类型角标的胶囊,只改渲染不改数据(见下「文书编写约定」)。
|
- `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)。
|
首页真实模式已接入 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 请求和响应类型按业务域放在 `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 开发服务。
|
开发环境默认使用 `/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`。
|
当前尚未接入的签署能力包括手写板设备桥接、线上签署页面/签名回调和打印服务。签署投递、一次性 Token 消费和真实 PNG 上传 API 已完成封装,但页面不能用 Canvas 演示数据冒充真实签名;需要接入设备适配器或患者 H5 回调后再启用。短信初次发送接口需要完整手机号,而患者查询只返回脱敏手机号,因此真实模式会要求当前操作人员确认完整投递地址;生产环境也可以改为由后端根据患者 ID 解析投递地址。签署工作台缺省即使用演示数据(`VITE_MOCK_SIGNING`),需要走真实投递时把它设为 `false`。
|
||||||
|
|||||||
@@ -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<string, WorkbenchCampus> = {
|
||||||
|
心内科: '本部院区',
|
||||||
|
消化内科: '本部院区',
|
||||||
|
'麻醉科·精神科': '本部院区',
|
||||||
|
医务处: '本部院区',
|
||||||
|
医学影像科: '东院区',
|
||||||
|
急诊科: '东院区',
|
||||||
|
普外科: '西院区',
|
||||||
|
血液科: '西院区',
|
||||||
|
}
|
||||||
|
|
||||||
|
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<string, string> | 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 [
|
||||||
|
`<h2>${document.name}</h2>`,
|
||||||
|
'<h3>一、患者与就诊信息</h3>',
|
||||||
|
'<table><tbody>',
|
||||||
|
`<tr><td>患者姓名</td><td>${field('patient', '患者姓名', 'text', true)}</td>`,
|
||||||
|
`<td>性别</td><td>${field('sex', '性别')}</td></tr>`,
|
||||||
|
`<tr><td>年龄</td><td>${field('age', '年龄')}</td>`,
|
||||||
|
`<td>住院号/门诊号</td><td>${field('visit', '就诊号')}</td></tr>`,
|
||||||
|
'</tbody></table>',
|
||||||
|
'<h3>二、风险告知与注意事项</h3>',
|
||||||
|
`<ol>${document.items.map((item) => `<li>${item}</li>`).join('')}</ol>`,
|
||||||
|
'<p>医务人员已就上述诊疗方案、医疗风险、替代方案及可能出现的不良后果向患者本人(或授权委托人)充分说明,患者已完全理解并表示同意。</p>',
|
||||||
|
'<h3>三、签字确认</h3>',
|
||||||
|
'<table><tbody>',
|
||||||
|
`<tr><td>患者(或授权委托人)签名:${field('signature', '患者签名', 'signature', true)}</td>`,
|
||||||
|
`<td>签署日期:${field('signedDate', '签署日期', 'date', true)}</td></tr>`,
|
||||||
|
`<tr><td>经治医师签名:${field('doctor', '医师签名', 'signature')}</td><td></td></tr>`,
|
||||||
|
'</tbody></table>',
|
||||||
|
'<p>注:本知情同意书采用电子方式签署,签名图片与文书原件一并留存,可随时查验。</p>',
|
||||||
|
].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<VisitType, string> = {
|
||||||
|
门诊: '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<SigningRecordListResponse> {
|
||||||
|
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<SigningRecord[]> {
|
||||||
|
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<SigningRecord | null> {
|
||||||
|
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<SigningTaskEvent[]> {
|
||||||
|
if (!useMockData) {
|
||||||
|
return getSigningTaskEvents(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
const record = getMockRecords().find((item) => item.id === id)
|
||||||
|
return record ? buildMockEvents(record) : []
|
||||||
|
}
|
||||||
@@ -366,6 +366,8 @@ function getRangeStart(range: SigningTaskQuery['dateRange']) {
|
|||||||
date.setDate(date.getDate() - 2)
|
date.setDate(date.getDate() - 2)
|
||||||
} else if (range === '7d') {
|
} else if (range === '7d') {
|
||||||
date.setDate(date.getDate() - 6)
|
date.setDate(date.getDate() - 6)
|
||||||
|
} else if (range === '30d') {
|
||||||
|
date.setDate(date.getDate() - 29)
|
||||||
}
|
}
|
||||||
|
|
||||||
return date
|
return date
|
||||||
@@ -740,6 +742,8 @@ function toBackendTaskQuery(query: SigningTaskQuery, options: SigningApiOptions
|
|||||||
start.setDate(start.getDate() - 2)
|
start.setDate(start.getDate() - 2)
|
||||||
} else if (query.dateRange === '7d') {
|
} else if (query.dateRange === '7d') {
|
||||||
start.setDate(start.getDate() - 6)
|
start.setDate(start.getDate() - 6)
|
||||||
|
} else if (query.dateRange === '30d') {
|
||||||
|
start.setDate(start.getDate() - 29)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (query.dateRange && query.dateRange !== 'all') {
|
if (query.dateRange && query.dateRange !== 'all') {
|
||||||
|
|||||||
@@ -171,7 +171,12 @@ export interface SigningTaskQuery extends PageQuery {
|
|||||||
visitType?: VisitType | 'all'
|
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<SigningTaskRecord>
|
export type SigningTaskListResponse = PageResult<SigningTaskRecord>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 签署记录:签署任务的历史全量视图。
|
||||||
|
*
|
||||||
|
* 字段沿用签署任务(同一份数据的另一种视角),额外带出「文档类别」:
|
||||||
|
* 历史记录可能引用已经停用甚至归档的模板版本,类别必须随记录一起固化下来,
|
||||||
|
* 不能靠当前文库反查——模板一下架,历史记录就会丢掉自己的分类。
|
||||||
|
*/
|
||||||
|
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<SigningRecord>
|
||||||
|
|
||||||
/** 页面提交模型。真实接口请求会在 API 边界转换为 CreateSigningTaskRequest。 */
|
/** 页面提交模型。真实接口请求会在 API 边界转换为 CreateSigningTaskRequest。 */
|
||||||
export interface CreateSigningTaskInput {
|
export interface CreateSigningTaskInput {
|
||||||
campus: WorkbenchCampus
|
campus: WorkbenchCampus
|
||||||
|
|||||||
@@ -31,6 +31,12 @@ export const mockFlags = {
|
|||||||
workbenchHome: resolveMockFlag(import.meta.env.VITE_MOCK_WORKBENCH_HOME, false),
|
workbenchHome: resolveMockFlag(import.meta.env.VITE_MOCK_WORKBENCH_HOME, false),
|
||||||
/** 签署工作台:签署任务、签署产物,以及仅用于签署留痕的审计日志 */
|
/** 签署工作台:签署任务、签署产物,以及仅用于签署留痕的审计日志 */
|
||||||
signing: resolveMockFlag(import.meta.env.VITE_MOCK_SIGNING, true),
|
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),
|
documents: resolveMockFlag(import.meta.env.VITE_MOCK_DOCUMENTS, false),
|
||||||
/** 文档权限:模板权限配置与权限矩阵 */
|
/** 文档权限:模板权限配置与权限矩阵 */
|
||||||
|
|||||||
@@ -880,6 +880,19 @@ function readHostText(host: HTMLElement): string {
|
|||||||
* 两者都放在本文件里,属性名与判据只有一份——签署端不该自己写 data-sign-* 选择器。
|
* 两者都放在本文件里,属性名与判据只有一份——签署端不该自己写 data-sign-* 选择器。
|
||||||
*/
|
*/
|
||||||
export function applyAnswerToFieldHost(host: HTMLElement, value: SigningFieldValue): void {
|
export function applyAnswerToFieldHost(host: HTMLElement, value: SigningFieldValue): void {
|
||||||
|
// 签名位的取值是图片 dataURL,必须写成 <img>。
|
||||||
|
// 少了这一分支它会掉到下面的文本分支,把整串 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<HTMLElement>(`.${OPTION_CLASS}`))
|
const items = Array.from(host.querySelectorAll<HTMLElement>(`.${OPTION_CLASS}`))
|
||||||
const multiAttr = host.getAttribute(FIELD_MULTI_ATTR)
|
const multiAttr = host.getAttribute(FIELD_MULTI_ATTR)
|
||||||
|
|
||||||
@@ -988,7 +1001,7 @@ export function applySelectedItemsToRoot(root: HTMLElement, items: string[]): vo
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 把签名图写进指定签名位,并摘掉它的采集入口。返回是否真的发生了改动。
|
* 把签名图写进**已经拿到手的**签名位宿主,并摘掉它的采集入口。返回是否真的发生了写入。
|
||||||
*
|
*
|
||||||
* 只改这一个宿主节点,不重建整篇文书 —— 整篇重建会换掉 v-html 的 DOM,
|
* 只改这一个宿主节点,不重建整篇文书 —— 整篇重建会换掉 v-html 的 DOM,
|
||||||
* 操作人员正在输入的填写项会连同光标一起丢。写完后由调用方回读 DOM
|
* 操作人员正在输入的填写项会连同光标一起丢。写完后由调用方回读 DOM
|
||||||
@@ -996,6 +1009,39 @@ export function applySelectedItemsToRoot(root: HTMLElement, items: string[]): vo
|
|||||||
*
|
*
|
||||||
* 签名图只作为 <img src> 写进渲染结果,不进模板正文:
|
* 签名图只作为 <img src> 写进渲染结果,不进模板正文:
|
||||||
* 它是 base64 大字符串,进正文会让模板哈希随签名变化,缓存键跟着失效。
|
* 它是 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 {
|
export function applySignatureToRoot(root: HTMLElement, fieldId: string, dataUrl: string): boolean {
|
||||||
if (!fieldId || !dataUrl) {
|
if (!fieldId || !dataUrl) {
|
||||||
@@ -1008,26 +1054,7 @@ export function applySignatureToRoot(root: HTMLElement, fieldId: string, dataUrl
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// 同一张图重复写入没有意义,返回 false 也让调用方少跑一轮回读
|
return writeSignatureToHost(host, dataUrl, fieldId)
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ const loginStore = useLoginStore()
|
|||||||
const componentRegistry: Record<string, Component> = {
|
const componentRegistry: Record<string, Component> = {
|
||||||
'dashboard/index': defineAsyncComponent(() => import('@/views/workbench/home/index.vue')),
|
'dashboard/index': defineAsyncComponent(() => import('@/views/workbench/home/index.vue')),
|
||||||
'sign/workbench/index': defineAsyncComponent(() => import('@/views/workbench/signing/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/org/index': defineAsyncComponent(() => import('@/views/management/users/index.vue')),
|
||||||
'system/role/index': defineAsyncComponent(() => import('@/views/management/roles/index.vue')),
|
'system/role/index': defineAsyncComponent(() => import('@/views/management/roles/index.vue')),
|
||||||
'doc/library/index': defineAsyncComponent(() => import('@/views/management/documents/index.vue')),
|
'doc/library/index': defineAsyncComponent(() => import('@/views/management/documents/index.vue')),
|
||||||
|
|||||||
@@ -0,0 +1,404 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue'
|
||||||
|
|
||||||
|
import type { SigningTaskEvent, SigningTaskStatus } from '@/api/workbench/types'
|
||||||
|
import SigningDocumentInteractive from '@/components/signing/SigningDocumentInteractive.vue'
|
||||||
|
import SigningDocumentPreview from '@/components/signing/SigningDocumentPreview.vue'
|
||||||
|
import TaskAuditTimeline from '@/components/signing/TaskAuditTimeline.vue'
|
||||||
|
|
||||||
|
import type { SigningRecordRow } from '../types'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
visible: boolean
|
||||||
|
record: SigningRecordRow | null
|
||||||
|
loading: boolean
|
||||||
|
events: SigningTaskEvent[]
|
||||||
|
eventsLoading: boolean
|
||||||
|
eventsError: boolean
|
||||||
|
canDownload: boolean
|
||||||
|
downloading: boolean
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
close: []
|
||||||
|
downloadPdf: []
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const statusLabels: Record<SigningTaskStatus, string> = {
|
||||||
|
pending: '待签署',
|
||||||
|
signing: '签署中',
|
||||||
|
signed: '已签署',
|
||||||
|
expired: '已超时',
|
||||||
|
void: '已作废',
|
||||||
|
failed: '处理失败',
|
||||||
|
}
|
||||||
|
|
||||||
|
const documentRef = ref<InstanceType<typeof SigningDocumentInteractive> | null>(null)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 供页面导出 PDF 使用:把内嵌的文书容器透出去。
|
||||||
|
* 与签署工作台的详情组件同口径——导出的 PDF 必须是页面上看到的这一份渲染结果,
|
||||||
|
* 由页面自己去取元素,而不是在组件里另拼一份文书。
|
||||||
|
*/
|
||||||
|
function getDocumentElement() {
|
||||||
|
return documentRef.value?.getDocumentElement() ?? null
|
||||||
|
}
|
||||||
|
|
||||||
|
defineExpose({ getDocumentElement })
|
||||||
|
|
||||||
|
function handleVisibleChange(value: boolean) {
|
||||||
|
if (!value) {
|
||||||
|
emit('close')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function value(value: string | undefined) {
|
||||||
|
return value || '—'
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<el-dialog
|
||||||
|
:model-value="props.visible"
|
||||||
|
width="1120px"
|
||||||
|
top="5vh"
|
||||||
|
class="record-dialog"
|
||||||
|
destroy-on-close
|
||||||
|
:close-on-click-modal="false"
|
||||||
|
@update:model-value="handleVisibleChange"
|
||||||
|
>
|
||||||
|
<template #header>
|
||||||
|
<div class="dialog-header">
|
||||||
|
<div class="dialog-header__title">
|
||||||
|
<strong>{{ record?.documentName ?? '签署记录详情' }}</strong>
|
||||||
|
<span v-if="record" class="dialog-header__meta">
|
||||||
|
{{ record.id }} · {{ record.patientName }} · {{ value(record.campus) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<span v-if="record" class="status-tag" :class="`status-${record.status}`">
|
||||||
|
{{ statusLabels[record.status] }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<div v-if="loading" class="dialog-state">正在加载记录详情…</div>
|
||||||
|
<div v-else-if="!record" class="dialog-state">未能读取该签署记录,请稍后重试。</div>
|
||||||
|
|
||||||
|
<div v-else class="record-detail">
|
||||||
|
<aside class="record-detail__side">
|
||||||
|
<section class="info-block">
|
||||||
|
<h4>记录信息</h4>
|
||||||
|
<dl>
|
||||||
|
<div><dt>任务号</dt><dd>{{ record.id }}</dd></div>
|
||||||
|
<div><dt>文档类别</dt><dd>{{ value(record.category) }}</dd></div>
|
||||||
|
<div><dt>科室</dt><dd>{{ value(record.department) }}</dd></div>
|
||||||
|
<div><dt>院区</dt><dd>{{ value(record.campus) }}</dd></div>
|
||||||
|
<div>
|
||||||
|
<dt>签署方式</dt>
|
||||||
|
<dd>{{ record.method === 'pad' ? '✍️ 手写板' : '📱 短信' }}</dd>
|
||||||
|
</div>
|
||||||
|
<div><dt>发起时间</dt><dd>{{ value(record.createdAt) }}</dd></div>
|
||||||
|
<div><dt>签署完成</dt><dd>{{ value(record.signedAt) }}</dd></div>
|
||||||
|
<div><dt>失效时间</dt><dd>{{ value(record.expiresAt) }}</dd></div>
|
||||||
|
<div v-if="record.voidReason" class="info-block__wide">
|
||||||
|
<dt>作废原因</dt><dd>{{ record.voidReason }}</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="info-block">
|
||||||
|
<h4>患者与就诊</h4>
|
||||||
|
<dl>
|
||||||
|
<div><dt>患者</dt><dd>{{ record.patientName }}({{ record.sex }} · {{ record.age }}岁)</dd></div>
|
||||||
|
<div><dt>患者 ID</dt><dd>{{ record.patientId }}</dd></div>
|
||||||
|
<div><dt>就诊号</dt><dd>{{ record.visitNo }}</dd></div>
|
||||||
|
<div><dt>就诊类型</dt><dd>{{ record.visitType }}</dd></div>
|
||||||
|
<div><dt>就诊日期</dt><dd>{{ value(record.visitDate) }}</dd></div>
|
||||||
|
<div><dt>任务来源</dt><dd>{{ value(record.source) }}</dd></div>
|
||||||
|
</dl>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="info-block">
|
||||||
|
<h4>签署证据</h4>
|
||||||
|
<div class="signature-box">
|
||||||
|
<img v-if="record.signatureDataUrl" :src="record.signatureDataUrl" alt="签署人签名" />
|
||||||
|
<span v-else-if="record.status === 'void'">任务已作废,签名不保留</span>
|
||||||
|
<span v-else>未采集签名</span>
|
||||||
|
</div>
|
||||||
|
<dl>
|
||||||
|
<div><dt>签署人</dt><dd>{{ value(record.signerName) }}</dd></div>
|
||||||
|
<div class="info-block__wide">
|
||||||
|
<dt>正文哈希</dt>
|
||||||
|
<dd class="mono">{{ value(record.contentSha256) }}</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<TaskAuditTimeline
|
||||||
|
:events="props.events"
|
||||||
|
:loading="props.eventsLoading"
|
||||||
|
:error="props.eventsError"
|
||||||
|
/>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<main class="record-detail__doc">
|
||||||
|
<SigningDocumentInteractive
|
||||||
|
v-if="record.documentContentHtml"
|
||||||
|
ref="documentRef"
|
||||||
|
:task="record"
|
||||||
|
/>
|
||||||
|
<SigningDocumentPreview v-else :task="record" />
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template #footer>
|
||||||
|
<div class="dialog-footer">
|
||||||
|
<span class="dialog-footer__note">
|
||||||
|
记录内容以服务端留存的签署证据为准,页面展示不作为签署效力判据。
|
||||||
|
</span>
|
||||||
|
<button type="button" class="action-button action-button--ghost" @click="emit('close')">
|
||||||
|
关闭
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="action-button"
|
||||||
|
:disabled="!canDownload || downloading || !record"
|
||||||
|
@click="emit('downloadPdf')"
|
||||||
|
>
|
||||||
|
{{ downloading ? '生成中…' : '下载签署后 PDF' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.dialog-header {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-header__title {
|
||||||
|
display: flex;
|
||||||
|
min-width: 0;
|
||||||
|
flex: 1;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-header__title strong {
|
||||||
|
overflow: hidden;
|
||||||
|
color: var(--ink);
|
||||||
|
font-size: 15px;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-header__meta {
|
||||||
|
color: var(--mut);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-tag {
|
||||||
|
flex-shrink: 0;
|
||||||
|
padding: 3px 11px;
|
||||||
|
font-size: 11.5px;
|
||||||
|
font-weight: 700;
|
||||||
|
border-radius: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-signed {
|
||||||
|
color: var(--ok);
|
||||||
|
background: var(--ok-l);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-pending {
|
||||||
|
color: var(--brand);
|
||||||
|
background: var(--brand-l);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-signing {
|
||||||
|
color: var(--info);
|
||||||
|
background: var(--info-l);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-expired {
|
||||||
|
color: var(--err);
|
||||||
|
background: var(--err-l);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-void {
|
||||||
|
color: #8a9aa3;
|
||||||
|
background: #eceef0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-failed {
|
||||||
|
color: #a15c19;
|
||||||
|
background: #fff3df;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-state {
|
||||||
|
padding: 60px 0;
|
||||||
|
color: var(--mut);
|
||||||
|
font-size: 13px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 两栏各滚各的:左栏是信息与留痕,右栏是文书,互不牵连 */
|
||||||
|
.record-detail {
|
||||||
|
display: flex;
|
||||||
|
gap: 16px;
|
||||||
|
align-items: stretch;
|
||||||
|
height: 68vh;
|
||||||
|
min-height: 380px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.record-detail__side {
|
||||||
|
display: flex;
|
||||||
|
width: 372px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
padding-right: 4px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.record-detail__doc {
|
||||||
|
min-width: 0;
|
||||||
|
flex: 1;
|
||||||
|
padding-right: 4px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-block {
|
||||||
|
padding: 12px 14px;
|
||||||
|
background: #f8fbfc;
|
||||||
|
border: 1px solid #edf3f5;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-block h4 {
|
||||||
|
margin: 0 0 8px;
|
||||||
|
color: var(--ink);
|
||||||
|
font-size: 12.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-block dl {
|
||||||
|
display: grid;
|
||||||
|
gap: 6px 12px;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-block dl > div {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-block__wide {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-block dt {
|
||||||
|
color: var(--mut);
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-block dd {
|
||||||
|
margin: 2px 0 0;
|
||||||
|
overflow: hidden;
|
||||||
|
color: var(--ink);
|
||||||
|
font-size: 12px;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-block .mono {
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||||
|
font-size: 11px;
|
||||||
|
word-break: break-all;
|
||||||
|
white-space: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
.signature-box {
|
||||||
|
display: flex;
|
||||||
|
min-height: 76px;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
background: #fff;
|
||||||
|
border: 1px dashed var(--line);
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.signature-box img {
|
||||||
|
max-width: 100%;
|
||||||
|
max-height: 72px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.signature-box span {
|
||||||
|
color: var(--mut);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-footer {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-footer__note {
|
||||||
|
flex: 1;
|
||||||
|
color: var(--mut);
|
||||||
|
font-size: 11.5px;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-button {
|
||||||
|
flex-shrink: 0;
|
||||||
|
padding: 8px 16px;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
background: var(--brand);
|
||||||
|
border-radius: 7px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-button:hover {
|
||||||
|
background: var(--brand-d);
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-button--ghost {
|
||||||
|
color: var(--brand);
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid var(--brand);
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-button--ghost:hover {
|
||||||
|
background: var(--brand-l);
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-button:disabled {
|
||||||
|
color: #9aabb3;
|
||||||
|
cursor: not-allowed;
|
||||||
|
background: #eef3f5;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1180px) {
|
||||||
|
.record-detail {
|
||||||
|
height: auto;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.record-detail__side,
|
||||||
|
.record-detail__doc {
|
||||||
|
width: 100%;
|
||||||
|
overflow-y: visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
.record-detail__doc {
|
||||||
|
max-height: 60vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,221 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { reactive, watch } from 'vue'
|
||||||
|
|
||||||
|
import type { SigningMethod, WorkbenchCampus } from '@/api/workbench/types'
|
||||||
|
|
||||||
|
import type { SigningRecordFilterForm, SigningRecordFilterOption } from '../types'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
filter: SigningRecordFilterForm
|
||||||
|
campuses: SigningRecordFilterOption<WorkbenchCampus | 'all'>[]
|
||||||
|
statuses: SigningRecordFilterOption<SigningRecordFilterForm['status']>[]
|
||||||
|
methods: SigningRecordFilterOption<SigningMethod | 'all'>[]
|
||||||
|
departments: SigningRecordFilterOption[]
|
||||||
|
categories: SigningRecordFilterOption[]
|
||||||
|
/**
|
||||||
|
* 文档类别是否可筛。
|
||||||
|
* 真实模式下后端签署任务接口不支持按类别检索,这里如实禁用并给出原因,
|
||||||
|
* 不做"选了但被静默忽略"——那种筛选项比没有更误导人。
|
||||||
|
*/
|
||||||
|
categoryEnabled: boolean
|
||||||
|
canExport: boolean
|
||||||
|
exporting: boolean
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
search: [filter: SigningRecordFilterForm]
|
||||||
|
export: []
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const draft = reactive<SigningRecordFilterForm>({ ...props.filter })
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.filter,
|
||||||
|
(value) => {
|
||||||
|
Object.assign(draft, value)
|
||||||
|
},
|
||||||
|
{ deep: true },
|
||||||
|
)
|
||||||
|
|
||||||
|
function submit() {
|
||||||
|
emit('search', { ...draft })
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<form class="filter-card" @submit.prevent="submit">
|
||||||
|
<label class="filter-field">
|
||||||
|
<span>时间</span>
|
||||||
|
<select v-model="draft.dateRange" aria-label="筛选时间范围">
|
||||||
|
<option value="today">今日</option>
|
||||||
|
<option value="yesterday">昨日</option>
|
||||||
|
<option value="3d">近3天</option>
|
||||||
|
<option value="7d">近7天</option>
|
||||||
|
<option value="30d">近30天</option>
|
||||||
|
<option value="all">全部</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="filter-field">
|
||||||
|
<span>院区</span>
|
||||||
|
<select v-model="draft.campus" aria-label="筛选院区">
|
||||||
|
<option v-for="option in campuses" :key="option.value" :value="option.value">
|
||||||
|
{{ option.label }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="filter-field">
|
||||||
|
<span>科室</span>
|
||||||
|
<select v-model="draft.department" aria-label="筛选科室">
|
||||||
|
<option v-for="option in departments" :key="option.value" :value="option.value">
|
||||||
|
{{ option.label }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label
|
||||||
|
class="filter-field"
|
||||||
|
:title="categoryEnabled ? '' : '后端签署记录接口暂不支持按文档类别检索'"
|
||||||
|
>
|
||||||
|
<span>文档类别</span>
|
||||||
|
<select v-model="draft.category" :disabled="!categoryEnabled" aria-label="筛选文档类别">
|
||||||
|
<option v-for="option in categories" :key="option.value" :value="option.value">
|
||||||
|
{{ option.label }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="filter-field">
|
||||||
|
<span>方式</span>
|
||||||
|
<select v-model="draft.method" aria-label="筛选签署方式">
|
||||||
|
<option v-for="option in methods" :key="option.value" :value="option.value">
|
||||||
|
{{ option.label }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="filter-field">
|
||||||
|
<span>状态</span>
|
||||||
|
<select v-model="draft.status" aria-label="筛选状态">
|
||||||
|
<option v-for="option in statuses" :key="option.value" :value="option.value">
|
||||||
|
{{ option.label }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="filter-field filter-field--keyword">
|
||||||
|
<span>关键词</span>
|
||||||
|
<input
|
||||||
|
v-model="draft.keyword"
|
||||||
|
placeholder="患者姓名 / 患者ID / 任务号 / 文档名"
|
||||||
|
aria-label="关键词"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<span class="filter-spacer" />
|
||||||
|
<button type="submit" class="action-button action-button--ghost">查询</button>
|
||||||
|
<button
|
||||||
|
v-if="canExport"
|
||||||
|
type="button"
|
||||||
|
class="action-button action-button--ghost"
|
||||||
|
:disabled="exporting"
|
||||||
|
@click="emit('export')"
|
||||||
|
>
|
||||||
|
{{ exporting ? '导出中…' : '导出' }}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.filter-card {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
padding: 14px 18px;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
background: var(--card);
|
||||||
|
border-radius: var(--r);
|
||||||
|
box-shadow: var(--sh);
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-field {
|
||||||
|
display: flex;
|
||||||
|
min-width: 104px;
|
||||||
|
gap: 6px;
|
||||||
|
align-items: center;
|
||||||
|
color: var(--mut);
|
||||||
|
font-size: 12.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-field select,
|
||||||
|
.filter-field input {
|
||||||
|
width: auto;
|
||||||
|
min-width: 82px;
|
||||||
|
padding: 7px 9px;
|
||||||
|
color: var(--ink);
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-field select:focus,
|
||||||
|
.filter-field input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--brand);
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-field select:disabled {
|
||||||
|
color: #9aabb3;
|
||||||
|
cursor: not-allowed;
|
||||||
|
background: #f4f7f8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-field--keyword input {
|
||||||
|
width: 210px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-spacer {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-button {
|
||||||
|
flex-shrink: 0;
|
||||||
|
padding: 8px 16px;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
background: var(--brand);
|
||||||
|
border-radius: 7px;
|
||||||
|
transition: background 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-button:hover {
|
||||||
|
background: var(--brand-d);
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-button--ghost {
|
||||||
|
color: var(--brand);
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid var(--brand);
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-button--ghost:hover {
|
||||||
|
background: var(--brand-l);
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-button--ghost:disabled {
|
||||||
|
color: #9aabb3;
|
||||||
|
cursor: not-allowed;
|
||||||
|
background: #fff;
|
||||||
|
border-color: var(--line);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1200px) {
|
||||||
|
.filter-spacer {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,340 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
|
||||||
|
import type { SigningTaskStatus } from '@/api/workbench/types'
|
||||||
|
|
||||||
|
import type { SigningRecordRow } from '../types'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
rows: SigningRecordRow[]
|
||||||
|
total: number
|
||||||
|
page: number
|
||||||
|
pageSize: number
|
||||||
|
loading: boolean
|
||||||
|
error: string
|
||||||
|
canView: boolean
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
view: [row: SigningRecordRow]
|
||||||
|
'page-change': [page: number]
|
||||||
|
'page-size-change': [size: number]
|
||||||
|
retry: []
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const pageSizeOptions = [20, 50, 100]
|
||||||
|
|
||||||
|
const pageCount = computed(() => Math.max(Math.ceil(props.total / props.pageSize), 1))
|
||||||
|
|
||||||
|
const statusLabels: Record<SigningTaskStatus, string> = {
|
||||||
|
pending: '待签署',
|
||||||
|
signing: '签署中',
|
||||||
|
signed: '已签署',
|
||||||
|
expired: '已超时',
|
||||||
|
void: '已作废',
|
||||||
|
failed: '处理失败',
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 待签署要带出渠道:现场等着签的是手写板,等患者点链接的是短信,处理方式完全不同 */
|
||||||
|
function getStatusLabel(row: SigningRecordRow) {
|
||||||
|
if (row.status === 'pending') {
|
||||||
|
return `${statusLabels[row.status]}·${row.method === 'pad' ? '手写板' : '短信'}`
|
||||||
|
}
|
||||||
|
|
||||||
|
return statusLabels[row.status]
|
||||||
|
}
|
||||||
|
|
||||||
|
function getMethodLabel(row: SigningRecordRow) {
|
||||||
|
return row.method === 'pad' ? '✍️ 手写板' : '📱 短信'
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTime(value: string | undefined) {
|
||||||
|
return value || '—'
|
||||||
|
}
|
||||||
|
|
||||||
|
function handlePageSizeChange(event: Event) {
|
||||||
|
const value = Number((event.target as HTMLSelectElement).value)
|
||||||
|
|
||||||
|
if (Number.isFinite(value)) {
|
||||||
|
emit('page-size-change', value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div v-if="error" class="table-error" role="alert">
|
||||||
|
<span>{{ error }}</span>
|
||||||
|
<button type="button" @click="emit('retry')">重试</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="table-container" :class="{ 'is-loading': loading }" :aria-busy="loading">
|
||||||
|
<div class="table-scroll">
|
||||||
|
<table class="record-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>任务号</th>
|
||||||
|
<th>患者</th>
|
||||||
|
<th>文档</th>
|
||||||
|
<th>科室</th>
|
||||||
|
<th>院区</th>
|
||||||
|
<th>方式</th>
|
||||||
|
<th>状态</th>
|
||||||
|
<th>发起时间</th>
|
||||||
|
<th>操作</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="row in rows" :key="row.id">
|
||||||
|
<td class="task-id">{{ row.id }}</td>
|
||||||
|
<td>
|
||||||
|
<strong>{{ row.patientName }}</strong>
|
||||||
|
<span class="patient-meta">{{ row.sex }} · {{ row.age }}岁</span>
|
||||||
|
<span class="patient-id">{{ row.patientId }}</span>
|
||||||
|
</td>
|
||||||
|
<td class="document-name" :title="row.documentName">
|
||||||
|
{{ row.documentName }}
|
||||||
|
<span class="document-category">{{ row.category || '—' }}</span>
|
||||||
|
</td>
|
||||||
|
<td>{{ row.department }}</td>
|
||||||
|
<td>{{ row.campus || '—' }}</td>
|
||||||
|
<td>{{ getMethodLabel(row) }}</td>
|
||||||
|
<td>
|
||||||
|
<span class="status-tag" :class="`status-${row.status}`">
|
||||||
|
{{ getStatusLabel(row) }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td>{{ formatTime(row.createdAt) }}</td>
|
||||||
|
<td>
|
||||||
|
<button
|
||||||
|
v-if="canView"
|
||||||
|
type="button"
|
||||||
|
class="view-button"
|
||||||
|
@click="emit('view', row)"
|
||||||
|
>
|
||||||
|
查看
|
||||||
|
</button>
|
||||||
|
<span v-else class="no-action">—</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr v-if="!rows.length">
|
||||||
|
<td colspan="9" class="empty-cell">没有符合条件的签署记录</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="total" class="table-footer">
|
||||||
|
<span>共 {{ total }} 条</span>
|
||||||
|
<label>
|
||||||
|
每页
|
||||||
|
<select :value="pageSize" aria-label="选择每页条数" @change="handlePageSizeChange">
|
||||||
|
<option v-for="option in pageSizeOptions" :key="option" :value="option">
|
||||||
|
{{ option }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
条
|
||||||
|
</label>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="page-button"
|
||||||
|
:disabled="page <= 1 || loading"
|
||||||
|
@click="page > 1 && emit('page-change', page - 1)"
|
||||||
|
>
|
||||||
|
上一页
|
||||||
|
</button>
|
||||||
|
<span>第 {{ page }} / {{ pageCount }} 页</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="page-button"
|
||||||
|
:disabled="page >= pageCount || loading"
|
||||||
|
@click="emit('page-change', page + 1)"
|
||||||
|
>
|
||||||
|
下一页
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.table-scroll {
|
||||||
|
width: 100%;
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-container {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-container.is-loading {
|
||||||
|
min-height: 120px;
|
||||||
|
pointer-events: none;
|
||||||
|
opacity: 0.55;
|
||||||
|
}
|
||||||
|
|
||||||
|
.record-table {
|
||||||
|
width: 100%;
|
||||||
|
min-width: 1080px;
|
||||||
|
font-size: 13px;
|
||||||
|
border-collapse: collapse;
|
||||||
|
}
|
||||||
|
|
||||||
|
.record-table th {
|
||||||
|
padding: 9px 12px;
|
||||||
|
color: var(--mut);
|
||||||
|
font-size: 12.5px;
|
||||||
|
font-weight: 700;
|
||||||
|
text-align: left;
|
||||||
|
white-space: nowrap;
|
||||||
|
background: #f5f9fa;
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.record-table td {
|
||||||
|
padding: 9px 12px;
|
||||||
|
vertical-align: middle;
|
||||||
|
border-bottom: 1px solid #eef4f6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.record-table tbody tr:hover td {
|
||||||
|
background: #f7fbfc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-id {
|
||||||
|
font-size: 12px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.patient-meta,
|
||||||
|
.patient-id {
|
||||||
|
display: block;
|
||||||
|
margin-top: 2px;
|
||||||
|
color: var(--mut);
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.document-name {
|
||||||
|
max-width: 250px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.document-category {
|
||||||
|
display: block;
|
||||||
|
margin-top: 2px;
|
||||||
|
color: var(--mut);
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-tag {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 2px 10px;
|
||||||
|
font-size: 11.5px;
|
||||||
|
font-weight: 700;
|
||||||
|
white-space: nowrap;
|
||||||
|
border-radius: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-signed {
|
||||||
|
color: var(--ok);
|
||||||
|
background: var(--ok-l);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-pending {
|
||||||
|
color: var(--brand);
|
||||||
|
background: var(--brand-l);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-signing {
|
||||||
|
color: var(--info);
|
||||||
|
background: var(--info-l);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-expired {
|
||||||
|
color: var(--err);
|
||||||
|
background: var(--err-l);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-void {
|
||||||
|
color: #8a9aa3;
|
||||||
|
background: #eceef0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-failed {
|
||||||
|
color: #a15c19;
|
||||||
|
background: #fff3df;
|
||||||
|
}
|
||||||
|
|
||||||
|
.view-button {
|
||||||
|
padding: 4px 10px;
|
||||||
|
color: var(--brand);
|
||||||
|
font-size: 12px;
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid var(--brand);
|
||||||
|
border-radius: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.view-button:hover {
|
||||||
|
background: var(--brand-l);
|
||||||
|
}
|
||||||
|
|
||||||
|
.no-action {
|
||||||
|
color: var(--mut);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-cell {
|
||||||
|
padding: 40px 12px !important;
|
||||||
|
color: var(--mut);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-error {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-height: 90px;
|
||||||
|
color: var(--err);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-error button,
|
||||||
|
.page-button {
|
||||||
|
padding: 5px 10px;
|
||||||
|
color: var(--brand);
|
||||||
|
font-size: 12px;
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid var(--brand);
|
||||||
|
border-radius: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-button:disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.45;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-footer {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-end;
|
||||||
|
padding-top: 12px;
|
||||||
|
color: var(--mut);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-footer label {
|
||||||
|
display: inline-flex;
|
||||||
|
gap: 4px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-footer select {
|
||||||
|
padding: 3px 5px;
|
||||||
|
color: var(--ink);
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,598 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onMounted, reactive, ref } from 'vue'
|
||||||
|
import { ElMessage } from 'element-plus'
|
||||||
|
|
||||||
|
import { getCampuses, getDepartments } from '@/api/management/organization'
|
||||||
|
import { downloadSigningArtifact, getSigningArtifacts } from '@/api/workbench/artifacts'
|
||||||
|
import {
|
||||||
|
getAllSigningRecords,
|
||||||
|
getSigningRecordDetail,
|
||||||
|
getSigningRecordEvents,
|
||||||
|
getSigningRecords,
|
||||||
|
isSigningRecordsMockEnabled,
|
||||||
|
mockRecordCategories,
|
||||||
|
mockRecordDepartments,
|
||||||
|
} from '@/api/workbench/records'
|
||||||
|
import type {
|
||||||
|
SigningApiOptions,
|
||||||
|
SigningMethod,
|
||||||
|
SigningTaskEvent,
|
||||||
|
SigningTaskStatus,
|
||||||
|
WorkbenchCampus,
|
||||||
|
} from '@/api/workbench/types'
|
||||||
|
import { useAppStore } from '@/stores/app'
|
||||||
|
import { useLoginStore } from '@/stores/login'
|
||||||
|
import { downloadSigningPdf } from '@/utils/signing-pdf'
|
||||||
|
|
||||||
|
import SigningRecordDetailDialog from './components/SigningRecordDetailDialog.vue'
|
||||||
|
import SigningRecordFilterBar from './components/SigningRecordFilterBar.vue'
|
||||||
|
import SigningRecordTable from './components/SigningRecordTable.vue'
|
||||||
|
import type {
|
||||||
|
SigningRecordExportColumn,
|
||||||
|
SigningRecordFilterForm,
|
||||||
|
SigningRecordFilterOption,
|
||||||
|
SigningRecordRow,
|
||||||
|
} from './types'
|
||||||
|
|
||||||
|
const appStore = useAppStore()
|
||||||
|
const loginStore = useLoginStore()
|
||||||
|
|
||||||
|
const PAGE_SIZE_OPTIONS = [20, 50, 100]
|
||||||
|
const DEFAULT_PAGE_SIZE = 20
|
||||||
|
|
||||||
|
const statusLabels: Record<SigningTaskStatus, string> = {
|
||||||
|
pending: '待签署',
|
||||||
|
signing: '签署中',
|
||||||
|
signed: '已签署',
|
||||||
|
expired: '已超时',
|
||||||
|
void: '已作废',
|
||||||
|
failed: '处理失败',
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 导出列。与表格保持同一口径,但比表格多出「签署人 / 完成时间 / 失效时间」——
|
||||||
|
* 导出是给台账用的,留痕要素不能只在页面上看得到。
|
||||||
|
*/
|
||||||
|
const EXPORT_COLUMNS: SigningRecordExportColumn[] = [
|
||||||
|
{ label: '任务号', value: (record) => record.id },
|
||||||
|
{ label: '患者姓名', value: (record) => record.patientName },
|
||||||
|
{ label: '患者ID', value: (record) => record.patientId },
|
||||||
|
{ label: '性别', value: (record) => record.sex },
|
||||||
|
{ label: '年龄', value: (record) => String(record.age) },
|
||||||
|
{ label: '就诊号', value: (record) => record.visitNo },
|
||||||
|
{ label: '就诊类型', value: (record) => record.visitType },
|
||||||
|
{ label: '文档名称', value: (record) => record.documentName },
|
||||||
|
{ label: '文档类别', value: (record) => record.category },
|
||||||
|
{ label: '科室', value: (record) => record.department },
|
||||||
|
{ label: '院区', value: (record) => record.campus },
|
||||||
|
{ label: '签署方式', value: (record) => (record.method === 'pad' ? '手写板' : '短信线上') },
|
||||||
|
{ label: '状态', value: (record) => statusLabels[record.status] },
|
||||||
|
{ label: '发起时间', value: (record) => record.createdAt ?? '' },
|
||||||
|
{ label: '签署完成时间', value: (record) => record.signedAt ?? '' },
|
||||||
|
{ label: '失效时间', value: (record) => record.expiresAt },
|
||||||
|
{ label: '签署人', value: (record) => record.signerName },
|
||||||
|
{ label: '作废原因', value: (record) => record.voidReason ?? '' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const filter = reactive<SigningRecordFilterForm>({
|
||||||
|
// 记录页默认看近 7 天:历史全量一屏装不下,先给一个"最近发生了什么"的窗口
|
||||||
|
dateRange: '7d',
|
||||||
|
campus: 'all',
|
||||||
|
department: '',
|
||||||
|
category: '',
|
||||||
|
method: 'all',
|
||||||
|
status: 'all',
|
||||||
|
keyword: '',
|
||||||
|
})
|
||||||
|
|
||||||
|
const rows = ref<SigningRecordRow[]>([])
|
||||||
|
const total = ref(0)
|
||||||
|
const page = ref(1)
|
||||||
|
const pageSize = ref(DEFAULT_PAGE_SIZE)
|
||||||
|
const loading = ref(true)
|
||||||
|
const errorMessage = ref('')
|
||||||
|
const exporting = ref(false)
|
||||||
|
|
||||||
|
const detailVisible = ref(false)
|
||||||
|
const detailRecord = ref<SigningRecordRow | null>(null)
|
||||||
|
const detailLoading = ref(false)
|
||||||
|
const detailEvents = ref<SigningTaskEvent[]>([])
|
||||||
|
const detailEventsLoading = ref(false)
|
||||||
|
const detailEventsError = ref(false)
|
||||||
|
const pdfExporting = ref(false)
|
||||||
|
const detailDialogRef = ref<InstanceType<typeof SigningRecordDetailDialog> | null>(null)
|
||||||
|
|
||||||
|
const campusIds = ref<Record<string, string>>({})
|
||||||
|
const campusNames = ref<Record<string, WorkbenchCampus>>({})
|
||||||
|
const departmentIds = ref<Record<string, string>>({})
|
||||||
|
const departmentNames = ref<string[]>([])
|
||||||
|
|
||||||
|
let listRequestId = 0
|
||||||
|
let detailRequestId = 0
|
||||||
|
|
||||||
|
function canRead(permission: string) {
|
||||||
|
return isSigningRecordsMockEnabled || loginStore.hasPermission(permission)
|
||||||
|
}
|
||||||
|
|
||||||
|
const canView = computed(() => canRead('sign:record:view'))
|
||||||
|
const canExport = computed(
|
||||||
|
() => !loginStore.isReadOnly && canRead('sign:record:export'),
|
||||||
|
)
|
||||||
|
const canDownloadArtifact = computed(() => canRead('sign:artifact:download'))
|
||||||
|
|
||||||
|
const campusOptions = computed<SigningRecordFilterOption<WorkbenchCampus | 'all'>[]>(() => [
|
||||||
|
{ label: '全部院区', value: 'all' },
|
||||||
|
...appStore.campuses.map((campus) => ({ label: campus, value: campus })),
|
||||||
|
])
|
||||||
|
|
||||||
|
const statusOptions: SigningRecordFilterOption<SigningRecordFilterForm['status']>[] = [
|
||||||
|
{ label: '全部状态', value: 'all' },
|
||||||
|
...(['pending', 'signing', 'signed', 'expired', 'void', 'failed'] as SigningTaskStatus[]).map(
|
||||||
|
(status) => ({ label: statusLabels[status], value: status }),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
const methodOptions: SigningRecordFilterOption<SigningMethod | 'all'>[] = [
|
||||||
|
{ label: '全部方式', value: 'all' },
|
||||||
|
{ label: '手写板', value: 'pad' },
|
||||||
|
{ label: '短信线上', value: 'sms' },
|
||||||
|
]
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 科室下拉:演示模式取演示记录里出现过的科室,真实模式取组织接口的科室清单。
|
||||||
|
* 不用「当前页出现过的科室」——翻页会换掉可选项,筛完再想换一个科室就找不到了。
|
||||||
|
*/
|
||||||
|
const departmentOptions = computed<SigningRecordFilterOption[]>(() => {
|
||||||
|
const names = isSigningRecordsMockEnabled ? mockRecordDepartments : departmentNames.value
|
||||||
|
return [
|
||||||
|
{ label: '全部科室', value: '' },
|
||||||
|
...names.map((name) => ({ label: name, value: name })),
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
|
const categoryOptions = computed<SigningRecordFilterOption[]>(() => {
|
||||||
|
// 真实模式下后端签署任务接口不支持按类别检索,这里给不出可选项,由筛选条禁用该字段
|
||||||
|
const categories = isSigningRecordsMockEnabled
|
||||||
|
? mockRecordCategories
|
||||||
|
: [...new Set(rows.value.map((row) => row.category).filter(Boolean))]
|
||||||
|
|
||||||
|
return [
|
||||||
|
{ label: '全部类别', value: '' },
|
||||||
|
...categories.map((category) => ({ label: category, value: category })),
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
|
const signingApiOptions = computed<SigningApiOptions>(() => ({
|
||||||
|
campus: appStore.selectedCampus,
|
||||||
|
campusId: campusIds.value[appStore.selectedCampus],
|
||||||
|
campusIds: campusIds.value,
|
||||||
|
campusNames: campusNames.value,
|
||||||
|
departmentIds: departmentIds.value,
|
||||||
|
}))
|
||||||
|
|
||||||
|
/** 真实模式才需要:把院区 / 科室名称换成接口要的 id,并留一份院区名用于回显 */
|
||||||
|
async function loadOrganizationOptions() {
|
||||||
|
if (isSigningRecordsMockEnabled) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const [campusesResult, departmentsResult] = await Promise.allSettled([
|
||||||
|
getCampuses(),
|
||||||
|
getDepartments(),
|
||||||
|
])
|
||||||
|
|
||||||
|
if (campusesResult.status === 'fulfilled') {
|
||||||
|
campusIds.value = Object.fromEntries(
|
||||||
|
campusesResult.value.map((campus) => [campus.name, campus.id]),
|
||||||
|
)
|
||||||
|
campusNames.value = Object.fromEntries(
|
||||||
|
campusesResult.value.map((campus) => [campus.id, campus.name]),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (departmentsResult.status === 'fulfilled') {
|
||||||
|
departmentIds.value = Object.fromEntries(
|
||||||
|
departmentsResult.value.map((department) => [department.name, department.id]),
|
||||||
|
)
|
||||||
|
departmentNames.value = departmentsResult.value.map((department) => department.name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadRecords(resetPage = false) {
|
||||||
|
if (resetPage) {
|
||||||
|
page.value = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
const requestId = ++listRequestId
|
||||||
|
loading.value = true
|
||||||
|
errorMessage.value = ''
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await getSigningRecords(
|
||||||
|
{ ...filter, page: page.value, pageSize: pageSize.value },
|
||||||
|
signingApiOptions.value,
|
||||||
|
)
|
||||||
|
|
||||||
|
if (requestId !== listRequestId) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
rows.value = response.records
|
||||||
|
total.value = response.total
|
||||||
|
page.value = response.page
|
||||||
|
pageSize.value = response.pageSize
|
||||||
|
} catch {
|
||||||
|
if (requestId === listRequestId) {
|
||||||
|
rows.value = []
|
||||||
|
total.value = 0
|
||||||
|
errorMessage.value = '签署记录加载失败,请稍后重试'
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (requestId === listRequestId) {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSearch(nextFilter: SigningRecordFilterForm) {
|
||||||
|
Object.assign(filter, nextFilter)
|
||||||
|
void loadRecords(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handlePageChange(nextPage: number) {
|
||||||
|
const pageCount = Math.max(Math.ceil(total.value / pageSize.value), 1)
|
||||||
|
|
||||||
|
if (nextPage < 1 || nextPage > pageCount || nextPage === page.value) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
page.value = nextPage
|
||||||
|
void loadRecords()
|
||||||
|
}
|
||||||
|
|
||||||
|
function handlePageSizeChange(nextSize: number) {
|
||||||
|
if (!PAGE_SIZE_OPTIONS.includes(nextSize) || nextSize === pageSize.value) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
pageSize.value = nextSize
|
||||||
|
void loadRecords(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openRecord(row: SigningRecordRow) {
|
||||||
|
if (!canView.value) {
|
||||||
|
ElMessage.warning('当前账号没有查看签署记录的权限')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
detailVisible.value = true
|
||||||
|
detailRecord.value = row
|
||||||
|
detailEvents.value = []
|
||||||
|
detailEventsError.value = false
|
||||||
|
detailLoading.value = true
|
||||||
|
detailEventsLoading.value = true
|
||||||
|
|
||||||
|
const requestId = ++detailRequestId
|
||||||
|
|
||||||
|
const [detailResult, eventsResult] = await Promise.allSettled([
|
||||||
|
getSigningRecordDetail(row.id, signingApiOptions.value),
|
||||||
|
getSigningRecordEvents(row.id),
|
||||||
|
])
|
||||||
|
|
||||||
|
if (requestId !== detailRequestId) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (detailResult.status === 'fulfilled' && detailResult.value) {
|
||||||
|
// 任务接口不返回文档类别与正文,正文靠详情接口补回;
|
||||||
|
// 类别沿用列表里带出的那一份,避免被详情里的空值覆盖
|
||||||
|
detailRecord.value = {
|
||||||
|
...row,
|
||||||
|
...detailResult.value,
|
||||||
|
category: detailResult.value.category || row.category,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (eventsResult.status === 'fulfilled') {
|
||||||
|
detailEvents.value = eventsResult.value
|
||||||
|
} else {
|
||||||
|
detailEventsError.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
detailLoading.value = false
|
||||||
|
detailEventsLoading.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeRecord() {
|
||||||
|
detailVisible.value = false
|
||||||
|
detailRecord.value = null
|
||||||
|
detailEvents.value = []
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveBlob(blob: Blob, fileName: string) {
|
||||||
|
const url = URL.createObjectURL(blob)
|
||||||
|
const link = document.createElement('a')
|
||||||
|
|
||||||
|
link.href = url
|
||||||
|
link.download = fileName
|
||||||
|
link.click()
|
||||||
|
window.setTimeout(() => URL.revokeObjectURL(url), 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDownloadPdf() {
|
||||||
|
const record = detailRecord.value
|
||||||
|
|
||||||
|
if (!record) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!canDownloadArtifact.value) {
|
||||||
|
ElMessage.warning('当前账号没有下载签署文件的权限')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pdfExporting.value) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
pdfExporting.value = true
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 正式文书以服务端产物为准:有签署后 PDF 就直接下载那一份
|
||||||
|
const artifacts = await getSigningArtifacts(record.id)
|
||||||
|
const signedPdf = artifacts.find((item) => item.artifactType === 'SIGNED_PDF')
|
||||||
|
|
||||||
|
if (signedPdf) {
|
||||||
|
saveBlob(await downloadSigningArtifact(signedPdf.id), signedPdf.fileName)
|
||||||
|
ElMessage.success('签署后 PDF 下载已开始')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isSigningRecordsMockEnabled) {
|
||||||
|
ElMessage.info('该记录暂无服务端生成的签署后 PDF')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 演示环境没有文件服务:按当前渲染结果现场生成一份,先让前端看到成品效果
|
||||||
|
const element = detailDialogRef.value?.getDocumentElement()
|
||||||
|
|
||||||
|
if (!element) {
|
||||||
|
ElMessage.info('该记录没有固化的文书正文,无法导出 PDF')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
await downloadSigningPdf({ element, fileName: `${record.documentName}-${record.id}` })
|
||||||
|
ElMessage.success('签署后 PDF 已生成')
|
||||||
|
} catch {
|
||||||
|
ElMessage.error('签署后 PDF 下载失败,请稍后重试')
|
||||||
|
} finally {
|
||||||
|
pdfExporting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeCsvField(value: string) {
|
||||||
|
return /[",\n\r]/.test(value) ? `"${value.replace(/"/g, '""')}"` : value
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 导出当前筛选条件下的全部记录。
|
||||||
|
*
|
||||||
|
* 后端还没有签署记录导出接口,所以先在前端按当前条件取全量、生成 CSV。
|
||||||
|
* 待后端提供导出任务后应改为服务端导出:导出范围与导出人都要写进审计日志,
|
||||||
|
* 这一步在浏览器里做不到。
|
||||||
|
*/
|
||||||
|
async function handleExport() {
|
||||||
|
if (!canExport.value) {
|
||||||
|
ElMessage.warning('当前账号没有导出签署记录的权限')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!total.value) {
|
||||||
|
ElMessage.info('当前筛选条件下没有可导出的签署记录')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
exporting.value = true
|
||||||
|
|
||||||
|
try {
|
||||||
|
const records = await getAllSigningRecords(
|
||||||
|
{ ...filter, page: 1, pageSize: pageSize.value },
|
||||||
|
signingApiOptions.value,
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!records.length) {
|
||||||
|
ElMessage.info('当前筛选条件下没有可导出的签署记录')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const lines = [
|
||||||
|
EXPORT_COLUMNS.map((column) => escapeCsvField(column.label)).join(','),
|
||||||
|
...records.map((record) =>
|
||||||
|
EXPORT_COLUMNS.map((column) => escapeCsvField(column.value(record))).join(','),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
const now = new Date()
|
||||||
|
const stamp = `${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, '0')}${String(
|
||||||
|
now.getDate(),
|
||||||
|
).padStart(2, '0')}${String(now.getHours()).padStart(2, '0')}${String(now.getMinutes()).padStart(
|
||||||
|
2,
|
||||||
|
'0',
|
||||||
|
)}`
|
||||||
|
|
||||||
|
// 带 BOM:Excel 直接打开 UTF-8 CSV 才不会把中文显示成乱码
|
||||||
|
saveBlob(
|
||||||
|
new Blob([`\uFEFF${lines.join('\r\n')}`], { type: 'text/csv;charset=utf-8' }),
|
||||||
|
`签署记录_${stamp}.csv`,
|
||||||
|
)
|
||||||
|
ElMessage.success(`已导出 ${records.length} 条签署记录`)
|
||||||
|
} catch {
|
||||||
|
ElMessage.error('导出失败,请稍后重试')
|
||||||
|
} finally {
|
||||||
|
exporting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function initializePage() {
|
||||||
|
await appStore.loadCampuses()
|
||||||
|
await loadOrganizationOptions()
|
||||||
|
await loadRecords(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
void initializePage()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section class="records-page">
|
||||||
|
<div class="page-intro">
|
||||||
|
<div>
|
||||||
|
<h1>签署记录查询</h1>
|
||||||
|
<p>按条件检索全量历史签署记录;工作台只保留当天签署操作,历史记录统一在这里查询。</p>
|
||||||
|
</div>
|
||||||
|
<div class="page-intro__meta">
|
||||||
|
<span>共 {{ total }} 条记录</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<SigningRecordFilterBar
|
||||||
|
:filter="filter"
|
||||||
|
:campuses="campusOptions"
|
||||||
|
:statuses="statusOptions"
|
||||||
|
:methods="methodOptions"
|
||||||
|
:departments="departmentOptions"
|
||||||
|
:categories="categoryOptions"
|
||||||
|
:category-enabled="isSigningRecordsMockEnabled"
|
||||||
|
:can-export="canExport"
|
||||||
|
:exporting="exporting"
|
||||||
|
@search="handleSearch"
|
||||||
|
@export="handleExport"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div class="record-card">
|
||||||
|
<div class="record-card__heading">
|
||||||
|
<strong>签署记录</strong>
|
||||||
|
<span class="record-card__count">
|
||||||
|
{{ total ? `共查询到 ${total} 条签署记录` : '没有符合条件的签署记录' }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<SigningRecordTable
|
||||||
|
:rows="rows"
|
||||||
|
:total="total"
|
||||||
|
:page="page"
|
||||||
|
:page-size="pageSize"
|
||||||
|
:loading="loading"
|
||||||
|
:error="errorMessage"
|
||||||
|
:can-view="canView"
|
||||||
|
@view="openRecord"
|
||||||
|
@page-change="handlePageChange"
|
||||||
|
@page-size-change="handlePageSizeChange"
|
||||||
|
@retry="loadRecords()"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<p class="record-card__note">
|
||||||
|
记录只读:已签署的正式文书不可直接修改,需要追加内容时请回到签署工作台新建签署任务;
|
||||||
|
历史记录可点击「查看」核对当时的文书、签名与操作留痕。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<SigningRecordDetailDialog
|
||||||
|
ref="detailDialogRef"
|
||||||
|
:visible="detailVisible"
|
||||||
|
:record="detailRecord"
|
||||||
|
:loading="detailLoading"
|
||||||
|
:events="detailEvents"
|
||||||
|
:events-loading="detailEventsLoading"
|
||||||
|
:events-error="detailEventsError"
|
||||||
|
:can-download="canDownloadArtifact"
|
||||||
|
:downloading="pdfExporting"
|
||||||
|
@close="closeRecord"
|
||||||
|
@download-pdf="handleDownloadPdf"
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.records-page {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 1600px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-intro {
|
||||||
|
display: flex;
|
||||||
|
gap: 20px;
|
||||||
|
align-items: flex-end;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-intro h1 {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--ink);
|
||||||
|
font-size: 19px;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-intro p {
|
||||||
|
margin: 4px 0 0;
|
||||||
|
color: var(--mut);
|
||||||
|
font-size: 12.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-intro__meta {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
color: var(--mut);
|
||||||
|
font-size: 12px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-intro__meta span {
|
||||||
|
padding: 5px 9px;
|
||||||
|
background: var(--card);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.record-card {
|
||||||
|
padding: 14px 18px 12px;
|
||||||
|
background: var(--card);
|
||||||
|
border-radius: var(--r);
|
||||||
|
box-shadow: var(--sh);
|
||||||
|
}
|
||||||
|
|
||||||
|
.record-card__heading {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.record-card__heading strong {
|
||||||
|
font-size: 13.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.record-card__count {
|
||||||
|
margin-left: auto;
|
||||||
|
color: var(--mut);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.record-card__note {
|
||||||
|
margin: 10px 0 0;
|
||||||
|
color: var(--mut);
|
||||||
|
font-size: 11.5px;
|
||||||
|
line-height: 1.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 680px) {
|
||||||
|
.page-intro {
|
||||||
|
align-items: flex-start;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -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<T extends string = string> {
|
||||||
|
label: string
|
||||||
|
value: T
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 表格行就是记录本身:记录页没有「选中后展开」的二级结构,不需要再包一层 */
|
||||||
|
export type SigningRecordRow = SigningRecord
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 导出列定义。
|
||||||
|
* 表头文字与取值函数放在一起:导出文件里的字段一旦和页面表格两处各写一份,
|
||||||
|
* 改了页面忘了改导出,是这类页面最常见的不一致。
|
||||||
|
*/
|
||||||
|
export interface SigningRecordExportColumn {
|
||||||
|
label: string
|
||||||
|
value: (record: SigningRecord) => string
|
||||||
|
}
|
||||||
@@ -9,10 +9,10 @@ import type {
|
|||||||
} from '@/api/workbench/types'
|
} from '@/api/workbench/types'
|
||||||
import type { SigningFieldProgress, SigningSignatureRequest } from '@/utils/signing-document'
|
import type { SigningFieldProgress, SigningSignatureRequest } from '@/utils/signing-document'
|
||||||
|
|
||||||
import SigningDocumentInteractive from './SigningDocumentInteractive.vue'
|
import SigningDocumentInteractive from '@/components/signing/SigningDocumentInteractive.vue'
|
||||||
import SigningDocumentPreview from './SigningDocumentPreview.vue'
|
import SigningDocumentPreview from '@/components/signing/SigningDocumentPreview.vue'
|
||||||
|
import TaskAuditTimeline from '@/components/signing/TaskAuditTimeline.vue'
|
||||||
import SigningArtifactList from './SigningArtifactList.vue'
|
import SigningArtifactList from './SigningArtifactList.vue'
|
||||||
import TaskAuditTimeline from './TaskAuditTimeline.vue'
|
|
||||||
import type { SigningTaskAction } from '../types'
|
import type { SigningTaskAction } from '../types'
|
||||||
|
|
||||||
defineProps<{
|
defineProps<{
|
||||||
|
|||||||
Reference in New Issue
Block a user