feat(签署记录): 新增签署记录查询页并复用签署任务接口

This commit is contained in:
yelan
2026-09-24 19:52:28 +08:00
parent 53c6f05969
commit c4718a3912
17 changed files with 2401 additions and 28 deletions
+2
View File
@@ -11,6 +11,8 @@ VITE_API_BASE_URL=/api
# 「接口报错」或「数据不对」,排查成本很高。
# VITE_MOCK_WORKBENCH_HOME=false
# VITE_MOCK_SIGNING=true
# 签署记录查询:后端暂无历史记录检索接口,缺省走演示数据
# VITE_MOCK_SIGN_RECORDS=true
# VITE_MOCK_DOCUMENTS=false
# VITE_MOCK_DOCUMENT_PERMISSIONS=false
# VITE_MOCK_REPORTS=false
+23 -3
View File
@@ -84,7 +84,7 @@ TanStack Vue Query、PDF 预览、报表图表、自动化测试和签字板适
## 当前状态
已完成 Vite 基础初始化、路由、Pinia、原型主题 CSS、登录页、首页、签署工作台和文档库管理页面。角色权限与系统设置中的菜单设置已接入 MEDISIGN RBAC 接口;导航、路由和页面操作按授权资源树控制,角色数据范围在角色配置中维护,员工页面只负责绑定角色。组织架构页按机构→院区→科室分组展示员工列表,新增院区默认归属“新华院区”,支持节点筛选、员工维护和组织节点维护。系统参数卡片仍保留 Mock,真实签字板/线上签名回调和打印服务仍需设备或后端能力。
已完成 Vite 基础初始化、路由、Pinia、原型主题 CSS、登录页、首页、签署工作台、签署记录查询和文档库管理页面。角色权限与系统设置中的菜单设置已接入 MEDISIGN RBAC 接口;导航、路由和页面操作按授权资源树控制,角色数据范围在角色配置中维护,员工页面只负责绑定角色。组织架构页按机构→院区→科室分组展示员工列表,新增院区默认归属“新华院区”,支持节点筛选、员工维护和组织节点维护。系统参数卡片仍保留 Mock,真实签字板/线上签名回调和打印服务仍需设备或后端能力。
文档库管理的真实模式已打通「模板 → 版本 → 正文」三段:
@@ -94,7 +94,9 @@ TanStack Vue Query、PDF 预览、报表图表、自动化测试和签字板适
签署工作台的真实模式同样依赖正文:`GET /api/v1/templates/available-versions` 只返回 `contentSha256`,签署端用这个哈希当缓存键去换回正文快照(同一哈希命中同一份正文,版本更新后哈希变化即自动失效),避免版本更新后仍按旧正文签署。取不到正文时任务详情如实提示并给出占位版式,不退化成「看起来像真文书」的假纸样。
自测与演示:显式开启 `VITE_MOCK_DOCUMENTS=true` 或 `VITE_MOCK_SIGNING=true` 时,文档库和签署任务的演示数据会镜像到 localStorage(见 `utils/mock-storage.ts`),刷新页面后仍可继续,不必每次重建文书、重发签署;文档库页头的「重置演示数据」可清空这些本地数据回到初始状态(该按钮只在演示模式下出现)。文档库缺省使用真实接口,签署工作台暂时缺省使用演示数据;持久化只针对演示数据,不涉及登录态与真实患者信息。本地建议在 `.env.development.local` 里显式写出开关,不要依赖缺省值。
自测与演示:显式开启 `VITE_MOCK_DOCUMENTS=true` 或 `VITE_MOCK_SIGNING=true` 时,文档库和签署任务的演示数据会镜像到 localStorage(见 `utils/mock-storage.ts`),刷新页面后仍可继续,不必每次重建文书、重发签署;文档库页头的「重置演示数据」可清空这些本地数据回到初始状态(该按钮只在演示模式下出现)。文档库缺省使用真实接口,签署工作台与签署记录查询暂时缺省使用演示数据;持久化只针对演示数据,不涉及登录态与真实患者信息。本地建议在 `.env.development.local` 里显式写出开关,不要依赖缺省值。
签署记录查询的演示数据不走 localStorage:它按「距今天数」现场生成(见 `api/workbench/records.ts` 的 `MOCK_RECORD_SEEDS`),每次打开都落在今日/近 3 天/近 7 天/近 30 天这些窗口内,演示时筛选条件不会因为数据过期而全部落空。
## 当前路由结构
@@ -105,6 +107,7 @@ TanStack Vue Query、PDF 预览、报表图表、自动化测试和签字板适
ClinicalLayout(实际导航和页面路由由授权菜单资源树提供)
├─ /dashboard 首页
├─ /sign/workbench 签署工作台
├─ /sign/records 签署记录查询
├─ /system/org 组织架构 / 员工角色绑定
├─ /system/role 角色权限与数据范围
├─ /doc/library 文档库管理
@@ -119,6 +122,9 @@ ClinicalLayout(实际导航和页面路由由授权菜单资源树提供)
跨页面复用的业务组件和 composable 不放在具体 View 目录中:
- `components/signing/NewSigningTaskDialog.vue`:新增签署任务公共弹窗,供签署工作台和文档库管理复用;
- `components/signing/SigningDocumentInteractive.vue`:签署态文书的交互化渲染(填写项回显、签名图回填、勾选收集),签署工作台详情与签署记录详情共用;
- `components/signing/SigningDocumentPreview.vue`:正文取不到时的占位版式,不冒充真文书;
- `components/signing/TaskAuditTimeline.vue`:操作留痕时间线,签署工作台详情与签署记录详情共用;
- `composables/useSigningTaskForm.ts`:封装患者定位、就诊选择、模板选择、签署方式、校验和任务提交状态;
- `utils/signing-document.ts`:把文书定稿 HTML 变换为签署态 HTML 的渲染管线(占位符填充、签名域、勾选与行内选项识别),签署工作台的任务详情和文档库「签署预览」共用同一份实现;
- `utils/signing-editor-marks.ts`:编辑区里把占位符与勾选框渲染成带类型角标的胶囊,只改渲染不改数据(见下「文书编写约定」)。
@@ -218,10 +224,24 @@ API 模块与页面按业务域对应。工作台页面使用 `api/workbench`
首页真实模式已接入 GET /api/v1/workbench/overview;接口口径、字段映射和验收标准见 [docs/workbench-home-api.md](./docs/workbench-home-api.md)。
## 签署记录查询
`views/workbench/records` 是「工作台 → 签署记录查询」(`/sign/records`,菜单资源 `sign/record/index`)的实现:工作台只保留当天的签署操作,历史记录的检索、核对与导出统一放在这里。页面只读——已签署的正式文书不可直接修改,需要追加内容时回签署工作台新建签署任务。
**接口现状与边界**(后端还没有「按条件检索历史签署记录」的专用接口):
- 真实模式复用 `GET /api/v1/sign-tasks` 的筛选能力(见 `api/workbench/records.ts` 的 `toTaskQuery`),映射口径与签署工作台保持一致,不另造一套;
- 因此**「文档类别」在真实模式下不可筛**,筛选条会禁用该字段并给出原因(title 提示),而不是让它静默失效——一个「选了不生效」的筛选项比没有这个筛选项更误导人;
- 类别字段签署任务接口不返回,列表与详情都按空值展示,只有演示数据带类别;
- 导出是前端按当前筛选条件取全量后生成 CSV(UTF-8 带 BOM、CRLF 换行,Excel 直接打开不乱码)。**后端提供导出任务后应改为服务端导出**:导出范围与导出人都要写进审计日志,这一步在浏览器里做不到;
- 详情弹窗复用签署工作台的文书渲染与留痕组件(`components/signing/`),签署后 PDF 优先取服务端产物(`sign-artifacts` 里的 `SIGNED_PDF`);演示环境没有文件服务时,按页面上当前渲染结果现场生成一份。
演示数据缺省开启(`VITE_MOCK_SIGN_RECORDS`),且与签署工作台**分开开关**:两者共用一个开关会导致「关掉工作台的演示数据」把记录页一起带进空态。
API 请求和响应类型按业务域放在 `api/workbench/types.ts`、`api/management/types.ts`;页面展示和交互类型放在对应 View 目录的 `types.ts`;全局复用类型放在 `types/common.ts`。页面提交模型会在 API 边界转换为后端 DTO,不向后端发送患者快照、文档名称或明文手机号等页面字段。
开发环境默认使用 `/api` 作为同源接口前缀,Vite 会将 `/api` 转发到 `https://ipad.shenynet.com`,因此浏览器不会直接跨域请求后端。代理配置位于 `vite.config.ts`,不改写 `/api/v1/...` 路径。修改代理或环境变量后需要重启 Vite 开发服务。
生产环境不会使用 Vite 的开发代理,需要在 Nginx 或其他网关中配置同样的 `/api` 反向代理。Mock 开关按业务模块(对齐菜单)拆分,缺省只有文档库和签署工作台使用演示数据,其余模块直接调用真实后端;各开关的环境变量名与缺省值见 `src/utils/mock-flags.ts`,需要临时切换时在本地 `.env.*.local` 里覆盖即可。使用本地开发代理时,`VITE_API_BASE_URL` 应填写 `/api`;如果改为直连后端,则需要后端配置允许当前前端源的 CORS。
生产环境不会使用 Vite 的开发代理,需要在 Nginx 或其他网关中配置同样的 `/api` 反向代理。Mock 开关按业务模块(对齐菜单)拆分,缺省只有文档库之外的签署侧(签署工作台 `VITE_MOCK_SIGNING`、签署记录查询 `VITE_MOCK_SIGN_RECORDS`)使用演示数据,其余模块直接调用真实后端;各开关的环境变量名与缺省值见 `src/utils/mock-flags.ts`,需要临时切换时在本地 `.env.*.local` 里覆盖即可。使用本地开发代理时,`VITE_API_BASE_URL` 应填写 `/api`;如果改为直连后端,则需要后端配置允许当前前端源的 CORS。
当前尚未接入的签署能力包括手写板设备桥接、线上签署页面/签名回调和打印服务。签署投递、一次性 Token 消费和真实 PNG 上传 API 已完成封装,但页面不能用 Canvas 演示数据冒充真实签名;需要接入设备适配器或患者 H5 回调后再启用。短信初次发送接口需要完整手机号,而患者查询只返回脱敏手机号,因此真实模式会要求当前操作人员确认完整投递地址;生产环境也可以改为由后端根据患者 ID 解析投递地址。签署工作台缺省即使用演示数据(`VITE_MOCK_SIGNING`),需要走真实投递时把它设为 `false`。
+675
View File
@@ -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)
} else if (range === '7d') {
date.setDate(date.getDate() - 6)
} else if (range === '30d') {
date.setDate(date.getDate() - 29)
}
return date
@@ -740,6 +742,8 @@ function toBackendTaskQuery(query: SigningTaskQuery, options: SigningApiOptions
start.setDate(start.getDate() - 2)
} else if (query.dateRange === '7d') {
start.setDate(start.getDate() - 6)
} else if (query.dateRange === '30d') {
start.setDate(start.getDate() - 29)
}
if (query.dateRange && query.dateRange !== 'all') {
+34 -1
View File
@@ -171,7 +171,12 @@ export interface SigningTaskQuery extends PageQuery {
visitType?: VisitType | 'all'
}
export type SigningDateRange = 'today' | 'yesterday' | '3d' | '7d' | 'all'
/**
* 时间范围筛选。
* `30d` 只被「签署记录查询」用到(历史记录跨度比工作台大),
* 但两侧共用同一个联合类型与同一套区间换算,避免各写一份后口径漂移。
*/
export type SigningDateRange = 'today' | 'yesterday' | '3d' | '7d' | '30d' | 'all'
/**
* 文书填写项的控件类型。
@@ -256,6 +261,34 @@ export interface SigningTaskRecord extends WorkbenchTask {
export type SigningTaskListResponse = PageResult<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。 */
export interface CreateSigningTaskInput {
campus: WorkbenchCampus
+6
View File
@@ -31,6 +31,12 @@ export const mockFlags = {
workbenchHome: resolveMockFlag(import.meta.env.VITE_MOCK_WORKBENCH_HOME, false),
/** 签署工作台:签署任务、签署产物,以及仅用于签署留痕的审计日志 */
signing: resolveMockFlag(import.meta.env.VITE_MOCK_SIGNING, true),
/**
* 签署记录查询:历史签署记录检索。
* 缺省开着:后端还没有「按条件检索历史签署记录」的接口(签署记录查询是独立菜单,
* 不与签署工作台共用一个开关,否则关掉工作台演示数据会把记录页一起带进空态)。
*/
signRecords: resolveMockFlag(import.meta.env.VITE_MOCK_SIGN_RECORDS, true),
/** 文档库管理:文书库与文书模板接口 */
documents: resolveMockFlag(import.meta.env.VITE_MOCK_DOCUMENTS, false),
/** 文档权限:模板权限配置与权限矩阵 */
+48 -21
View File
@@ -880,6 +880,19 @@ function readHostText(host: HTMLElement): string {
* 两者都放在本文件里,属性名与判据只有一份——签署端不该自己写 data-sign-* 选择器。
*/
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 multiAttr = host.getAttribute(FIELD_MULTI_ATTR)
@@ -988,7 +1001,7 @@ export function applySelectedItemsToRoot(root: HTMLElement, items: string[]): vo
}
/**
* 把签名图写进指定签名位,并摘掉它的采集入口。返回是否真的发生了改动。
* 把签名图写进**已经拿到手的**签名位宿主,并摘掉它的采集入口。返回是否真的发生了写入。
*
* 只改这一个宿主节点,不重建整篇文书 —— 整篇重建会换掉 v-html 的 DOM,
* 操作人员正在输入的填写项会连同光标一起丢。写完后由调用方回读 DOM
@@ -996,6 +1009,39 @@ export function applySelectedItemsToRoot(root: HTMLElement, items: string[]): vo
*
* 签名图只作为 <img src> 写进渲染结果,不进模板正文:
* 它是 base64 大字符串,进正文会让模板哈希随签名变化,缓存键跟着失效。
*
* 宿主级实现,供两条路径共用:applySignatureToRoot(只拿到根,按字段键找宿主)
* 与 applyAnswerToFieldHost(宿主就在手上)。签名图的 DOM 形态只能有一份定义,
* 否则「点出来的签名」与「读回来的签名」会长得不一样。
*/
function writeSignatureToHost(host: HTMLElement, dataUrl: string, altFallback = ''): boolean {
// 同一张图重复写入没有意义,返回 false 也让调用方少跑一轮回读
if (host.querySelector(`img.${SIGNATURE_IMAGE_CLASS}`)?.getAttribute('src') === dataUrl) {
return false
}
const label = host.getAttribute(FIELD_LABEL_ATTR) ?? ''
const image = host.ownerDocument.createElement('img')
image.className = SIGNATURE_IMAGE_CLASS
image.src = dataUrl
image.alt = label || altFallback
// 签名位填充后只剩签名图:原型不保留「未签署」提示,也不再有点击提示
host.replaceChildren(image)
host.title = label
// 采集入口用完即摘:留着会让「还能再点一次」的观感落空
host.classList.remove(ACTIONABLE_CLASS, 'sign-field--missing')
host.removeAttribute(FIELD_ACTION_ATTR)
host.removeAttribute('role')
host.removeAttribute('tabindex')
return true
}
/**
* 按字段键在根节点里找到签名位,把签名图写进去。返回是否真的发生了改动。
*
* 与 applyAnswerToFieldHost 共用 writeSignatureToHost:签名图的 DOM 形态只有一份定义。
*/
export function applySignatureToRoot(root: HTMLElement, fieldId: string, dataUrl: string): boolean {
if (!fieldId || !dataUrl) {
@@ -1008,26 +1054,7 @@ export function applySignatureToRoot(root: HTMLElement, fieldId: string, dataUrl
return false
}
// 同一张图重复写入没有意义,返回 false 也让调用方少跑一轮回读
if (host.querySelector(`img.${SIGNATURE_IMAGE_CLASS}`)?.getAttribute('src') === dataUrl) {
return false
}
const image = root.ownerDocument.createElement('img')
image.className = SIGNATURE_IMAGE_CLASS
image.src = dataUrl
image.alt = host.getAttribute(FIELD_LABEL_ATTR) ?? fieldId
// 签名位填充后只剩签名图:原型不保留「未签署」提示,也不再有点击提示
host.replaceChildren(image)
host.title = host.getAttribute(FIELD_LABEL_ATTR) ?? ''
// 采集入口用完即摘:留着会让「还能再点一次」的观感落空
host.classList.remove(ACTIONABLE_CLASS, 'sign-field--missing')
host.removeAttribute(FIELD_ACTION_ATTR)
host.removeAttribute('role')
host.removeAttribute('tabindex')
return true
return writeSignatureToHost(host, dataUrl, fieldId)
}
/**
@@ -12,6 +12,7 @@ const loginStore = useLoginStore()
const componentRegistry: Record<string, Component> = {
'dashboard/index': defineAsyncComponent(() => import('@/views/workbench/home/index.vue')),
'sign/workbench/index': defineAsyncComponent(() => import('@/views/workbench/signing/index.vue')),
'sign/record/index': defineAsyncComponent(() => import('@/views/workbench/records/index.vue')),
'system/org/index': defineAsyncComponent(() => import('@/views/management/users/index.vue')),
'system/role/index': defineAsyncComponent(() => import('@/views/management/roles/index.vue')),
'doc/library/index': defineAsyncComponent(() => import('@/views/management/documents/index.vue')),
@@ -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'
import type { SigningFieldProgress, SigningSignatureRequest } from '@/utils/signing-document'
import SigningDocumentInteractive from './SigningDocumentInteractive.vue'
import SigningDocumentPreview from './SigningDocumentPreview.vue'
import SigningDocumentInteractive from '@/components/signing/SigningDocumentInteractive.vue'
import SigningDocumentPreview from '@/components/signing/SigningDocumentPreview.vue'
import TaskAuditTimeline from '@/components/signing/TaskAuditTimeline.vue'
import SigningArtifactList from './SigningArtifactList.vue'
import TaskAuditTimeline from './TaskAuditTimeline.vue'
import type { SigningTaskAction } from '../types'
defineProps<{