From c2984bfd9815ddbbc75867b2abb7d39cfa104a33 Mon Sep 17 00:00:00 2001 From: xy Date: Wed, 30 Sep 2026 08:35:02 +0800 Subject: [PATCH] =?UTF-8?q?feat(font):=20=E6=96=B0=E5=A2=9E=E5=AD=97?= =?UTF-8?q?=E4=BD=93=E8=AE=BE=E7=BD=AE=E5=B9=B6=E7=BB=9F=E4=B8=80=E5=8C=BB?= =?UTF-8?q?=E6=8A=A4=E7=AB=AF=E4=B8=8E=E6=82=A3=E8=80=85=E7=AB=AF=E6=96=87?= =?UTF-8?q?=E4=B9=A6=E5=AD=97=E4=BD=93?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 6 + .../src/api/management/document-fonts.ts | 63 ++++ clinical-web/src/api/workbench/signing.ts | 1 + clinical-web/src/api/workbench/types.ts | 3 + .../src/components/document/DocumentPaper.vue | 44 ++- .../document/DocumentPreviewDialog.vue | 4 + .../signing/SigningDocumentInteractive.vue | 1 + .../components/DocumentFontSettings.vue | 294 ++++++++++++++++++ .../src/views/management/settings/index.vue | 8 +- .../components/OnlineSigningDialog.vue | 1 + .../components/SignedDocumentDialog.vue | 1 + packages/document-fonts/index.ts | 54 ++++ patient-h5/src/api/types.ts | 2 + patient-h5/src/components/DocumentPaper.vue | 16 +- patient-h5/src/composables/useSignFlow.ts | 14 +- patient-h5/src/types/signing.ts | 2 + patient-h5/src/views/consent/ConsentView.vue | 1 + patient-h5/src/views/signer/SignerView.vue | 1 + 18 files changed, 511 insertions(+), 5 deletions(-) create mode 100644 clinical-web/src/api/management/document-fonts.ts create mode 100644 clinical-web/src/views/management/settings/components/DocumentFontSettings.vue create mode 100644 packages/document-fonts/index.ts diff --git a/README.md b/README.md index 671c50e..c3305ed 100644 --- a/README.md +++ b/README.md @@ -117,3 +117,9 @@ patient-h5/dist/ → 患者手机签署网页 3. 再接入 `packages` 中的公共类型和接口客户端; 4. 验证电子病历 iframe、单点登录和签字板调用; 5. 补充收费拦截、动态补签和急诊例外流程。 + +## 文书字体 + +系统设置 → 参数设置 → 文书字体,已接入真实字体接口。支持 TTF/TTC 字面选择、常规体/粗体上传、检测、浏览器/PDF 预览和设置默认字体。使用系统设置查看权限和参数编辑权限。 + +医护端任务与患者 H5 使用任务固化的字体描述,通过 `packages/document-fonts` 公共加载器加载同一 TTF。模板预览读取当前默认字体;字体加载失败时阻止展示可签署正文。后端需先升级至包含 V33 字体迁移的版本,完整部署及接口说明见后端 `docs/document-fonts.md`。 diff --git a/clinical-web/src/api/management/document-fonts.ts b/clinical-web/src/api/management/document-fonts.ts new file mode 100644 index 0000000..108bb7c --- /dev/null +++ b/clinical-web/src/api/management/document-fonts.ts @@ -0,0 +1,63 @@ +import { request } from '@/utils/request' +import { unwrapApiResponse, unwrapNullableApiResponse, isSuccessCode } from '@/utils/api-response' +import type { ApiResponse } from '@/types/common' +import type { DocumentFont } from '../../../../packages/document-fonts' +export type { DocumentFont } from '../../../../packages/document-fonts' +export interface FontRecord extends DocumentFont { + enabled: boolean + createdAt: string +} +export interface FontNodeCheck { + fontId: string + node: string + available: boolean + message: string + checkedAt: string +} +export interface FontSettings { + nodes: FontNodeCheck[] + defaultFontId: string | null + fonts: FontRecord[] + legacyFileReadable: boolean +} +export interface FontFaceInfo { + index: number + name: string + style: string +} +export interface FontCheck { + available: boolean + message: string + node: string + checkedAt: string +} +const base = '/v1/document-fonts' +const longRequest = { timeout: 90000 } +export const getDocumentFontSettings = () => + request.get>(base).then(unwrapApiResponse) +export const getDefaultDocumentFont = () => + request.get>(`${base}/current`).then(unwrapNullableApiResponse) +export const inspectDocumentFont = (file: File) => { + const form = new FormData() + form.append('file', file) + return request + .post>(`${base}/inspect`, form, longRequest) + .then(unwrapApiResponse) +} +export const uploadDocumentFont = (form: FormData) => + request.post>(base, form, longRequest).then(unwrapApiResponse) +export const checkDocumentFont = (id: string) => + request + .post>(`${base}/${id}/check`, undefined, longRequest) + .then(unwrapApiResponse) +export const previewDocumentFont = (id: string) => + request.get(`${base}/${id}/preview`, { ...longRequest, responseType: 'blob' }) +function requireSuccess(response: ApiResponse) { + if (!isSuccessCode(response.code)) throw new Error(response.message || '设置失败') +} +export const activateDocumentFont = (id: string) => + request + .put>(`${base}/${id}/default`, undefined, longRequest) + .then(requireSuccess) +export const setDocumentFontEnabled = (id: string, enabled: boolean) => + request.put>(`${base}/${id}/status`, { enabled }).then(requireSuccess) diff --git a/clinical-web/src/api/workbench/signing.ts b/clinical-web/src/api/workbench/signing.ts index 684caf9..3e56bda 100644 --- a/clinical-web/src/api/workbench/signing.ts +++ b/clinical-web/src/api/workbench/signing.ts @@ -895,6 +895,7 @@ function mapSigningTask( createdAt: formatApiDateTime(dto.createdAt), updatedAt: formatApiDateTime(dto.updatedAt), rowVersion: dto.rowVersion, + documentFont: dto.documentFont ?? null, backendStatus: dto.status, signedAt: formatApiDateTime(dto.signedAt) || undefined, voidReason: dto.voidReason ?? undefined, diff --git a/clinical-web/src/api/workbench/types.ts b/clinical-web/src/api/workbench/types.ts index df9a377..fe27287 100644 --- a/clinical-web/src/api/workbench/types.ts +++ b/clinical-web/src/api/workbench/types.ts @@ -1,3 +1,4 @@ +import type { DocumentFont } from '../../../../packages/document-fonts' import type { ApiResponse, PageQuery, PageResult } from '@/types/common' export type WorkbenchCampus = string @@ -274,6 +275,7 @@ export type SigningFieldValue = string | boolean | string[] export type SigningAnswers = Record export interface SigningTaskRecord extends WorkbenchTask { + documentFont?: DocumentFont | null templateVersionId: string campus: WorkbenchCampus campusId?: string @@ -532,6 +534,7 @@ export interface SignTaskResponseDto { visitSnapshotSchemaVersion: string visitSnapshotCapturedAt: string visitSnapshotHash: string + documentFont?: DocumentFont | null templateContentSha256: string templateFileSha256: string smsMobileMasked: string | null diff --git a/clinical-web/src/components/document/DocumentPaper.vue b/clinical-web/src/components/document/DocumentPaper.vue index fa74546..321608a 100644 --- a/clinical-web/src/components/document/DocumentPaper.vue +++ b/clinical-web/src/components/document/DocumentPaper.vue @@ -14,6 +14,9 @@ */ import { computed, onMounted, ref, watch } from 'vue' +import { loadDocumentFont, type DocumentFont } from '../../../../packages/document-fonts' +import { getDefaultDocumentFont } from '@/api/management/document-fonts' + import type { SigningAnswers } from '@/api/workbench/types' import { applyAnswerToFieldHost, @@ -36,6 +39,8 @@ const props = withDefaults( defineProps<{ /** 文书正文(模板定稿 / 任务固化快照) */ contentHtml: string + /** undefined: template preview uses default; null: historical task retains legacy font. */ + documentFont?: DocumentFont | null /** 回填上下文:患者信息 / 签名 / 勾选 / 已填取值 */ data?: SigningDocumentData /** 填写项可否改动、签名位可否点击采集 */ @@ -62,6 +67,7 @@ const props = withDefaults( variant?: 'plain' | 'card' }>(), { + documentFont: undefined, data: () => ({}), interactive: false, signatureActionable: undefined, @@ -80,6 +86,26 @@ const emit = defineEmits<{ signatureRequest: [request: SigningSignatureRequest] }>() +const fontReady = ref(false) +const fontError = ref('') +const fontFamily = ref('') +let fontLoadVersion = 0 +async function prepareFont() { + const version = ++fontLoadVersion + fontReady.value = false + fontError.value = '' + try { + const font = + props.documentFont === undefined ? await getDefaultDocumentFont() : props.documentFont + const family = await loadDocumentFont(font, import.meta.env.VITE_API_BASE_URL || '/api') + if (version !== fontLoadVersion) return + fontFamily.value = family + fontReady.value = true + } catch { + if (version === fontLoadVersion) fontError.value = '文书字体加载失败,请重试。' + } +} +watch(() => props.documentFont, prepareFont, { immediate: true }) const containerRef = ref(null) /** @@ -300,6 +326,7 @@ onMounted(() => { /** 供「导出 PDF」使用:拿到已渲染的文书容器(含固定页眉与签署态取值) */ function getDocumentElement() { + if (!fontReady.value) return null return containerRef.value } @@ -310,7 +337,16 @@ defineExpose({ getDocumentElement }) -
+

