From 97c5182ba44704233f3f9a6e785873f87ec392ad Mon Sep 17 00:00:00 2001 From: yelan Date: Thu, 17 Sep 2026 16:44:54 +0800 Subject: [PATCH] =?UTF-8?q?feat(=E6=96=87=E4=B9=A6=E7=AE=A1=E7=90=86):=20?= =?UTF-8?q?=E5=A2=9E=E5=8A=A0=E7=89=88=E6=9C=AC=E5=B7=A5=E4=BD=9C=E6=B5=81?= =?UTF-8?q?=E4=B8=8E=E7=89=88=E6=9C=AC=E5=8E=86=E5=8F=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 模板状态按后端原始状态展示与筛选,待审核与已驳回分开展示 - 仅已发布模板可发起签署,新增提交审核/审核通过/驳回等动作 - 新增版本历史弹窗,复用统一的模板时间格式化方法 - 演示数据补齐已签署任务的签名图,并提供获取签名 dataURL 的接口 - 修复编辑器插入占位符时未定位光标导致内容落到文首的问题 --- clinical-web/src/api/management/documents.ts | 11 +- clinical-web/src/api/workbench/artifacts.ts | 35 ++ clinical-web/src/api/workbench/signing.ts | 32 +- clinical-web/src/utils/demo-signature.ts | 13 + .../components/DocumentPreviewDialog.vue | 43 ++- .../components/DocumentTemplateCard.vue | 125 ++++++- .../components/DocumentVersionDialog.vue | 335 ++++++++++++++++++ .../components/editors/EditorWangPane.vue | 36 +- .../src/views/management/documents/index.vue | 238 ++++++++++++- .../src/views/management/documents/mock.ts | 121 ++++++- .../src/views/management/documents/types.ts | 26 +- .../signing/components/SigningTaskDetail.vue | 4 +- .../src/views/workbench/signing/index.vue | 27 +- 13 files changed, 982 insertions(+), 64 deletions(-) create mode 100644 clinical-web/src/utils/demo-signature.ts create mode 100644 clinical-web/src/views/management/documents/components/DocumentVersionDialog.vue diff --git a/clinical-web/src/api/management/documents.ts b/clinical-web/src/api/management/documents.ts index 6a8e2a7..b12883c 100644 --- a/clinical-web/src/api/management/documents.ts +++ b/clinical-web/src/api/management/documents.ts @@ -44,7 +44,14 @@ const mockRecords: DocumentRecord[] = [ }, ] -function formatDateTime(value: string | null | undefined) { +/** + * 后端时间戳 → 页面展示用的 `YYYY-MM-DD HH:mm`。 + * + * 导出给版本历史弹窗复用:版本列表与模板列表是同一批时间字段, + * 各写一份格式化早晚会分叉出两种显示口径。解析不出来时原样返回去壳的字符串, + * 不返回 Invalid Date。 + */ +export function formatTemplateDateTime(value: string | null | undefined) { if (!value) { return '' } @@ -94,7 +101,7 @@ function mapDocument(dto: TemplateResponseDto): DocumentRecord { status: normalizeDocumentStatus(dto.status), backendStatus: dto.status ?? undefined, updatedBy: dto.updatedBy ?? dto.createdBy ?? '—', - updatedAt: formatDateTime(dto.updatedAt ?? dto.createdAt), + updatedAt: formatTemplateDateTime(dto.updatedAt ?? dto.createdAt), versionId, departmentId: dto.departmentId, departmentName: dto.departmentId ? undefined : '全院通用', diff --git a/clinical-web/src/api/workbench/artifacts.ts b/clinical-web/src/api/workbench/artifacts.ts index 3a8932c..979e5fb 100644 --- a/clinical-web/src/api/workbench/artifacts.ts +++ b/clinical-web/src/api/workbench/artifacts.ts @@ -195,3 +195,38 @@ export function downloadSigningArtifact(artifactId: string): Promise { responseType: 'blob', }) } + +/** Blob → dataURL */ +function blobToDataUrl(blob: Blob): Promise { + return new Promise((resolve, reject) => { + const reader = new FileReader() + reader.onload = () => { + resolve(typeof reader.result === 'string' ? reader.result : '') + } + reader.onerror = () => { + reject(new Error('签名图读取失败')) + } + reader.readAsDataURL(blob) + }) +} + +/** + * 取回签名图并转成 dataURL,供文书渲染使用。 + * + * 签名在后端是独立的 SIGNATURE_IMAGE 产物,任务接口(SignTaskResponseDto) + * **不返回任何签名字段**,所以前端必须自己把它取回来——否则文书里的签名位 + * 永远是「待患者签名」,导出的 PDF 也就没有患者签名。 + * + * 这里刻意转成 dataURL 而不是直接把下载地址塞给 :文书导出 PDF 走 + * html2canvas 截图,跨域的 会被画布安全策略拦掉 + * (`useCORS` 只在服务端返回 CORS 响应头时才生效),表现为 + * 「页面上看得到签名、导出的 PDF 里却是空的」。 + */ +export async function fetchSignatureDataUrl(artifact: SigningArtifact): Promise { + try { + return await blobToDataUrl(await downloadSigningArtifact(artifact.id)) + } catch { + // 取签名图失败不该让整个详情页失败:正文、审计轨迹、产物列表都还能看 + return '' + } +} diff --git a/clinical-web/src/api/workbench/signing.ts b/clinical-web/src/api/workbench/signing.ts index dece3f0..1286539 100644 --- a/clinical-web/src/api/workbench/signing.ts +++ b/clinical-web/src/api/workbench/signing.ts @@ -5,6 +5,7 @@ import { readCachedTemplateContent, } from '@/api/management/documents' import { unwrapApiResponse } from '@/utils/api-response' +import { DEMO_SIGNATURE_DATA_URL } from '@/utils/demo-signature' import { mockFlags } from '@/utils/mock-flags' import { readLocalSigningTemplates } from '@/utils/mock-document-library' import { MOCK_STORAGE_KEYS, readMockState, writeMockState } from '@/utils/mock-storage' @@ -254,30 +255,39 @@ const defaultMockRecords: SigningTaskRecord[] = [ ] /** - * 给演示任务补上文书正文快照。 + * 给演示任务补齐渲染所需的数据:文书正文快照 + 已签署任务的签名图。 * - * 预置任务(以及历史上从写死模板创建的任务)只记了 documentId,没有正文快照; - * 缺正文时详情会退化成前端写死的回退纸样——内容与模板无关、没有签名位、 - * 也没有固定页眉。这里按 documentId 从文书库取正文补上; - * 实在取不到的保持原样,由详情页走回退分支,不伪造正文。 + * ① 正文:预置任务(以及历史上从写死模板创建的任务)只记了 documentId,没有正文快照; + * 缺正文时详情会退化成前端写死的回退纸样——内容与模板无关、没有签名位、 + * 也没有固定页眉。这里按 documentId 从文书库取正文补上; + * 实在取不到的保持原样,由详情页走回退分支,不伪造正文。 + * ② 签名图:种子任务里标了「已签署」却不带签名,会出现「列表说已签署、打开没有签名、 + * 产物列表也是空的」这种自相矛盾的状态,下载的 PDF 也缺签名。 + * 这里给缺签名的已签署任务补一张示意签名;真实签署过的任务已有签名图,不会被覆盖。 */ function hydrateTaskDocuments(records: SigningTaskRecord[]): SigningTaskRecord[] { const templates = new Map(readLocalSigningTemplates().map((template) => [template.id, template])) return records.map((record) => { - if (record.documentContentHtml) { - return record + let next = record + + if (next.status === 'signed' && !next.signatureDataUrl) { + next = { ...next, signatureDataUrl: DEMO_SIGNATURE_DATA_URL } } - const template = templates.get(record.documentId) + if (next.documentContentHtml) { + return next + } + + const template = templates.get(next.documentId) if (!template?.contentHtml) { - return record + return next } return { - ...record, - templateVersionId: record.templateVersionId || template.versionId, + ...next, + templateVersionId: next.templateVersionId || template.versionId, documentContentHtml: template.contentHtml, } }) diff --git a/clinical-web/src/utils/demo-signature.ts b/clinical-web/src/utils/demo-signature.ts new file mode 100644 index 0000000..f2f92d3 --- /dev/null +++ b/clinical-web/src/utils/demo-signature.ts @@ -0,0 +1,13 @@ +/** + * 演示用的手写签名图(内联 PNG)。 + * + * 演示种子任务里有「已签署」状态的任务,但种子数据不带签名图,导致 + * 列表显示「已签署」、打开却没有签名、产物列表也是空的——状态与数据自相矛盾。 + * 这里给它们补一张示意签名,让演示数据自洽。 + * + * 笔画是抽象曲线,**不写具体姓名**:演示数据里出现一个像真人的签名, + * 等于在留痕材料上凭空造了个签署人。真实签署的签名图由签署端采集, + * 不存在这里(见 api/workbench/artifacts 的 fetchSignatureDataUrl)。 + */ +export const DEMO_SIGNATURE_DATA_URL = + 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAALAAAAA0CAYAAAAqlrKQAAABGklEQVR42u3a0ZGDQAxEQYJwNI6F/NOwI+ADaqWVUHcVAXh4YPDdcQAAAAAAAABAb5/v+bs6rJOzvSUWRivmPefAIkHhGjnvPFhm4aOCkPfcQKwUcLUbOuY8eJxIDs/QcefBrkl3TRHH7GXXxK97Y8fsNH7XzGdVEd/bya4FP7iI43d11xVx2/MqXhEjXBETGG6lWERMy3DHvoC4kJ+FW32UKSfPP0I9GMUdqE6U4r0xmK/RPsGO/QnsbUNU/iwiHf6Y0DViYXrRaRmxYGkZsWhpGbFwaRuxcGkZsbsuW1+qhMuoiIVLyYivYvQbLm1DFi6hAa0Kq0O0UVv58/SAY/JWQnS4gN8cr4txb0ij78AejewFAAAAAKT4A3Urz8xIiE95AAAAAElFTkSuQmCC' diff --git a/clinical-web/src/views/management/documents/components/DocumentPreviewDialog.vue b/clinical-web/src/views/management/documents/components/DocumentPreviewDialog.vue index b990793..041f23f 100644 --- a/clinical-web/src/views/management/documents/components/DocumentPreviewDialog.vue +++ b/clinical-web/src/views/management/documents/components/DocumentPreviewDialog.vue @@ -9,7 +9,7 @@ import { type SigningFieldProgress, } from '@/utils/signing-document' -import { documentStatusLabels } from '../mock' +import { readStatusLabel, readStatusTone } from '../mock' import type { DocumentTemplate } from '../types' const props = defineProps<{ @@ -74,19 +74,17 @@ watch( }, ) -const statusLabel = computed(() => { - if (!props.template) { - return '' - } +const statusLabel = computed(() => (props.template ? readStatusLabel(props.template) : '')) - return documentStatusLabels[props.template.status] -}) +/** 徽标配色与文书卡片同口径:草稿/待审核/已驳回/已发布/已归档各不相同 */ +const statusTone = computed(() => (props.template ? readStatusTone(props.template) : 'draft')) -/** 与文书卡片保持同一口径:草稿与已归档不可发起签署 */ +/** + * 与文书卡片保持同一口径:只有已发布(后端 APPROVED / PUBLISHED)才能发起签署。 + * 不放行 review —— 它同时承载待审核与已驳回,两者都未定稿。 + */ const canInitiate = computed( - () => - Boolean(props.template?.contentHtml) && - (props.template?.status === 'published' || props.template?.status === 'review'), + () => Boolean(props.template?.contentHtml) && props.template?.status === 'published', ) // 预置文书的 contentHtml 是完整 HTML 文档(自带版式样式); @@ -179,7 +177,7 @@ function syncPreviewFields() {

{{ template.name }}

类 Word 版式 - {{ statusLabel }} + {{ statusLabel }}