From 8ec55e48c9686749a3dec4148bfd3f3ce58a7a5b Mon Sep 17 00:00:00 2001 From: yelan Date: Fri, 25 Sep 2026 12:09:58 +0800 Subject: [PATCH] =?UTF-8?q?feat(=E7=AD=BE=E7=BD=B2=E6=96=B9=E5=BC=8F):=20?= =?UTF-8?q?=E6=89=A9=E5=B1=95=E4=B8=BA=E6=89=8B=E5=86=99=E6=9D=BF/?= =?UTF-8?q?=E7=9F=AD=E4=BF=A1/Pad/=E5=85=AC=E4=BC=97=E5=8F=B7=E5=9B=9B?= =?UTF-8?q?=E7=A7=8D=E5=B9=B6=E9=9B=86=E4=B8=AD=E5=8F=A3=E5=BE=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- clinical-web/src/api/management/types.ts | 3 +- clinical-web/src/api/workbench/home.ts | 71 +- clinical-web/src/api/workbench/records.ts | 26 +- .../src/api/workbench/report-analytics.ts | 33 +- clinical-web/src/api/workbench/signing.ts | 117 +- clinical-web/src/api/workbench/types.ts | 23 +- .../signing/NewSigningTaskDialog.vue | 1510 +++++++++++------ .../src/composables/useSigningTaskForm.ts | 358 +++- .../src/utils/mock-document-library.ts | 5 +- clinical-web/src/utils/signing-method.ts | 141 ++ .../src/views/management/documents/index.vue | 3 +- .../reports/components/ReportDetailTable.vue | 6 +- .../src/views/management/reports/index.vue | 12 +- .../src/views/management/reports/mock.ts | 32 + .../src/views/management/reports/types.ts | 6 +- clinical-web/src/views/reports/aggregate.ts | 20 +- .../reports/components/ReportExportDialog.vue | 4 +- .../reports/components/ReportFilterBar.vue | 5 +- .../workbench/home/components/TodoPanel.vue | 12 +- .../src/views/workbench/home/types.ts | 5 +- .../components/SigningRecordDetailDialog.vue | 3 +- .../records/components/SigningRecordTable.vue | 5 +- .../src/views/workbench/records/index.vue | 7 +- .../signing/components/SigningTaskDetail.vue | 95 +- .../signing/components/SigningTaskList.vue | 25 +- .../src/views/workbench/signing/index.vue | 25 +- 26 files changed, 1864 insertions(+), 688 deletions(-) create mode 100644 clinical-web/src/utils/signing-method.ts diff --git a/clinical-web/src/api/management/types.ts b/clinical-web/src/api/management/types.ts index 3d3108c..1057acd 100644 --- a/clinical-web/src/api/management/types.ts +++ b/clinical-web/src/api/management/types.ts @@ -310,7 +310,8 @@ export interface OrganizationResponseDto { export type ReportBackendTaskStatus = 'CREATED' | 'WAITING_SIGN' | 'SIGNED' | 'EXPIRED' | 'VOIDED' | 'GENERATING' | 'FAILED' -export type ReportBackendSignMethod = 'PAD' | 'SMS' +/** 与 workbench 的 BackendSigningMethod 同口径:四种方式平铺,无通道归并 */ +export type ReportBackendSignMethod = 'PAD' | 'SMS' | 'IPAD' | 'WX' export type ReportTimeDimension = 'CREATED' | 'SIGNED' | 'EXPIRED' diff --git a/clinical-web/src/api/workbench/home.ts b/clinical-web/src/api/workbench/home.ts index a7c8d4c..d3a711c 100644 --- a/clinical-web/src/api/workbench/home.ts +++ b/clinical-web/src/api/workbench/home.ts @@ -79,6 +79,27 @@ const mockCampusOverviews: Record = { method: 'sms', updatedAt: '2026-08-27 09:40', }, + { + // Pad 移动端:待办里要四种方式都有样本,徽标配色才看得出区别 + id: 'T0818004', + patientId: 'P202610003', + patientName: '赵某某', + documentName: '住院须知及告知书', + department: '消化内科', + status: 'pending', + method: 'ipad', + updatedAt: '2026-08-27 09:05', + }, + { + id: 'T0818005', + patientId: 'P202610007', + patientName: '孙浩然', + documentName: '内镜检查知情同意书', + department: '普外科', + status: 'pending', + method: 'wx', + updatedAt: '2026-08-27 08:50', + }, ], ranking: [ { id: 'doc-001', documentName: '住院患者知情同意书', department: '全院通用', baseCount: 58 }, @@ -137,6 +158,26 @@ const mockCampusOverviews: Record = { method: 'sms', updatedAt: '2026-08-27 08:30', }, + { + id: 'T0818012', + patientId: 'P202610003', + patientName: '赵某某', + documentName: '住院须知及告知书', + department: '消化内科', + status: 'pending', + method: 'ipad', + updatedAt: '2026-08-27 09:05', + }, + { + id: 'T0818013', + patientId: 'P202610007', + patientName: '孙浩然', + documentName: '内镜检查知情同意书', + department: '普外科', + status: 'pending', + method: 'wx', + updatedAt: '2026-08-27 08:50', + }, ], ranking: [ { @@ -215,6 +256,26 @@ const mockCampusOverviews: Record = { method: 'pad', updatedAt: '2026-08-27 08:05', }, + { + id: 'T0818024', + patientId: 'P202610003', + patientName: '赵某某', + documentName: '住院须知及告知书', + department: '消化内科', + status: 'pending', + method: 'ipad', + updatedAt: '2026-08-27 09:05', + }, + { + id: 'T0818025', + patientId: 'P202610007', + patientName: '孙浩然', + documentName: '内镜检查知情同意书', + department: '普外科', + status: 'pending', + method: 'wx', + updatedAt: '2026-08-27 08:50', + }, ], ranking: [ { @@ -281,8 +342,16 @@ function mapTodoStatus(status: BackendSigningTaskStatus): WorkbenchTodoStatus { } } +const METHOD_BY_BACKEND: Record = { + PAD: 'pad', + SMS: 'sms', + IPAD: 'ipad', + WX: 'wx', +} + function mapSigningMethod(method: BackendSigningMethod): SigningMethod { - return method === 'SMS' ? 'sms' : 'pad' + // 后端理论上只回这四个;未知取值按手写板兜底,避免整行渲染不出来 + return METHOD_BY_BACKEND[method] ?? 'pad' } function mapWorkbenchOverview(dto: WorkbenchOverviewResponseDto): WorkbenchOverviewResponse { diff --git a/clinical-web/src/api/workbench/records.ts b/clinical-web/src/api/workbench/records.ts index da1171e..82b66d7 100644 --- a/clinical-web/src/api/workbench/records.ts +++ b/clinical-web/src/api/workbench/records.ts @@ -16,6 +16,7 @@ import { DEMO_SIGNATURE_DATA_URL } from '@/utils/demo-signature' import { mockFlags } from '@/utils/mock-flags' import { buildFieldSpanHtml } from '@/utils/signing-fields' +import { formatSigningMethod, toBackendSigningMethod } from '@/utils/signing-method' import { campusOfDepartment, @@ -128,10 +129,13 @@ 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: 52, visitType: '住院', doc: 'mock-doc-admission', status: 'pending', method: 'ipad', days: 0, at: '09:05', source: '工作台发起' }, + { patient: '孙浩然', sex: '男', age: 54, visitType: '住院', doc: 'mock-doc-endoscopy', status: 'pending', method: 'wx', days: 0, at: '08:50' }, // 昨天 { 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: '文书选择错误,已重新发起' }, + { patient: '罗文静', sex: '女', age: 44, visitType: '住院', doc: 'mock-doc-mri', status: 'signed', method: 'ipad', days: 1, at: '11:35', minutes: 5 }, // 近 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: '工作台发起' }, @@ -142,6 +146,7 @@ const MOCK_RECORD_SEEDS: MockRecordSeed[] = [ { 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' }, + { patient: '梁俊豪', sex: '男', age: 35, visitType: '住院', doc: 'mock-doc-surgery', status: 'signed', method: 'wx', days: 6, at: '10:28', minutes: 4 }, // 近 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 }, @@ -447,25 +452,30 @@ export async function getSigningRecordDetail( * 如果每次刷新都在变,就没法用来核对签署链路了。 */ function buildMockEvents(record: SigningRecord): SigningTaskEvent[] { - const channel = record.method === 'pad' ? '手写板' : '短信' + const backendChannel = toBackendSigningMethod(record.method) + // 线上方式(短信 / 公众号)才有一条「已发送」留痕,现场方式是设备上直接采集 + const isOnline = record.method === 'sms' || record.method === 'wx' const createdAt = record.createdAt ?? record.updatedAt const events: SigningTaskEvent[] = [ { id: `${record.id}-created`, time: createdAt, - text: `张文静创建签署任务(${channel})`, + text: `张文静创建签署任务(${formatSigningMethod(record.method)})`, eventType: 'TASK_CREATED', - channel: record.method === 'pad' ? 'PAD' : 'SMS', + channel: backendChannel, }, ] - if (record.method === 'sms') { + if (isOnline) { events.push({ id: `${record.id}-sent`, time: createdAt, - text: `已向 ${record.patientName} 预留手机号发送签署短信`, + text: + record.method === 'wx' + ? `已向 ${record.patientName} 推送公众号签署通知` + : `已向 ${record.patientName} 预留手机号发送签署短信`, eventType: 'SEND_PREPARED', - channel: 'SMS', + channel: backendChannel, attemptNo: 1, }) } @@ -476,7 +486,7 @@ function buildMockEvents(record: SigningRecord): SigningTaskEvent[] { time: record.updatedAt, text: '已打开签名采集,等待患者完成签名', eventType: 'SIGNING_STARTED', - channel: 'PAD', + channel: backendChannel, }) } @@ -486,7 +496,7 @@ function buildMockEvents(record: SigningRecord): SigningTaskEvent[] { time: record.signedAt, text: `患者 ${record.signerName} 完成签名,系统已合成文书并双留存`, eventType: 'SIGNED', - channel: record.method === 'pad' ? 'PAD' : 'SMS', + channel: backendChannel, }) } diff --git a/clinical-web/src/api/workbench/report-analytics.ts b/clinical-web/src/api/workbench/report-analytics.ts index 0759623..327bd0c 100644 --- a/clinical-web/src/api/workbench/report-analytics.ts +++ b/clinical-web/src/api/workbench/report-analytics.ts @@ -182,6 +182,36 @@ function pickStatus(random: () => number): SigningTaskStatus { return 'signed' } +/** + * 四种签署方式的抽样权重。 + * + * 现场两种(手写板 / Pad)占比高,线上两种(短信 / 公众号)略低 —— 四种都要出现, + * 否则「签署方式分布」这个报表维度会缺行,演示时看不到新方式。 + * 权重和不必是 100,`pickMethod` 会自己算总和。 + */ +const METHOD_WEIGHTS: readonly { method: SigningMethod; weight: number }[] = [ + { method: 'pad', weight: 38 }, + { method: 'ipad', weight: 24 }, + { method: 'sms', weight: 26 }, + { method: 'wx', weight: 12 }, +] + +const METHOD_TOTAL_WEIGHT = METHOD_WEIGHTS.reduce((sum, item) => sum + item.weight, 0) + +function pickMethod(random: () => number): SigningMethod { + let roll = random() * METHOD_TOTAL_WEIGHT + + for (const item of METHOD_WEIGHTS) { + roll -= item.weight + + if (roll <= 0) { + return item.method + } + } + + return 'pad' +} + function pad(value: number): string { return String(value).padStart(2, '0') } @@ -219,7 +249,8 @@ function buildTasks(): ReportTaskRow[] { const patient = pick(random, DEMO_PATIENTS) const document = pick(random, mockDocumentCatalog) const status = pickStatus(random) - const method: SigningMethod = random() < 0.45 ? 'pad' : 'sms' + // 四种方式按现场/线上偏重的权重抽,保证「签署方式分布」四个维度都有量 + const method = pickMethod(random) // 越靠近今天的任务越少:真实签署量不会是均匀分布,均匀分布的趋势图一眼假 const daysAgo = Math.floor(DEMO_SPAN_DAYS * Math.pow(random(), 1.35)) diff --git a/clinical-web/src/api/workbench/signing.ts b/clinical-web/src/api/workbench/signing.ts index 1b4e993..9eb35e8 100644 --- a/clinical-web/src/api/workbench/signing.ts +++ b/clinical-web/src/api/workbench/signing.ts @@ -10,6 +10,7 @@ import { mockFlags } from '@/utils/mock-flags' import { readLocalSigningTemplates } from '@/utils/mock-document-library' import { MOCK_STORAGE_KEYS, readMockState, writeMockState } from '@/utils/mock-storage' import { request } from '@/utils/request' +import { ALL_SIGNING_METHODS, formatSigningMethod, toBackendSigningMethod } from '@/utils/signing-method' import { resendSigningSms as resendSigningSmsDelivery } from './deliveries' import type { @@ -51,7 +52,8 @@ const mockPatients: PatientProfile[] = [ sex: '男', age: 46, idCard: '310***********1234', - phone: '13800005678', + phone: '138****5678', + phoneFull: '13800005678', visits: [ { id: 'visit-001', @@ -76,7 +78,8 @@ const mockPatients: PatientProfile[] = [ sex: '女', age: 37, idCard: '310***********6721', - phone: '13900006789', + phone: '139****6789', + phoneFull: '13900006789', visits: [ { id: 'visit-003', @@ -94,7 +97,8 @@ const mockPatients: PatientProfile[] = [ sex: '男', age: 52, idCard: '310***********2846', - phone: '13700007890', + phone: '137****7890', + phoneFull: '13700007890', visits: [ { id: 'visit-004', @@ -112,7 +116,8 @@ const mockPatients: PatientProfile[] = [ sex: '女', age: 29, idCard: '310***********9152', - phone: '13600008901', + phone: '136****8901', + phoneFull: '13600008901', visits: [ { id: 'visit-005', @@ -130,7 +135,8 @@ const mockPatients: PatientProfile[] = [ sex: '男', age: 61, idCard: '310***********3488', - phone: '13500009012', + phone: '135****9012', + phoneFull: '13500009012', visits: [ { id: 'visit-006', @@ -252,6 +258,73 @@ const defaultMockRecords: SigningTaskRecord[] = [ expiresAt: '2026-08-30 14:20', updatedAt: '2026-08-27 08:20', }, + { + // Pad 移动端(床旁/诊间):原型种子里就有这一条,待签署,用来验证 Pad 方式的状态徽标与详情动作 + id: 'task-006', + templateVersionId: 'tpl-002-v1', + campus: '本部院区', + patientId: 'P202610003', + patientName: '赵某某', + sex: '男', + age: 52, + visitNo: 'ZY20260826027', + visitType: '住院', + visitDate: '2026-08-26', + documentId: 'tpl-002', + documentName: '住院须知及告知书', + department: '消化内科', + status: 'pending', + signerName: '赵某某', + method: 'ipad', + source: '工作台发起', + expiresAt: '2026-08-30 18:00', + updatedAt: '2026-08-27 09:05', + }, + { + // 微信公众号:四种方式里最晚加的一条,演示数据里必须有一条, + // 否则「公众号」的筛选项与状态徽标永远没有样本可看 + id: 'task-007', + templateVersionId: 'tpl-003-v3', + campus: '西院区', + patientId: 'P202610005', + patientName: '陈志强', + sex: '男', + age: 61, + visitNo: 'ZY20260826011', + visitType: '住院', + visitDate: '2026-08-26', + documentId: 'tpl-003', + documentName: '内镜检查知情同意书', + department: '普外科', + status: 'pending', + signerName: '陈志强', + method: 'wx', + source: '电子病历', + expiresAt: '2026-08-31 10:00', + updatedAt: '2026-08-27 09:40', + }, + { + // 短信线上:待签署的四种方式各要一条,列表徽标的四种配色才都能看到 + id: 'task-008', + templateVersionId: 'tpl-004-v1', + campus: '东院区', + patientId: 'P202610002', + patientName: '王某某', + sex: '女', + age: 37, + visitNo: 'JZ20260827018', + visitType: '门诊', + visitDate: '2026-08-27', + documentId: 'tpl-004', + documentName: 'MRI 磁共振检查知情同意书', + department: '放射科', + status: 'pending', + signerName: '王某某', + method: 'sms', + source: '工作台发起', + expiresAt: '2026-08-31 18:00', + updatedAt: '2026-08-27 09:50', + }, ] /** @@ -405,9 +478,15 @@ export function getMockSigningTask(taskId: string): SigningTaskRecord | null { return task ? cloneTask(task) : null } -/** 演示患者清单(仅 Mock 模式):给签署弹窗做“点一下就能定位”的输入提示 */ -export function getMockPatientHints(): { id: string; name: string }[] { - return useMockData ? mockPatients.map((item) => ({ id: item.id, name: item.name })) : [] +/** + * 演示患者清单(仅 Mock 模式)。 + * + * 给发起签署弹窗做「候选患者」下拉:原型允许按 患者ID / 姓名 / 证件号 / 就诊号 + * 模糊匹配,因此这里返回完整档案(克隆一份,避免调用方就地改动演示数据), + * 而不是只给 id + 姓名。真实模式返回空数组,候选下拉自然不出现。 + */ +export function getMockPatients(): PatientProfile[] { + return useMockData ? mockPatients.map(clonePatient) : [] } function isNotFoundError(error: unknown) { @@ -467,8 +546,16 @@ function normalizeVisitType(value: string): VisitType { return '其他' } +const METHOD_BY_BACKEND: Record = { + PAD: 'pad', + SMS: 'sms', + IPAD: 'ipad', + WX: 'wx', +} + function normalizeSigningMethod(value: BackendSigningMethod): SigningMethod { - return value === 'SMS' ? 'sms' : 'pad' + // 后端理论上只回这四个;出现未知取值时按手写板兜底,避免整行渲染不出来 + return METHOD_BY_BACKEND[value] ?? 'pad' } function normalizeTaskStatus(value: BackendSigningTaskStatus): SigningTaskStatus { @@ -531,8 +618,8 @@ function mapAvailableTemplate(dto: AvailableTemplateVersionResponseDto): Signing campusId: dto.campusId, category: dto.category || '未分类', description: `已发布版本 ${dto.versionNo}`, - // 可用模板接口没有返回通道 ACL;具体通道仍由后端在创建时校验。 - supportedMethods: ['pad', 'sms'], + // 可用模板接口不返回方式 ACL;四种方式都放行,实际允许范围由后端在创建时校验。 + supportedMethods: [...ALL_SIGNING_METHODS], // 接口不返回正文,只能带出哈希;正文由 hydrateTemplateContents 按需补回 contentSha256: dto.contentSha256, contentHtml: cachedContent || undefined, @@ -677,7 +764,7 @@ function createTemplateFromInput(input: CreateSigningTaskInput): SigningTemplate department: input.department, category: '—', description: '', - supportedMethods: ['pad', 'sms'], + supportedMethods: [...ALL_SIGNING_METHODS], } } @@ -708,7 +795,7 @@ function toBackendTaskQuery(query: SigningTaskQuery, options: SigningApiOptions } if (query.method && query.method !== 'all') { - params.signMethod = query.method === 'sms' ? 'SMS' : 'PAD' + params.signMethod = toBackendSigningMethod(query.method) } if (query.visitType && query.visitType !== 'all') { @@ -1041,7 +1128,7 @@ export async function createSigningTask(input: CreateSigningTaskInput): Promise< patientId: input.patientId, visitId: input.visitId, templateVersionId: input.templateVersionId, - signMethod: input.method === 'sms' ? 'SMS' : 'PAD', + signMethod: toBackendSigningMethod(input.method), idempotencyKey: createIdempotencyKey(), } const response = await request.post>( @@ -1244,7 +1331,7 @@ export async function getSigningTaskEvents(id: string): Promise(null) const initialTemplate = computed(() => props.initialTemplate ?? null) -// 演示模式下列出可用的 Mock 患者,点一下直接定位,省得记患者 ID(真实模式为空数组) -const patientHints = getMockPatientHints() - -function usePatientHint(id: string) { - patientId.value = id - errors.patient = '' - void locatePatient() -} const { + closeSuggestions, + departmentRows, + documentList, documentSearch, errors, - filteredTemplateGroups, + isMethodAllowed, loadingTemplates, loadTemplates, locatePatient, locating, maskPhone, method, + openSuggestions, patient, patientId, + patientSuggestions, + phonePlain, + pickPatient, + prescription, resetForNextTask, resetForm, selectMethod, selectTemplate, selectVisit, - selectedTemplate, selectedTemplateId, selectedVisit, selectedVisitId, smsDestination, submit: submitForm, submitting, + suggestionOpen, + toggleCategory, + toggleDepartment, + togglePhonePlain, visits, } = useSigningTaskForm({ campus: computed(() => appStore.selectedCampus), initialTemplate, }) +/** + * 候选患者下拉要在失焦后稍等再关:行上的动作走 mousedown(先于 blur 触发), + * 立刻关闭会让点击落空——原型同样用 160ms 延迟。 + */ +function handlePatientBlur() { + window.setTimeout(closeSuggestions, 160) +} + +/** 输入即展开候选并清掉上一次的定位报错 */ +function handlePatientInput() { + openSuggestions() + errors.patient = '' +} + +/** + * 演示用 Pad 设备(原型 #ntIpadDev 的三个选项)。 + * 设备列表没有接口,真实模式下不编造,改为提示由设备适配器选择。 + */ +const IPAD_DEVICES = [ + 'Pad Pro 11″(儿科急诊 · 移动签署车01)', + 'Pad Pro 11″(儿科急诊 · 诊间02)', + 'Pad Air(核医学科 · PET护士台)', +] +const ipadDevice = ref(IPAD_DEVICES[0]) + +/* ---------------- 文档签署预览(原型 ntPreviewDoc) ---------------- */ + +/** + * 预览用空快照:原型预览的是**空白模板版式**(占位符留成下划线), + * 不预填某位患者的信息——那是签署时才有的事,提前填上会让人误以为已带出数据。 + */ +const PREVIEW_SNAPSHOT: SigningDocumentData = { + patientName: '', + sex: '', + age: '', + visitNo: '', + visitDate: '', + phone: '', + signedAt: '', +} + +const previewTemplate = ref(null) + +const previewHtml = computed(() => { + const contentHtml = previewTemplate.value?.contentHtml + + return contentHtml ? buildSigningDocumentHtml(contentHtml, PREVIEW_SNAPSHOT, false) : '' +}) + +function openPreview(template: SigningTemplate) { + previewTemplate.value = template +} + +function closePreview() { + previewTemplate.value = null +} + +/* ---------------- 提交 ---------------- */ + async function submitTask(keepOpen: boolean) { try { const task = await submitForm() @@ -73,9 +137,10 @@ async function submitTask(keepOpen: boolean) { ElMessage.success(`已创建 1 个签署任务${smsMessage}`) if (keepOpen) { + // 原型:保留当前患者与就诊,只清掉文书与签署方式,方便连着发起多份 resetForNextTask() await nextTick() - patientInput.value?.focus() + ElMessage.info('保留当前患者与就诊,可继续发起') return } @@ -89,6 +154,7 @@ watch( () => props.visible, async (visible) => { if (!visible) { + previewTemplate.value = null return } @@ -118,22 +184,47 @@ watch(
+
A 患者定位 - 输入患者 ID / 门诊号 / 住院号,回车定位
-
- 演示患者 - -
+
- - {{ patient.name }} - {{ patient.sex }} · {{ patient.age }}岁 + + {{ patient.name }} + {{ patient.sex }} · {{ patient.age }}岁 + 在院 身份证号{{ patient.idCard }} 手机号 - {{ maskPhone(patient.phone) }} + {{ phonePlain ? (patient.phoneFull ?? patient.phone) : maskPhone(patient.phone) }} + 患者 ID{{ patient.id }} - 在院
+

{{ errors.patient }}

+
B 关联就诊 - 默认最近一次,在院优先
@@ -188,14 +277,28 @@ watch( @click="selectVisit(visit)" > - {{ visit.type }} · {{ visit.department }} - {{ visit.visitNo }} + {{ visit.type }} · {{ visit.department }} + + {{ visit.visitNo }} + {{ visit.visitDate }} ● 在院 - 最近一次 + +
+ + 关联处方 · {{ prescription.no }} + {{ prescription.description }} + 已自动关联 +

请先定位患者…

{{ errors.visit }}

@@ -203,131 +306,145 @@ watch(
+
C 选择文档 - 单选一份
-
- 已选: - {{ selectedTemplate.name }} - -
-
正在加载文档模板…
-
-
-
- ▾{{ department.name }} -
-
-
- ▾{{ category.name - }}{{ category.templates.length }} -
- +
正在加载文档模板…
+
+ +
+ +
无匹配文档
+
+ + +
+
+ {{ documentList.label }} · {{ documentList.rows.length }} 份
+ +
在左侧选择科室或分类,此处展示对应文档
-
没有匹配的文档模板

{{ errors.document }}

+
D 签署方式 - 二选一
-
+
📱 短信将发送至 - {{ patient ? maskPhone(patient.phone) : '定位患者后显示' }} + + + 来源:HIS 带出 + +
+

{{ errors.sms }}

+
+ +
+
+ 📲 + 签署设备 + + + Pad 设备列表接口待接入,真实环境由设备适配器选择 +
- -
@@ -335,7 +452,6 @@ watch(
- 患者手写签名图片将合成至文档签名域,PDF 原件与签名原图双留存
+ + +
+ +
@@ -373,6 +513,12 @@ watch( background: rgb(9 40 52 / 45%); } +/* 预览叠在发起弹窗之上,遮罩再深一档,避免两层弹窗看起来是同一层 */ +.dialog-mask--stacked { + z-index: 60; + background: rgb(9 40 52 / 55%); +} + .dialog-card { width: 680px; max-width: 96vw; @@ -411,12 +557,20 @@ watch( .dialog-close { flex-shrink: 0; + width: 28px; + height: 28px; padding: 0; margin-left: auto; color: var(--mut); - font-size: 20px; + font-size: 18px; line-height: 1; background: transparent; + border-radius: 6px; +} + +.dialog-close:hover { + color: var(--ink); + background: #eef4f6; } .dialog-body { @@ -432,6 +586,19 @@ watch( border-radius: 8px; } +/** + * A 区里的候选患者下拉是绝对定位、要溢出到下一个分区之上,被 .form-section 的 + * overflow:hidden 裁掉就只剩一条缝(原型同样踩过,用 .nt-sec:has(#ntSug) 放行)。 + * 放行 overflow 后表头方角会顶出圆角边框,单独把上方两角补回来。 + */ +.form-section:has(.nt-sug) { + overflow: visible; +} + +.form-section:has(.nt-sug) > .section-header { + border-radius: 7px 7px 0 0; +} + .section-header { display: flex; gap: 8px; @@ -441,13 +608,6 @@ watch( background: #f5f9fa; } -.section-header > span:not(.section-index) { - margin-left: auto; - color: var(--mut); - font-size: 11px; - font-weight: 400; -} - .section-index { display: grid; width: 18px; @@ -464,33 +624,612 @@ watch( padding: 8px 12px; } +/* A 区:输入框与候选下拉同一个定位容器,下拉才能贴着输入框下沿 */ + .patient-search { + position: relative; display: flex; gap: 10px; } -.patient-search input, -.sms-row input { +.patient-search input { min-width: 0; + flex: 1; padding: 7px 10px; color: var(--ink); + font-size: 14px; + letter-spacing: 1px; + background: #fff; border: 1px solid var(--line); border-radius: 6px; } -.patient-search input { - flex: 1; - font-size: 14px; - letter-spacing: 1px; -} - .patient-search input:focus, .sms-row input:focus, -.section-header input:focus { +.nt-search:focus, +.device-select:focus { outline: none; border-color: var(--brand); } +.nt-sug { + position: absolute; + z-index: 30; + top: calc(100% + 4px); + right: 104px; + left: 0; + max-height: 238px; + overflow: auto; + background: #fff; + border: 1px solid var(--line); + border-radius: 8px; + box-shadow: 0 10px 28px rgb(15 60 80 / 14%); +} + +.nt-sug .shd { + padding: 7px 12px 3px; + color: var(--mut); + font-size: 11px; + letter-spacing: 0.5px; +} + +.nt-sug .srow { + display: flex; + gap: 8px; + align-items: center; + padding: 7px 12px; + color: var(--ink); + font-size: 13px; + white-space: nowrap; + cursor: pointer; +} + +.nt-sug .srow:hover { + background: var(--brand-l); +} + +.nt-sug .srow b { + font-size: 13px; +} + +.nt-sug .srow .pid { + color: var(--brand); + font-size: 12px; + letter-spacing: 0.5px; +} + +.nt-sug .srow .mut { + font-size: 12px; +} + +.nt-sug .srow .mut:last-child { + margin-left: auto; +} + +.nt-sug .empty { + padding: 10px 12px; + color: var(--mut); + font-size: 12px; +} + +.patient-card { + display: flex; + gap: 14px; + align-items: center; + padding: 7px 12px; + margin-top: 8px; + background: var(--brand-l); + border: 1px solid var(--brand); + border-radius: 8px; +} + +.patient-avatar { + flex-shrink: 0; + font-size: 22px; + line-height: 1; +} + +.patient-name { + color: var(--brand-d); + font-size: 15px; + font-weight: 700; +} + +.patient-summary { + color: var(--ink); + font-size: 12px; + white-space: nowrap; +} + +.patient-field { + display: inline-flex; + gap: 4px; + align-items: center; + color: var(--ink); + font-size: 12px; + white-space: nowrap; +} + +.patient-field small { + color: var(--mut); + font-size: 11px; +} + +.in-hospital { + flex-shrink: 0; + padding: 1px 7px; + color: #fff; + font-size: 10.5px; + background: var(--err); + border-radius: 20px; +} + +.field-error { + margin: 6px 0 0; + color: var(--err); + font-size: 12px; +} + +/* B 区 */ + +.visit-list { + display: flex; + flex-direction: column; + gap: 6px; +} + +.visit-option { + display: flex; + gap: 10px; + align-items: center; + width: 100%; + padding: 5px 10px; + color: var(--ink); + font-size: 12px; + text-align: left; + background: #fff; + border: 1px solid var(--line); + border-radius: 6px; +} + +.visit-option:hover, +.visit-option--active { + border-color: var(--brand); +} + +.visit-option--active { + background: var(--brand-l); +} + +.radio-mark { + display: inline-block; + width: 13px; + height: 13px; + flex-shrink: 0; + border: 2px solid #b8cbd4; + border-radius: 50%; +} + +.visit-option--active .radio-mark { + background: radial-gradient(circle, var(--brand) 42%, transparent 46%); + border-color: var(--brand); +} + +.visit-title { + font-size: 12px; +} + +.visit-number { + padding: 0 7px; + color: var(--mut); + font-size: 10.5px; + background: #eef2f4; + border-radius: 4px; +} + +.visit-number--hospital { + color: var(--err); + background: var(--err-l); +} + +.visit-date { + color: var(--mut); + font-size: 11.5px; +} + +.visit-current { + margin-left: auto; + color: var(--ok); + font-size: 10.5px; + font-weight: 700; + white-space: nowrap; +} + +.visit-current--recent { + color: var(--brand); +} + +.visit-prescription { + display: flex; + gap: 10px; + align-items: center; + padding: 5px 10px; + font-size: 12px; + background: #f4f9f6; + border: 1px solid #bfe3cf; + border-radius: 6px; +} + +.visit-prescription__icon { + flex: none; +} + +.visit-prescription strong { + color: var(--ok); + font-size: 12px; + white-space: nowrap; +} + +.visit-prescription__text { + flex: 1; + overflow: hidden; + color: var(--mut); + white-space: nowrap; + text-overflow: ellipsis; +} + +.tag { + display: inline-block; + flex: none; + padding: 2px 10px; + font-size: 11.5px; + font-weight: 700; + border-radius: 20px; +} + +.tag--done { + color: var(--ok); + background: var(--ok-l); +} + +.section-empty, +.tree-empty { + padding: 4px 0; + margin: 0; + color: var(--mut); + font-size: 12px; +} + +/* C + D 并排 */ + +.document-method-grid { + display: flex; + gap: 12px; + align-items: stretch; +} + +.document-section { + min-width: 0; + flex: 1; +} + +.method-section { + display: flex; + flex: 0 0 360px; + flex-direction: column; +} + +.document-section, +.method-section { + margin-bottom: 0; +} + +.method-section .section-body { + display: flex; + flex: 1; + flex-direction: column; +} + +.nt-search { + width: 240px; + padding: 4px 10px; + margin-left: auto; + color: var(--ink); + font-size: 12.5px; + background: #fff; + border: 1px solid var(--line); + border-radius: 6px; +} + +/* 两栏:左 196px 科室→分类,右 1fr 文档标题 */ + +.template-tree { + display: grid; + grid-template-columns: 196px minmax(0, 1fr); + border: 1px solid var(--line); + border-radius: 8px; +} + +.nt-tree-left { + max-height: 340px; + padding: 2px 6px 2px 2px; + overflow-y: auto; + border-right: 1px solid var(--line); +} + +.nt-tree-right { + max-height: 340px; + padding: 6px 4px 6px 10px; + overflow-y: auto; +} + +.nt-dept, +.nt-cat { + display: flex; + gap: 6px; + align-items: center; + width: 100%; + color: var(--ink); + text-align: left; + background: transparent; + border-radius: 6px; +} + +.nt-dept { + padding: 5px 6px; + font-size: 12.5px; +} + +.nt-cat { + padding: 4px 6px 4px 28px; + font-size: 12px; +} + +.nt-dept:hover, +.nt-cat:hover { + background: var(--brand-l); +} + +.nt-dept--active { + color: var(--brand); + font-weight: 700; + background: #eef7fb; +} + +.nt-cat--active { + color: var(--brand); + font-weight: 700; + background: #eef7fb; +} + +.nt-arrow { + flex: none; + width: 10px; + font-size: 10px; +} + +.nt-dept__name, +.nt-cat__name { + flex: 1; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; +} + +.nt-count { + color: var(--mut); + font-size: 11px; +} + +.nt-list-label { + margin: 0 0 6px; + color: var(--mut); + font-size: 11px; +} + +.nt-list-breadcrumb { + margin: 5px 0 1px; + color: var(--mut); + font-size: 10.5px; +} + +.doc-option { + display: flex; + gap: 8px; + align-items: center; + padding: 5px 8px; + font-size: 13px; + cursor: pointer; + border-radius: 6px; + user-select: none; +} + +.doc-option:hover { + background: #f0f6f8; +} + +.doc-option--active { + background: var(--brand-l); +} + +.doc-option__mark { + display: flex; + width: 15px; + height: 15px; + flex-shrink: 0; + align-items: center; + justify-content: center; + color: #fff; + font-size: 10px; + border: 1.5px solid #b8cbd4; + border-radius: 50%; +} + +.doc-option--active .doc-option__mark { + background: var(--brand); + border-color: var(--brand); +} + +.doc-option__name { + flex: 1; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; +} + +.nt-preview { + flex: 0 0 auto; + padding: 2px 9px; + color: var(--brand); + font-size: 11px; + background: #fff; + border: 1px solid #cfe4ec; + border-radius: 5px; +} + +.nt-preview:hover { + background: var(--brand-l); + border-color: var(--brand); +} + +/* D 区 */ + +.method-list { + display: flex; + flex-direction: column; + gap: 10px; +} + +.method-option { + display: flex; + gap: 10px; + align-items: flex-start; + width: 100%; + padding: 7px 12px; + color: var(--ink); + text-align: left; + background: #fff; + border: 1.5px solid var(--line); + border-radius: 8px; +} + +.method-option:hover:not(:disabled), +.method-option--active { + border-color: var(--brand); +} + +.method-option--active { + background: var(--brand-l); +} + +.method-option:disabled { + cursor: not-allowed; + opacity: 0.45; +} + +.method-icon { + flex-shrink: 0; + font-size: 19px; + line-height: 1.3; +} + +.method-option strong { + display: block; + min-width: 0; + flex: 1; + margin-bottom: 1px; + font-size: 13px; +} + +.sms-confirm { + padding: 9px 12px; + margin-top: 10px; + background: var(--brand-l); + border: 1px dashed var(--brand); + border-radius: 8px; +} + +.sms-row { + display: flex; + gap: 8px; + align-items: center; +} + +.sms-icon { + flex-shrink: 0; + font-size: 16px; +} + +.sms-row strong { + flex-shrink: 0; + color: var(--brand-d); + font-size: 12.5px; +} + +.sms-row input { + width: 170px; + min-width: 0; + padding: 7px 10px; + color: var(--ink); + font-size: 13px; + background: #fff; + border: 1px solid var(--line); + border-radius: 6px; +} + +.device-select { + width: 100%; + min-width: 0; + padding: 7px 10px; + color: var(--ink); + font-size: 12.5px; + background: #fff; + border: 1px solid var(--line); + border-radius: 6px; +} + +.phone-source { + display: inline-flex; + flex: none; + align-items: center; + padding: 2px 8px; + color: var(--info); + font-size: 11px; + background: var(--info-l); + border-radius: 10px; +} + +.action-hint { + color: var(--mut); + font-size: 11.5px; +} + +.sms-confirm .field-error { + margin-top: 6px; + padding-left: 28px; +} + +/* 底部 */ + +.dialog-footer { + display: flex; + gap: 10px; + align-items: center; + flex-shrink: 0; + padding: 10px 18px; + color: var(--mut); + font-size: 12px; + background: #f8fbfc; + border-top: 1px solid var(--line); + border-radius: 0 0 14px 14px; +} + +.footer-spacer { + flex: 1; +} + +/* 按钮 */ + .action-button { flex-shrink: 0; padding: 8px 16px; @@ -520,405 +1259,98 @@ watch( background: var(--brand-l); } -.patient-card { - display: flex; - gap: 12px; - align-items: center; - flex-wrap: wrap; - padding: 7px 12px; - margin-top: 8px; - background: var(--brand-l); - border: 1px solid var(--brand); - border-radius: 8px; -} - -.patient-avatar { - display: grid; - width: 28px; - height: 28px; - color: #fff; - font-size: 13px; - background: var(--brand); - border-radius: 50%; - place-items: center; -} - -.patient-card > strong { - color: var(--brand-d); - font-size: 15px; -} - -.patient-card > span:not(.patient-field):not(.patient-avatar):not(.in-hospital) { - color: var(--ink); - font-size: 12px; -} - -.patient-field { - display: inline-flex; - gap: 4px; - align-items: center; - color: var(--ink); - font-size: 12px; - white-space: nowrap; -} - -.patient-field small { - color: var(--mut); +.action-button--mini { + padding: 2px 8px; font-size: 11px; + font-weight: 400; + border-radius: 5px; } -.in-hospital { - padding: 1px 7px; - color: #fff; - font-size: 10.5px; - background: var(--err); - border-radius: 20px; -} +/* 预览弹窗 */ -.field-error { - margin: 6px 0 0; - color: var(--err); - font-size: 12px; -} - -.visit-list { +.preview-card { display: flex; + width: 900px; + max-width: 96vw; + max-height: calc(100vh - 32px); flex-direction: column; - gap: 6px; -} - -.visit-option { - display: flex; - gap: 10px; - align-items: center; - width: 100%; - padding: 5px 10px; - color: var(--ink); - text-align: left; + margin: auto; + overflow: hidden; background: #fff; - border: 1px solid var(--line); - border-radius: 6px; + border-radius: 14px; + box-shadow: var(--sh-modal); + animation: dialog-pop 0.18s ease; } -.visit-option:hover, -.visit-option--active { - border-color: var(--brand); +.preview-body { + min-height: 0; + padding: 16px 18px; + overflow-y: auto; + background: #f1f5f7; } -.visit-option--active { - background: var(--brand-l); +.preview-paper { + padding: 26px 32px 30px; + background: #fff; + border-radius: var(--r); + box-shadow: var(--sh); } -.radio-mark { - display: inline-block; - width: 13px; - height: 13px; - flex-shrink: 0; - border: 2px solid #b8cbd4; - border-radius: 50%; -} - -.visit-option--active .radio-mark { - background: radial-gradient(circle, var(--brand) 42%, transparent 46%); - border-color: var(--brand); -} - -.visit-option strong { - font-size: 12px; -} - -.visit-number, -.visit-date { - color: var(--mut); - font-size: 11.5px; -} - -.visit-current { - margin-left: auto; - color: var(--ok); - font-size: 10.5px; - font-weight: 700; - white-space: nowrap; -} - -.visit-current--recent { - color: var(--brand); -} - -.section-empty, -.tree-empty, -.tree-loading { - padding: 4px 0; +.preview-empty { + padding: 40px 16px; margin: 0; color: var(--mut); - font-size: 12px; -} - -.document-method-grid { - display: grid; - grid-template-columns: minmax(0, 1fr) 360px; - gap: 12px; - align-items: stretch; -} - -.document-section, -.method-section { - margin-bottom: 0; -} - -.document-section .section-header input { - width: 240px; - padding: 4px 10px; - margin-left: auto; - color: var(--ink); font-size: 12.5px; + text-align: center; background: #fff; - border: 1px solid var(--line); - border-radius: 6px; + border-radius: var(--r); } -.document-section .section-header > span:not(.section-index) { - margin-left: 4px; +/* 注入的文书 HTML 不携带 scoped 属性,统一用 :deep 覆盖基础文书样式 */ + +.preview-paper :deep(p) { + margin: 0 0 6px; + color: #1c2b33; + font-size: 13.5px; + line-height: 1.85; } -.selected-template { - display: flex; - gap: 5px; - align-items: center; - padding: 5px 8px; - margin-bottom: 6px; - color: var(--brand-d); - font-size: 12px; - background: var(--brand-l); - border-radius: 6px; +.preview-paper :deep(h2) { + margin: 0 0 10px; + font-size: 18px; + text-align: center; } -.selected-template strong { - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; +.preview-paper :deep(h3) { + margin: 14px 0 6px; + font-size: 14px; } -.selected-template button { - padding: 0 3px; - margin-left: auto; - color: var(--mut); - font-size: 16px; - line-height: 1; - background: transparent; -} - -.template-tree { - height: 252px; - padding: 6px; - overflow-y: auto; - border: 1px solid var(--line); - border-radius: 8px; -} - -.tree-label { - display: flex; - gap: 6px; - align-items: center; - padding: 5px 6px; - font-size: 13px; -} - -.tree-label > span { - width: 12px; - color: var(--mut); - font-size: 10px; -} - -.tree-category { - margin-left: 18px; -} - -.tree-label--category { - color: var(--ink); - font-size: 12px; -} - -.tree-label small { - margin-left: auto; - color: var(--mut); - font-size: 11px; -} - -.document-option { - display: flex; - width: calc(100% - 10px); - gap: 8px; - align-items: center; - padding: 5px 6px; - margin-left: 10px; - color: var(--ink); - font-size: 12.5px; - text-align: left; - background: transparent; - border-radius: 6px; -} - -.document-option:hover, -.document-option--active { - background: var(--brand-l); -} - -.radio-mark--document { - display: grid; - width: 15px; - height: 15px; - color: transparent; - font-size: 10px; - border: 1.5px solid #b8cbd4; - place-items: center; -} - -.document-option--active .radio-mark--document { - color: #fff; - background: var(--brand); - border-color: var(--brand); -} - -.method-list { - display: flex; - flex-direction: column; - gap: 10px; -} - -.method-option { - display: flex; +.preview-paper :deep(table) { width: 100%; - gap: 10px; - align-items: flex-start; - padding: 8px 12px; - color: var(--ink); - text-align: left; - background: #fff; - border: 1.5px solid var(--line); - border-radius: 8px; -} - -.method-option:hover:not(:disabled), -.method-option--active { - border-color: var(--brand); -} - -.method-option--active { - background: var(--brand-l); -} - -.method-option:disabled { - cursor: not-allowed; - opacity: 0.45; -} - -.method-icon { - font-size: 19px; - line-height: 1.3; -} - -.method-option strong, -.method-option small { - display: block; -} - -.method-option strong { - margin-bottom: 1px; - font-size: 13px; -} - -.method-option small { - overflow: hidden; - color: var(--mut); - font-size: 11px; - line-height: 1.5; - text-overflow: ellipsis; - white-space: nowrap; -} - -.sms-confirm { - padding: 9px 12px; - margin-top: 10px; - background: var(--brand-l); - border: 1px dashed var(--brand); - border-radius: 8px; -} - -.sms-row { - display: flex; - gap: 8px; - align-items: center; -} - -.sms-row strong { - flex-shrink: 0; - color: var(--brand-d); + margin: 8px 0 12px; + border-collapse: collapse; + table-layout: auto; font-size: 12.5px; + line-height: 1.6; } -.sms-icon { - font-size: 16px; +.preview-paper :deep(th), +.preview-paper :deep(td) { + padding: 5px 7px; + border: 1px solid #52646e; + vertical-align: middle; + overflow-wrap: anywhere; + word-break: break-word; } -.sms-confirm p { - padding-left: 28px; - margin: 6px 0 0; - color: var(--mut); - font-size: 11.5px; -} - -.sms-destination-field { - display: flex; - gap: 8px; - align-items: center; - padding-left: 28px; - margin-top: 9px; -} - -.sms-destination-field span { - flex-shrink: 0; - color: var(--brand-d); - font-size: 12px; +.preview-paper :deep(th) { font-weight: 700; + background: #f2f6f8; } -.sms-destination-field input { - min-width: 0; - flex: 1; - padding: 6px 9px; - color: var(--ink); - font: inherit; - font-size: 12px; - background: #fff; - border: 1px solid var(--line); - border-radius: 6px; -} - -.sms-destination-field input:focus { - outline: none; - border-color: var(--brand); -} - -.sms-confirm .field-error { - padding-left: 28px; -} - -.dialog-footer { - display: flex; - gap: 10px; - align-items: center; - flex-shrink: 0; - padding: 10px 18px; - color: var(--mut); - font-size: 12px; - background: #f8fbfc; - border-top: 1px solid var(--line); - border-radius: 0 0 14px 14px; -} - -.footer-spacer { - flex: 1; +.preview-paper :deep(img) { + max-width: 100%; } @keyframes dialog-pop { @@ -933,13 +1365,13 @@ watch( } } -@media (max-width: 1000px) { +@media (max-width: 1100px) { .document-method-grid { - grid-template-columns: 1fr; + flex-direction: column; } .method-section { - margin-bottom: 8px; + flex: 1 1 auto; } } @@ -950,61 +1382,23 @@ watch( gap: 5px; } - .section-header { - align-items: flex-start; - flex-wrap: wrap; + .template-tree { + grid-template-columns: 1fr; } - .section-header > span:not(.section-index) { - margin-left: 0; + .nt-tree-left { + max-height: 200px; + border-right: none; + border-bottom: 1px solid var(--line); } - .document-section .section-header input { - width: 100%; - margin-left: 26px; + .nt-sug { + right: 0; } .dialog-footer { align-items: flex-end; flex-wrap: wrap; } - - .dialog-footer > span:first-child { - width: 100%; - } - - .sms-destination-field { - align-items: flex-start; - flex-direction: column; - } - - .sms-destination-field input { - width: 100%; - } -} -.demo-patients { - display: flex; - flex-wrap: wrap; - gap: 6px; - align-items: center; - margin-top: 8px; -} - -.demo-patients__label { - color: var(--mut); - font-size: 11.5px; -} - -.demo-patients__chip { - padding: 3px 8px; - color: var(--brand); - font-size: 11.5px; - background: var(--brand-l); - border: none; - border-radius: 10px; -} - -.demo-patients__chip:hover { - filter: brightness(0.95); } diff --git a/clinical-web/src/composables/useSigningTaskForm.ts b/clinical-web/src/composables/useSigningTaskForm.ts index e470dd0..a4212f1 100644 --- a/clinical-web/src/composables/useSigningTaskForm.ts +++ b/clinical-web/src/composables/useSigningTaskForm.ts @@ -5,6 +5,7 @@ import { sendSigningSms } from '@/api/workbench/deliveries' import { collectFieldDefs } from '@/utils/signing-fields' import { createSigningTask, + getMockPatients, getPatientProfile, getSigningTemplates, isSigningMockEnabled, @@ -23,14 +24,65 @@ export interface UseSigningTaskFormOptions { initialTemplate?: MaybeRefOrGetter } -interface TemplateCategoryGroup { +/** 左侧科室行(原型 #ntTree 左列) */ +export interface DocumentTreeDepartmentRow { name: string - templates: SigningTemplate[] + /** 该科室命中文书总数(含分类内) */ + count: number + open: boolean + active: boolean + categories: Array<{ name: string; count: number; active: boolean }> } -interface TemplateDepartmentGroup { - name: string - categories: TemplateCategoryGroup[] +/** 右侧文档行;搜索态下带出「科室 · 分类」小标题(与原型一致) */ +export interface DocumentListRow { + template: SigningTemplate + breadcrumb: string +} + +/** 关联处方(原型 V6.4:按就诊号确定性生成,仅演示数据) */ +export interface LinkedPrescription { + no: string + description: string +} + +/** + * 演示处方池。原型 RX_POOL 逐条对齐。 + * 真实模式没有处方接口,因此只在 Mock 模式下生成,绝不凭空造一条看起来真实的处方。 + */ +const PRESCRIPTION_POOL = [ + { item: '碘佛醇注射液 100ml', usage: '静脉注射 · 检查用' }, + { item: '阿莫西林胶囊 0.25g×24', usage: '口服 每日3次' }, + { item: '布洛芬缓释胶囊 0.3g×20', usage: '口服 每日2次' }, + { item: '奥美拉唑肠溶片 20mg×14', usage: '口服 每日1次 · 晨服' }, + { item: '0.9%氯化钠注射液 250ml', usage: '静脉滴注 · 检查前水化' }, +] + +/** + * 按就诊号确定性生成演示处方。 + * + * 用哈希而不是随机:同一就诊号每次打开弹窗必须带出同一条处方, + * 否则「关联处方」会随刷新变来变去,演示时没法解释。 + */ +function buildPrescription(visit: PatientVisit | null): LinkedPrescription | null { + if (!visit) { + return null + } + + let seed = 0 + + for (let index = 0; index < visit.visitNo.length; index += 1) { + seed = (seed * 31 + visit.visitNo.charCodeAt(index)) % 9973 + } + + const first = PRESCRIPTION_POOL[seed % PRESCRIPTION_POOL.length] + const second = PRESCRIPTION_POOL[(seed + 2) % PRESCRIPTION_POOL.length] + const extra = seed % 3 === 0 ? `;${second.item}(${second.usage})` : '' + + return { + no: `Rx${visit.visitNo.replace(/\D/g, '').slice(-8)}`, + description: `${first.item}(${first.usage})${extra}`, + } } export function useSigningTaskForm(options: UseSigningTaskFormOptions) { @@ -46,6 +98,15 @@ export function useSigningTaskForm(options: UseSigningTaskFormOptions) { const loadingTemplates = ref(false) const locating = ref(false) const submitting = ref(false) + /** 手机号是否明文展示(原型 togglePh);明文查看会记审计日志,因此每次开弹窗都要复位 */ + const phonePlain = ref(false) + /** 候选患者下拉是否展开(原型 #ntSug 的 .on) */ + const suggestionOpen = ref(false) + /** 左列展开的科室(原型 ntOpen 的 'd:' 键) */ + const expandedDepartments = ref([]) + /** 左列选中的科室与分类(原型 ntDeptSel / ntCatSel) */ + const selectedDepartment = ref('') + const selectedCategory = ref('') const errors = reactive({ patient: '', @@ -54,6 +115,9 @@ export function useSigningTaskForm(options: UseSigningTaskFormOptions) { sms: '', }) + /** 演示候选患者(真实模式为空数组,候选下拉自然不出现) */ + const patientCandidates = getMockPatients() + const selectedVisit = computed( () => visits.value.find((visit) => visit.id === selectedVisitId.value) ?? null, ) @@ -62,7 +126,35 @@ export function useSigningTaskForm(options: UseSigningTaskFormOptions) { () => templates.value.find((template) => template.id === selectedTemplateId.value) ?? null, ) - const filteredTemplateGroups = computed(() => { + /** 关联处方:随选中就诊带出(仅演示数据) */ + const prescription = computed(() => + isSigningMockEnabled ? buildPrescription(selectedVisit.value) : null, + ) + + /** + * 候选患者:原型 patMatches 的匹配口径——患者ID / 姓名 / 证件号 / 就诊号, + * 任一命中即算匹配;关键字为空时列出全部候选。 + */ + const patientSuggestions = computed(() => { + const keyword = patientId.value.trim() + + if (!keyword) { + return patientCandidates + } + + const lower = keyword.toLowerCase() + + return patientCandidates.filter( + (item) => + item.id.toLowerCase().includes(lower) || + item.name.includes(keyword) || + item.idCard.includes(keyword) || + item.visits.some((visit) => visit.visitNo.toLowerCase().includes(lower)), + ) + }) + + /** 命中关键字的科室 → 分类 → 文书(原型 renderNtTree 的组装段) */ + const matchedDepartments = computed(() => { const keyword = documentSearch.value.trim().toLowerCase() const grouped = new Map>() @@ -85,13 +177,86 @@ export function useSigningTaskForm(options: UseSigningTaskFormOptions) { grouped.set(template.department, categories) }) - return [...grouped.entries()].map(([department, categories]) => ({ - name: department, - categories: [...categories.entries()].map(([category, categoryTemplates]) => ({ - name: category, - templates: categoryTemplates, - })), - })) + return [...grouped.entries()].map(([name, categories]) => { + const categoryRows = [...categories.entries()].map(([categoryName, items]) => ({ + name: categoryName, + templates: items, + })) + + return { + name, + categories: categoryRows, + templates: categoryRows.flatMap((category) => category.templates), + } + }) + }) + + /** 搜索态:所有科室展开、不区分选中;否则按用户展开/选中状态渲染 */ + const searching = computed(() => documentSearch.value.trim().length > 0) + + const departmentRows = computed(() => + matchedDepartments.value.map((department) => { + const open = searching.value || expandedDepartments.value.includes(department.name) + + return { + name: department.name, + count: department.templates.length, + open, + active: + !searching.value && + selectedDepartment.value === department.name && + !selectedCategory.value, + categories: department.categories.map((category) => ({ + name: category.name, + count: category.templates.length, + active: + !searching.value && + selectedDepartment.value === department.name && + selectedCategory.value === category.name, + })), + } + }), + ) + + /** 右列数据源与标题(原型 renderNtTree 的 rightDocs / rightLbl) */ + const documentList = computed<{ label: string; rows: DocumentListRow[] }>(() => { + if (searching.value) { + return { + label: '搜索结果', + rows: matchedDepartments.value.flatMap((department) => + department.categories.flatMap((category) => + category.templates.map((template) => ({ + template, + breadcrumb: `${department.name} · ${category.name}`, + })), + ), + ), + } + } + + const current = matchedDepartments.value.find( + (department) => department.name === selectedDepartment.value, + ) + + if (!current) { + return { label: '', rows: [] } + } + + if (selectedCategory.value) { + const category = current.categories.find((item) => item.name === selectedCategory.value) + + if (category) { + return { + label: `${current.name} · ${category.name}`, + rows: category.templates.map((template) => ({ template, breadcrumb: '' })), + } + } + } + + return { + label: current.name, + rows: current.templates.map((template) => ({ template, breadcrumb: '' })), + } }) function getInitialTemplate() { @@ -109,6 +274,61 @@ export function useSigningTaskForm(options: UseSigningTaskFormOptions) { } } + /** + * 首次进入默认选中第一个有文书的科室并展开(原型 renderNtTree 的 _init 分支), + * 这样右列一进来就有内容,不会是一片空白等人去点。 + */ + function initDocumentTree() { + const first = matchedDepartments.value.find((department) => department.templates.length) + + if (!first) { + return + } + + selectedDepartment.value = first.name + selectedCategory.value = '' + expandedDepartments.value = [first.name] + } + + /** 从文档库带文书进来时:展开并选中该文书所属科室,右列直接展示 */ + function focusTemplateDepartment(template: SigningTemplate) { + selectedDepartment.value = template.department + selectedCategory.value = '' + + if (!expandedDepartments.value.includes(template.department)) { + expandedDepartments.value = [...expandedDepartments.value, template.department] + } + } + + function toggleDepartment(name: string) { + const expanded = expandedDepartments.value.includes(name) + + // 已选中且已展开的科室再点一次 = 收起;否则展开并选中,同时清掉分类选中 + if (selectedDepartment.value === name && !selectedCategory.value && expanded) { + expandedDepartments.value = expandedDepartments.value.filter((item) => item !== name) + return + } + + if (!expanded) { + expandedDepartments.value = [...expandedDepartments.value, name] + } + + selectedDepartment.value = name + selectedCategory.value = '' + } + + function toggleCategory(departmentName: string, categoryName: string) { + if (!expandedDepartments.value.includes(departmentName)) { + expandedDepartments.value = [...expandedDepartments.value, departmentName] + } + + const sameCategory = + selectedDepartment.value === departmentName && selectedCategory.value === categoryName + + selectedDepartment.value = departmentName + selectedCategory.value = sameCategory ? '' : categoryName + } + function clearErrors() { errors.patient = '' errors.visit = '' @@ -127,6 +347,11 @@ export function useSigningTaskForm(options: UseSigningTaskFormOptions) { selectedVisitId.value = '' method.value = initialTemplate?.supportedMethods[0] ?? 'pad' smsDestination.value = '' + phonePlain.value = false + suggestionOpen.value = false + selectedDepartment.value = '' + selectedCategory.value = '' + expandedDepartments.value = [] clearErrors() } @@ -139,6 +364,7 @@ export function useSigningTaskForm(options: UseSigningTaskFormOptions) { smsDestination.value = '' errors.document = '' errors.sms = '' + initDocumentTree() } async function loadTemplates() { @@ -155,6 +381,29 @@ export function useSigningTaskForm(options: UseSigningTaskFormOptions) { } includeInitialTemplate() + + const initialTemplate = getInitialTemplate() + + if (initialTemplate) { + focusTemplateDepartment(initialTemplate) + } else { + initDocumentTree() + } + } + + function openSuggestions() { + suggestionOpen.value = true + } + + function closeSuggestions() { + suggestionOpen.value = false + } + + /** 点候选行直接定位(原型 pickPat) */ + async function pickPatient(candidate: PatientProfile) { + patientId.value = candidate.id + closeSuggestions() + await locatePatient() } async function locatePatient() { @@ -165,26 +414,49 @@ export function useSigningTaskForm(options: UseSigningTaskFormOptions) { visits.value = [] selectedVisitId.value = '' selectedTemplateId.value = getInitialTemplate()?.id ?? '' + phonePlain.value = false if (!keyword) { - errors.patient = '请输入患者 ID、门诊号或住院号' + openSuggestions() + errors.patient = '请输入患者 ID / 姓名 / 就诊号' return } locating.value = true try { - const profile = await getPatientProfile(keyword) + let profile = await getPatientProfile(keyword) if (!profile) { + const matches = patientSuggestions.value + + // 原型口径:模糊命中唯一一个就直接定位,命中多个让人从候选列表里挑 + if (matches.length === 1) { + profile = matches[0] + } else if (matches.length > 1) { + openSuggestions() + errors.patient = `匹配到 ${matches.length} 位患者,请从候选列表中选择` + return + } + } + + if (!profile) { + closeSuggestions() errors.patient = '未找到该患者,请核对输入内容' return } + closeSuggestions() + patientId.value = profile.id patient.value = profile visits.value = profile.visits selectedVisitId.value = profile.visits.find((visit) => visit.isCurrent)?.id ?? profile.visits[0]?.id ?? '' + + // 已经选了短信方式才定位患者时,把投递号码一并带出(原型 locatePatient 同款) + if (method.value === 'sms' && !smsDestination.value.trim()) { + smsDestination.value = profile.phoneFull ?? '' + } } catch { errors.patient = '患者信息查询失败,请稍后重试' } finally { @@ -197,6 +469,16 @@ export function useSigningTaskForm(options: UseSigningTaskFormOptions) { errors.visit = '' } + /** 原型 togglePh:明文查看会留审计痕迹,因此每次切换都提示一次 */ + function togglePhonePlain() { + if (!patient.value?.phoneFull) { + return + } + + phonePlain.value = !phonePlain.value + ElMessage.info(phonePlain.value ? '明文查看已记录审计日志' : '已恢复脱敏显示') + } + /** * 文书必须带签名位才能发起签署:否则签名会随证据固化,但文书上没有落位的地方, * 签出来的文书看不出患者签过。 @@ -214,21 +496,43 @@ export function useSigningTaskForm(options: UseSigningTaskFormOptions) { const signatureMissingMessage = '该文书没有签名占位符,请先在文档库中补上签名位再发起' + /** 原型 toggleNtDoc:单选,再点一次取消选中 */ function selectTemplate(template: SigningTemplate) { - selectedTemplateId.value = template.id - errors.document = hasSignatureSlot(template) ? '' : signatureMissingMessage + const nextId = selectedTemplateId.value === template.id ? '' : template.id - if (!template.supportedMethods.includes(method.value)) { + selectedTemplateId.value = nextId + errors.document = nextId && !hasSignatureSlot(template) ? signatureMissingMessage : '' + + if (nextId && !template.supportedMethods.includes(method.value)) { method.value = template.supportedMethods[0] ?? 'pad' } } + /** + * 文书模板声明的可签署方式,按**具体取值**精确判断(四个平铺值,没有通道归并)。 + * 演示模板一律声明全部四种;真实模式由文档库配置决定。 + */ + function isMethodAllowed(nextMethod: SigningMethod) { + const template = selectedTemplate.value + + if (!template) { + return true + } + + return template.supportedMethods.includes(nextMethod) + } + function selectMethod(nextMethod: SigningMethod) { - if (selectedTemplate.value && !selectedTemplate.value.supportedMethods.includes(nextMethod)) { + if (!isMethodAllowed(nextMethod)) { return } method.value = nextMethod + + // 短信方式要确认投递号码:有完整号码就预填,没有则留空由操作人填写 + if (nextMethod === 'sms' && !smsDestination.value.trim()) { + smsDestination.value = patient.value?.phoneFull ?? '' + } } function maskPhone(value: string) { @@ -261,7 +565,7 @@ export function useSigningTaskForm(options: UseSigningTaskFormOptions) { valid = false } - if (!isSigningMockEnabled && method.value === 'sms') { + if (method.value === 'sms') { const destination = smsDestination.value.trim().replace(/\s+/g, '') if (!/^\+?[1-9]\d{6,14}$/.test(destination)) { @@ -309,17 +613,25 @@ export function useSigningTaskForm(options: UseSigningTaskFormOptions) { } return { + closeSuggestions, + departmentRows, + documentList, documentSearch, errors, - filteredTemplateGroups, + isMethodAllowed, loadingTemplates, loadTemplates, locatePatient, locating, maskPhone, method, + openSuggestions, patient, patientId, + patientSuggestions, + phonePlain, + pickPatient, + prescription, resetForNextTask, resetForm, selectMethod, @@ -332,7 +644,11 @@ export function useSigningTaskForm(options: UseSigningTaskFormOptions) { smsDestination, submit, submitting, + suggestionOpen, templates, + toggleCategory, + toggleDepartment, + togglePhonePlain, visits, } } diff --git a/clinical-web/src/utils/mock-document-library.ts b/clinical-web/src/utils/mock-document-library.ts index 8e17af8..cdffa40 100644 --- a/clinical-web/src/utils/mock-document-library.ts +++ b/clinical-web/src/utils/mock-document-library.ts @@ -17,8 +17,9 @@ * 只声明实际用到的字段,不绑定文档库页面的类型,避免 utils 反向依赖 views。 */ -import type { SigningFieldDef, SigningMethod, SigningTemplate } from '@/api/workbench/types' +import type { SigningFieldDef, SigningTemplate } from '@/api/workbench/types' +import { ALL_SIGNING_METHODS } from './signing-method' import { buildFieldSpanHtml } from './signing-fields' import { MOCK_STORAGE_KEYS, readMockState, writeMockState } from './mock-storage' @@ -311,7 +312,7 @@ export function readLocalSigningTemplates(): SigningTemplate[] { campusId: document.campusId ?? undefined, category: category.name, description: document.description ?? '', - supportedMethods: ['pad', 'sms'] as SigningMethod[], + supportedMethods: [...ALL_SIGNING_METHODS], contentHtml: document.contentHtml, contentSha256: document.contentSha256, })), diff --git a/clinical-web/src/utils/signing-method.ts b/clinical-web/src/utils/signing-method.ts new file mode 100644 index 0000000..974c1b5 --- /dev/null +++ b/clinical-web/src/utils/signing-method.ts @@ -0,0 +1,141 @@ +/** + * 签署方式的取值口径与文案(原型 V10.2 的四种)。 + * + * 为什么要单独成文件:文案在发起签署弹窗、任务卡、详情动作、记录表、报表维度五处都要用, + * 各写一份会随口径漂移而悄悄不一致(新增方式后列表静默显示成短信)。 + * + * **原型里没有「通道」这一层**,四个取值都是平铺的,各处按具体取值精确分支; + * 唯一存在归并的地方是任务卡的「待签署」标签(`readPendingTag`), + * 那也照抄原型的三分支,不是「现场/线上」两分。 + */ +import type { BackendSigningMethod, SigningMethod } from '@/api/workbench/types' + +export interface SigningMethodDescriptor { + key: SigningMethod + /** 发起签署弹窗里的完整文案(原型 #mdNew 的 D 区四张卡片) */ + title: string + /** 任务卡与列表用的短文案 */ + short: string + /** 筛选项文案(记录查询与报表两处沿用各自原型的写法) */ + filterLabel: string + /** 短文案前的图标(原型 methodLabel 同款) */ + icon: string + /** + * 原型 methodLabel 的完整写法(任务详情、报表维度、导出用)。 + * + * 单独给字段而不是拼 `${icon} ${short}签署`:原型里 `ipad` 是「📲 Pad 签署」—— + * 拉丁词与「签署」之间**有空格**,而中文词(手写板 / 短信 / 公众号)之间没有。 + * 拼接会在 Pad 上丢空格,与原型不一致(报表维度那一行会变成「📲 Pad签署」)。 + */ + label: string +} + +/** 顺序与原型 #mdNew 的 D 区一致:现场两种在前,线上两种在后 */ +export const SIGNING_METHODS: readonly SigningMethodDescriptor[] = [ + { + key: 'pad', + title: '手写板原笔迹签署(现场)', + short: '手写板', + filterLabel: '手写板', + icon: '✍️', + label: '✍️ 手写板签署', + }, + { + key: 'sms', + title: '短信链接签署(患者移动端 H5)', + short: '短信', + filterLabel: '短信线上', + icon: '📱', + label: '📱 短信签署', + }, + { + key: 'ipad', + title: 'Pad 移动端签署(床旁/诊间)', + short: 'Pad', + filterLabel: 'Pad', + icon: '📲', + label: '📲 Pad 签署', + }, + { + key: 'wx', + title: '微信公众号签署(患者微信内签署)', + short: '公众号', + filterLabel: '公众号', + icon: '💬', + label: '💬 公众号签署', + }, +] + +const METHOD_BY_KEY = new Map( + SIGNING_METHODS.map((item) => [item.key, item]), +) + +/** + * 全部四种方式。演示数据里模板一律声明全部允许 —— + * 原型没有「按文书限制签署方式」这个概念,`supportedMethods` 是本仓库的扩展位。 + */ +export const ALL_SIGNING_METHODS: readonly SigningMethod[] = SIGNING_METHODS.map((item) => item.key) + +/** 取方式描述。遇到未知取值按现场手写板兜底,避免列表出现空白单元格 */ +export function readSigningMethod(method: SigningMethod): SigningMethodDescriptor { + return METHOD_BY_KEY.get(method) ?? SIGNING_METHODS[0] +} + +/** 前端取值 → 后端取值,一一对应,不做任何归并 */ +export function toBackendSigningMethod(method: SigningMethod): BackendSigningMethod { + return method.toUpperCase() as BackendSigningMethod +} + +/** 带图标的短文案,如「✍️ 手写板」 */ +export function formatSigningMethod(method: SigningMethod): string { + const descriptor = readSigningMethod(method) + return `${descriptor.icon} ${descriptor.short}` +} + +/** 原型 methodLabel:✍️ 手写板签署 / 📲 Pad 签署 / 💬 公众号签署 / 📱 短信签署 */ +export function formatSigningMethodTitle(method: SigningMethod): string { + return readSigningMethod(method).label +} + +/** + * 任务卡的「待签署」标签。 + * + * 配色照抄原型 renderTasks 的三分支:`pad` / `ipad` / 其余(`sms` 与 `wx` 共用短信紫)。 + * 文案按**具体取值**给出(原型对 `wx` 也写「待签署·短信」,那是它漏改的老文案; + * 本仓库的取值是四个平铺值,写清楚是哪种更不容易看错)。 + */ +export function readPendingTag(method: SigningMethod): { className: string; text: string } { + const className = method === 'pad' ? 'tg-pad' : method === 'ipad' ? 'tg-ipad' : 'tg-sms' + return { className, text: `待签署·${readSigningMethod(method).short}` } +} + +/** + * 「方式」筛选项。原型两处的选项**并不一样**,所以分开维护,别合并成一份: + * - 记录查询 `#qMethod`:3 项(手写板 / 短信线上 / Pad),**没有公众号**; + * - 报表分析 `#repMethod`:4 项(手写板 / Pad / 短信 / 公众号),且短信写作「短信」。 + */ +export interface SigningMethodFilterOption { + value: SigningMethod + label: string +} + +export const WORKBENCH_METHOD_FILTERS: readonly SigningMethodFilterOption[] = [ + { value: 'pad', label: '手写板' }, + { value: 'sms', label: '短信线上' }, + { value: 'ipad', label: 'Pad' }, +] + +export const REPORT_METHOD_FILTERS: readonly SigningMethodFilterOption[] = [ + { value: 'pad', label: '手写板' }, + { value: 'ipad', label: 'Pad' }, + { value: 'sms', label: '短信' }, + { value: 'wx', label: '公众号' }, +] + +/** + * 切换签署方式(原型 switchMethod:`m || (curTask.method === 'pad' ? 'sms' : 'pad')`)。 + * 原型就是「手写板 ↔ 短信」二选一,Pad / 公众号一律回到手写板。 + */ +export function toggleSigningMethod(method: SigningMethod): SigningMethod { + return method === 'pad' ? 'sms' : 'pad' +} diff --git a/clinical-web/src/views/management/documents/index.vue b/clinical-web/src/views/management/documents/index.vue index aac108e..9c530f0 100644 --- a/clinical-web/src/views/management/documents/index.vue +++ b/clinical-web/src/views/management/documents/index.vue @@ -28,6 +28,7 @@ import { useLoginStore } from '@/stores/login' import { demoCampuses, demoDepartments } from '@/utils/mock-document-library' import { mockFlags } from '@/utils/mock-flags' import { clearMockState } from '@/utils/mock-storage' +import { ALL_SIGNING_METHODS } from '@/utils/signing-method' import DocumentFilterTabs from './components/DocumentFilterTabs.vue' import DocumentHistoryDialog from './components/DocumentHistoryDialog.vue' @@ -987,7 +988,7 @@ async function toSigningTemplate(template: DocumentTemplate): Promise +import { formatSigningMethod, readSigningMethod } from '@/utils/signing-method' + import type { ReportTask, ReportTaskStatus } from '../types' defineProps<{ @@ -30,7 +32,7 @@ const statusLabels: Record = { function getStatusLabel(row: ReportTask) { if (row.status === 'pending') { - return `${statusLabels[row.status]}·${row.method === 'pad' ? '手写板' : '短信'}` + return `${statusLabels[row.status]}·${readSigningMethod(row.method).short}` } return statusLabels[row.status] @@ -131,7 +133,7 @@ function handlePageSizeChange(event: Event) { {{ row.documentName }} {{ row.department }} - {{ row.method === 'pad' ? '✍️ 手写板' : '📱 短信' }} + {{ formatSigningMethod(row.method) }} {{ getStatusLabel(row) }} diff --git a/clinical-web/src/views/management/reports/index.vue b/clinical-web/src/views/management/reports/index.vue index 7f2ead3..5649fe1 100644 --- a/clinical-web/src/views/management/reports/index.vue +++ b/clinical-web/src/views/management/reports/index.vue @@ -22,6 +22,11 @@ import type { ReportTasksQueryDto, } from '@/api/management/types' import { useLoginStore } from '@/stores/login' +import { + formatSigningMethodTitle, + REPORT_METHOD_FILTERS, + toBackendSigningMethod, +} from '@/utils/signing-method' import DepartmentDistributionChart from './components/DepartmentDistributionChart.vue' import ReportDetailTable from './components/ReportDetailTable.vue' @@ -102,8 +107,7 @@ const filters = reactive({ const methodOptions: ReportSelectOption[] = [ { value: 'all', label: '全部方式' }, - { value: 'pad', label: '手写板' }, - { value: 'sms', label: '短信' }, + ...REPORT_METHOD_FILTERS.map((option) => ({ value: option.value, label: option.label })), ] interface ReportDateRange { @@ -571,7 +575,7 @@ function getScopeFilter(): ReportFilterDto { } if (filters.method !== 'all') { - filter.signMethod = filters.method === 'sms' ? 'SMS' : 'PAD' + filter.signMethod = toBackendSigningMethod(filters.method) } const keyword = filters.keyword.trim() @@ -994,7 +998,7 @@ function downloadMockReport() { task.documentName, task.department, task.campus, - task.method === 'pad' ? '手写板' : '短信', + formatSigningMethodTitle(task.method), getReportStatusLabel(task.status), formatExportDate(task.createdAt), formatExportDate(task.signedAt), diff --git a/clinical-web/src/views/management/reports/mock.ts b/clinical-web/src/views/management/reports/mock.ts index d4b3a8a..1a73ee8 100644 --- a/clinical-web/src/views/management/reports/mock.ts +++ b/clinical-web/src/views/management/reports/mock.ts @@ -169,6 +169,38 @@ export const reportTasks: ReportTask[] = [ status: 'void', createdAt: daysAgo(5, 11, 45), }, + // Pad 与公众号两条:四种方式都要有样本,否则「方式」列与筛选选到 Pad / 公众号时 + // 永远只能得到空结果,看着像筛选坏了(其实是演示数据没覆盖)。 + { + id: 'T0813002', + patientName: '罗文静', + patientId: 'P202610007', + documentName: '床旁超声检查知情同意书', + department: '放射科', + campus: '东院区', + category: 'consent', + method: 'ipad', + status: 'signed', + createdAt: daysAgo(2, 8, 50), + signedAt: daysAgo(2, 8, 58), + expiredAt: daysAgo(1, 8, 50), + durationMinutes: 8, + }, + { + id: 'T0813001', + patientName: '梁俊豪', + patientId: 'P202610008', + documentName: '体检报告发放及知情同意书', + department: '健康管理中心', + campus: '东院区', + category: 'consent', + method: 'wx', + status: 'signed', + createdAt: daysAgo(3, 16, 10), + signedAt: daysAgo(3, 16, 26), + expiredAt: daysAgo(2, 16, 10), + durationMinutes: 16, + }, ] export const reportDepartmentColors: Record = { diff --git a/clinical-web/src/views/management/reports/types.ts b/clinical-web/src/views/management/reports/types.ts index ce24f66..0e7c6f6 100644 --- a/clinical-web/src/views/management/reports/types.ts +++ b/clinical-web/src/views/management/reports/types.ts @@ -1,3 +1,5 @@ +import type { SigningMethod } from '@/api/workbench/types' + export type ReportPeriodOption = '7d' | '14d' | 'month' | 'custom' /** 院区由后端字典提供,保留 all 作为全院筛选值。 */ @@ -5,7 +7,7 @@ export type ReportCampus = 'all' | (string & {}) export type ReportDocumentCategory = 'all' | (string & {}) -export type ReportMethodFilter = 'all' | 'pad' | 'sms' +export type ReportMethodFilter = 'all' | SigningMethod export interface ReportFilterForm { period: ReportPeriodOption @@ -30,7 +32,7 @@ export interface ReportMetricCard { export type ReportTaskStatus = 'signed' | 'pending' | 'signing' | 'expired' | 'void' | 'failed' -export type ReportSigningMethod = 'pad' | 'sms' +export type ReportSigningMethod = SigningMethod export interface ReportTask { id: string diff --git a/clinical-web/src/views/reports/aggregate.ts b/clinical-web/src/views/reports/aggregate.ts index 6fee40e..7190d73 100644 --- a/clinical-web/src/views/reports/aggregate.ts +++ b/clinical-web/src/views/reports/aggregate.ts @@ -11,6 +11,7 @@ import type { ReportTaskRow } from '@/api/workbench/report-analytics' import type { SigningMethod, SigningTaskStatus } from '@/api/workbench/types' +import { formatSigningMethodTitle, SIGNING_METHODS } from '@/utils/signing-method' import type { ReportBarItem, @@ -31,10 +32,21 @@ export const OVERDUE_MINUTES = 2880 /** 趋势图最多画多少根柱子:自定义区间可能跨好几个月,画不下也没人看 */ const MAX_TREND_DAYS = 60 -export const METHOD_LABELS: Record = { - pad: '手写板', - sms: '短信线上', -} +/** + * 签署方式维度文案。 + * 口径统一走 utils/signing-method:四种方式各成一行,不能只列 pad / sms—— + * 报表维度是按任务实际取值聚合的,缺一行会让 Pad / 公众号的任务在图表里显示成空白。 + * 文案取原型的 methodLabel(`✍️ 手写板签署` 这种带图标的完整写法), + * 与报表明细表的「方式」列一致。 + * + * ⚠️ 这是**查表**,没有 `readSigningMethod` 那样的未知取值兜底:`METHOD_LABELS[未知值]` + * 会得到 undefined,汇总矩阵会多出一行没有名字的分组。今天不会触发(`SigningMethod` + * 是封闭联合,取值只来自本仓库的演示数据),但接真实接口时必须在边界上先把后端取值 + * 归一到这四个(见 api/workbench/signing.ts 的 normalizeSigningMethod),不要指望这里兜底。 + */ +export const METHOD_LABELS: Record = Object.fromEntries( + SIGNING_METHODS.map((option) => [option.key, formatSigningMethodTitle(option.key)]), +) as Record /** * 状态文案。六种状态与后端枚举一一对应,不映射成原型里的「待科室确认」—— diff --git a/clinical-web/src/views/reports/components/ReportExportDialog.vue b/clinical-web/src/views/reports/components/ReportExportDialog.vue index 0657eed..e50b60d 100644 --- a/clinical-web/src/views/reports/components/ReportExportDialog.vue +++ b/clinical-web/src/views/reports/components/ReportExportDialog.vue @@ -5,6 +5,7 @@ import { ElMessage } from 'element-plus' import type { ReportTaskRow } from '@/api/workbench/report-analytics' import type { SigningMethod, WorkbenchCampus } from '@/api/workbench/types' import { downloadCsv, fileStamp } from '@/utils/csv-export' +import { REPORT_METHOD_FILTERS } from '@/utils/signing-method' import { filterReportTasks, formatDuration, METHOD_LABELS, STATUS_LABELS } from '../aggregate' import type { ReportDateRange, ReportExportColumn, ReportFilterForm, ReportFilterOption } from '../types' @@ -44,8 +45,7 @@ const dateRangeOptions: ReportFilterOption[] = [ const methodOptions: ReportFilterOption[] = [ { label: '全部方式', value: 'all' }, - { label: '手写板', value: 'pad' }, - { label: '短信线上', value: 'sms' }, + ...REPORT_METHOD_FILTERS.map((option) => ({ label: option.label, value: option.value })), ] /** diff --git a/clinical-web/src/views/reports/components/ReportFilterBar.vue b/clinical-web/src/views/reports/components/ReportFilterBar.vue index f750e00..464fec5 100644 --- a/clinical-web/src/views/reports/components/ReportFilterBar.vue +++ b/clinical-web/src/views/reports/components/ReportFilterBar.vue @@ -1,5 +1,6 @@