+ {{ fontError || '正在加载文书字体…' }} + +

+
diff --git a/clinical-web/src/views/management/settings/index.vue b/clinical-web/src/views/management/settings/index.vue index d615b7c..d4889d3 100644 --- a/clinical-web/src/views/management/settings/index.vue +++ b/clinical-web/src/views/management/settings/index.vue @@ -6,7 +6,7 @@ * 只显示所选分区(V5.7 加索引 → V5.8 改单选切换 → V6.0 去卡片描述、单列铺满)。 * * 分区与接口现状: - * - 「菜单设置」是唯一接了真实接口的分区(菜单资源 CRUD,见 MenuResourceSettings), + * - 「菜单设置」和「文书字体」接入真实接口;菜单分区(菜单资源 CRUD,见 MenuResourceSettings), * 本页只负责把它放进索引,**不改它的实现**;无 `system:config:menu` 权限时整项不出现。 * - 其余分区(签署参数 / 短信模板 / 签署设备管理 / 签署状态推送 / 第三方系统嵌入 / * 文档类型设置)**暂时都没有后端接口**,全部用页面内演示数据渲染,刷新即回初值。 @@ -17,6 +17,7 @@ import { ElMessage } from 'element-plus' import { useLoginStore } from '@/stores/login' +import DocumentFontSettings from './components/DocumentFontSettings.vue' import DeviceSettingsCard from './components/DeviceSettingsCard.vue' import DocTypeSettingsCard from './components/DocTypeSettingsCard.vue' import EmbedSettingsCard from './components/EmbedSettingsCard.vue' @@ -55,11 +56,12 @@ const EMBED_URL = const loginStore = useLoginStore() const canManageMenus = computed(() => loginStore.hasPermission('system:config:menu')) -type SectionKey = 'sign' | 'sms' | 'device' | 'push' | 'embed' | 'menus' | 'docType' +type SectionKey = 'sign' | 'sms' | 'device' | 'push' | 'embed' | 'menus' | 'docType' | 'fonts' const sections = computed>(() => { const list: Array<{ key: SectionKey; label: string }> = [ { key: 'sign', label: '签署参数' }, + { key: 'fonts', label: '文书字体' }, { key: 'sms', label: '短信模板' }, { key: 'device', label: '签署设备管理' }, { key: 'push', label: '签署状态推送' }, @@ -282,6 +284,8 @@ function removeDocType(name: string) { @toggle="toggleSetting" /> + +
(() => ({ >(); + +export function loadDocumentFont( + font: DocumentFont | null, + apiBase: string, +): Promise { + if (!font) return Promise.resolve(""); + const key = `${apiBase}:${font.id}:${font.regularSha256}:${font.boldSha256 ?? ""}`; + const existing = loaded.get(key); + if (existing) return existing; + const family = `Medisign-${font.regularSha256}-${font.boldSha256 ?? "regular"}`; + const load = async () => { + const descriptors = [ + { sha: font.regularSha256, weight: "400" }, + { sha: font.boldSha256 ?? font.regularSha256, weight: "700" }, + ]; + const faces = await Promise.all( + descriptors.map(async ({ sha, weight }) => { + const url = `${apiBase.replace(/\/$/, "")}/v1/document-fonts/${encodeURIComponent(font.id)}/${sha}.ttf`; + const response = await fetch(url, { + signal: AbortSignal.timeout(30000), + }); + if (!response.ok) + throw new Error("文书字体加载失败,请重试或联系管理员。"); + const bytes = await response.arrayBuffer(); + // The URL names an immutable digest; verify before registering the face. + if (globalThis.crypto?.subtle) { + const digest = await crypto.subtle.digest("SHA-256", bytes); + const actual = Array.from(new Uint8Array(digest), (byte) => + byte.toString(16).padStart(2, "0"), + ).join(""); + if (actual !== sha) throw new Error("文书字体校验失败。"); + } + return new FontFace(family, bytes, { weight }).load(); + }), + ); + for (const face of faces) document.fonts.add(face); + return `"${family}"`; + }; + const promise = load().catch((error: unknown) => { + loaded.delete(key); + throw error; + }); + loaded.set(key, promise); + return promise; +} diff --git a/patient-h5/src/api/types.ts b/patient-h5/src/api/types.ts index be22436..5640622 100644 --- a/patient-h5/src/api/types.ts +++ b/patient-h5/src/api/types.ts @@ -1,3 +1,4 @@ +import type { DocumentFont } from '../../../packages/document-fonts' export interface ApiResponse { code: number | string message: string @@ -92,6 +93,7 @@ export interface SigningContextTemplate { } export interface SigningContextDocument { + font?: DocumentFont | null contentHtml?: string | null contentSha256?: string | null } diff --git a/patient-h5/src/components/DocumentPaper.vue b/patient-h5/src/components/DocumentPaper.vue index 0dce53a..e01cb75 100644 --- a/patient-h5/src/components/DocumentPaper.vue +++ b/patient-h5/src/components/DocumentPaper.vue @@ -6,6 +6,7 @@ const props = withDefaults( defineProps<{ /** 文书正文 HTML(含占位符),由 context 接口返回 */ contentHtml: string + fontFamily?: string fields?: SigningFormField[] patientAnswers?: Record interactive?: boolean @@ -22,6 +23,7 @@ const props = withDefaults( signedAt?: string | null }>(), { + fontFamily: '', answers: () => ({}), fields: () => [], patientAnswers: () => ({}), @@ -232,10 +234,22 @@ watch( 正文来自医院自己的文书模板(经后端存储下发),不是用户输入,因此这里用 v-html 渲染。 患者端不解析、不执行其中任何脚本 —— 后端下发前应已做清洗。 --> -
+