feat(font): 新增字体设置并统一医护端与患者端文书字体
This commit is contained in:
@@ -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`。
|
||||
|
||||
@@ -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<ApiResponse<FontSettings>>(base).then(unwrapApiResponse)
|
||||
export const getDefaultDocumentFont = () =>
|
||||
request.get<ApiResponse<DocumentFont | null>>(`${base}/current`).then(unwrapNullableApiResponse)
|
||||
export const inspectDocumentFont = (file: File) => {
|
||||
const form = new FormData()
|
||||
form.append('file', file)
|
||||
return request
|
||||
.post<ApiResponse<FontFaceInfo[]>>(`${base}/inspect`, form, longRequest)
|
||||
.then(unwrapApiResponse)
|
||||
}
|
||||
export const uploadDocumentFont = (form: FormData) =>
|
||||
request.post<ApiResponse<FontRecord>>(base, form, longRequest).then(unwrapApiResponse)
|
||||
export const checkDocumentFont = (id: string) =>
|
||||
request
|
||||
.post<ApiResponse<FontCheck>>(`${base}/${id}/check`, undefined, longRequest)
|
||||
.then(unwrapApiResponse)
|
||||
export const previewDocumentFont = (id: string) =>
|
||||
request.get<Blob>(`${base}/${id}/preview`, { ...longRequest, responseType: 'blob' })
|
||||
function requireSuccess(response: ApiResponse<unknown>) {
|
||||
if (!isSuccessCode(response.code)) throw new Error(response.message || '设置失败')
|
||||
}
|
||||
export const activateDocumentFont = (id: string) =>
|
||||
request
|
||||
.put<ApiResponse<unknown>>(`${base}/${id}/default`, undefined, longRequest)
|
||||
.then(requireSuccess)
|
||||
export const setDocumentFontEnabled = (id: string, enabled: boolean) =>
|
||||
request.put<ApiResponse<unknown>>(`${base}/${id}/status`, { enabled }).then(requireSuccess)
|
||||
@@ -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,
|
||||
|
||||
@@ -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<string, SigningFieldValue>
|
||||
|
||||
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
|
||||
|
||||
@@ -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<HTMLElement | null>(null)
|
||||
|
||||
/**
|
||||
@@ -300,6 +326,7 @@ onMounted(() => {
|
||||
|
||||
/** 供「导出 PDF」使用:拿到已渲染的文书容器(含固定页眉与签署态取值) */
|
||||
function getDocumentElement() {
|
||||
if (!fontReady.value) return null
|
||||
return containerRef.value
|
||||
}
|
||||
|
||||
@@ -310,7 +337,16 @@ defineExpose({ getDocumentElement })
|
||||
<!-- eslint-disable vue/no-v-html -- 内容为本系统定稿的文书 HTML(编辑器产出、仅医务人员可改),非任意用户输入 -->
|
||||
<!-- doc-paper--a4:无论哪种 variant 都是「一页 A4」(比例 + 一般文书页边距),
|
||||
variant 只决定盒子装饰 —— card 自带圆角阴影,plain 交给调用方 -->
|
||||
<article class="doc-paper doc-paper--a4" :class="{ 'doc-paper--card': variant === 'card' }">
|
||||
<p v-if="!fontReady" role="status">
|
||||
{{ fontError || '正在加载文书字体…' }}
|
||||
<button v-if="fontError" type="button" @click="prepareFont">重试</button>
|
||||
</p>
|
||||
<article
|
||||
class="doc-paper doc-paper--a4"
|
||||
:class="{ 'doc-paper--card': variant === 'card', 'managed-font': Boolean(fontFamily) }"
|
||||
:style="{ '--document-font': fontFamily, visibility: fontReady ? 'visible' : 'hidden' }"
|
||||
:inert="!fontReady"
|
||||
>
|
||||
<div
|
||||
ref="containerRef"
|
||||
class="doc-paper__body"
|
||||
@@ -325,6 +361,12 @@ defineExpose({ getDocumentElement })
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.managed-font,
|
||||
.managed-font :deep(*) {
|
||||
font-family: var(--document-font) !important;
|
||||
font-synthesis: none;
|
||||
}
|
||||
|
||||
/* 基础排版(p / h2 / h3 / table / th / td / img)在 utils/document-paper.ts 的
|
||||
DOCUMENT_PAPER_CSS 里,运行期注入 head —— 那里是唯一一份。
|
||||
这里只保留容器自己的盒子。 */
|
||||
|
||||
@@ -20,6 +20,7 @@ import type {
|
||||
} from '@/utils/signing-document'
|
||||
|
||||
import DocumentPaper from './DocumentPaper.vue'
|
||||
import type { DocumentFont } from '../../../../packages/document-fonts'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
@@ -35,6 +36,7 @@ const props = withDefaults(
|
||||
activeTab?: string
|
||||
/** 正文(模板定稿 / 任务固化快照) */
|
||||
contentHtml: string
|
||||
documentFont?: DocumentFont | null
|
||||
data?: SigningDocumentData
|
||||
interactive?: boolean
|
||||
signatureActionable?: boolean
|
||||
@@ -65,6 +67,7 @@ const props = withDefaults(
|
||||
stacked?: boolean
|
||||
}>(),
|
||||
{
|
||||
documentFont: undefined,
|
||||
subtitle: '',
|
||||
badges: () => [],
|
||||
tabs: undefined,
|
||||
@@ -164,6 +167,7 @@ defineExpose({ getDocumentElement })
|
||||
<DocumentPaper
|
||||
v-if="hasPaper"
|
||||
ref="paperRef"
|
||||
:document-font="documentFont"
|
||||
:content-html="contentHtml"
|
||||
:data="data"
|
||||
:interactive="interactive"
|
||||
|
||||
@@ -114,6 +114,7 @@ defineExpose({ getDocumentElement })
|
||||
<div class="sign-doc-body">
|
||||
<DocumentPaper
|
||||
ref="paperRef"
|
||||
:document-font="task.documentFont ?? null"
|
||||
:content-html="task.documentContentHtml ?? ''"
|
||||
:data="documentData"
|
||||
:interactive="interactive"
|
||||
|
||||
@@ -0,0 +1,294 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { useLoginStore } from '@/stores/login'
|
||||
import {
|
||||
getDocumentFontSettings,
|
||||
inspectDocumentFont,
|
||||
uploadDocumentFont,
|
||||
checkDocumentFont,
|
||||
previewDocumentFont,
|
||||
activateDocumentFont,
|
||||
setDocumentFontEnabled,
|
||||
type FontSettings,
|
||||
type FontFaceInfo,
|
||||
type FontRecord,
|
||||
type FontCheck,
|
||||
} from '@/api/management/document-fonts'
|
||||
import { loadDocumentFont } from '../../../../../../packages/document-fonts'
|
||||
const canEdit = computed(() => useLoginStore().hasPermission('system:config:edit'))
|
||||
const settings = ref<FontSettings | null>(null)
|
||||
const busy = ref(false)
|
||||
const error = ref('')
|
||||
const file = ref<File | null>(null)
|
||||
const boldFile = ref<File | null>(null)
|
||||
const faces = ref<FontFaceInfo[]>([])
|
||||
const boldFaces = ref<FontFaceInfo[]>([])
|
||||
const face = ref(0)
|
||||
const boldFace = ref(0)
|
||||
const licensed = ref(false)
|
||||
const checks = ref<Record<string, FontCheck>>({})
|
||||
const previewUrl = ref('')
|
||||
const family = ref('')
|
||||
const previewName = ref('')
|
||||
const apiBase = import.meta.env.VITE_API_BASE_URL || '/api'
|
||||
async function run(action: () => Promise<void>) {
|
||||
busy.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
await action()
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : '字体操作失败,请重试。'
|
||||
} finally {
|
||||
busy.value = false
|
||||
}
|
||||
}
|
||||
async function refresh() {
|
||||
settings.value = await getDocumentFontSettings()
|
||||
}
|
||||
async function selectFile(event: Event, bold = false) {
|
||||
const selected = (event.target as HTMLInputElement).files?.[0] ?? null
|
||||
if (bold) {
|
||||
boldFile.value = null
|
||||
boldFaces.value = []
|
||||
boldFace.value = 0
|
||||
} else {
|
||||
file.value = null
|
||||
faces.value = []
|
||||
face.value = 0
|
||||
}
|
||||
licensed.value = false
|
||||
if (!selected) return
|
||||
await run(async () => {
|
||||
if (selected.size > 32 * 1024 * 1024) throw new Error('单个字体文件不能超过 32 MiB。')
|
||||
const result = await inspectDocumentFont(selected)
|
||||
if (bold) {
|
||||
boldFile.value = selected
|
||||
boldFaces.value = result
|
||||
} else {
|
||||
file.value = selected
|
||||
faces.value = result
|
||||
}
|
||||
})
|
||||
}
|
||||
async function upload() {
|
||||
if (!file.value || !licensed.value) return
|
||||
const form = new FormData()
|
||||
form.append('file', file.value)
|
||||
form.append('faceIndex', String(face.value))
|
||||
form.append('licensed', 'true')
|
||||
if (boldFile.value) {
|
||||
form.append('boldFile', boldFile.value)
|
||||
form.append('boldFaceIndex', String(boldFace.value))
|
||||
}
|
||||
await run(async () => {
|
||||
await uploadDocumentFont(form)
|
||||
await refresh()
|
||||
ElMessage.success('字体已上传,可检测后设为默认字体')
|
||||
})
|
||||
}
|
||||
async function check(font: FontRecord) {
|
||||
await run(async () => {
|
||||
checks.value[font.id] = await checkDocumentFont(font.id)
|
||||
await refresh()
|
||||
})
|
||||
}
|
||||
async function preview(font: FontRecord) {
|
||||
await run(async () => {
|
||||
const nextFamily = await loadDocumentFont(font, apiBase)
|
||||
const pdf = await previewDocumentFont(font.id)
|
||||
if (pdf.type !== 'application/pdf') throw new Error('测试 PDF 生成失败。')
|
||||
if (previewUrl.value) URL.revokeObjectURL(previewUrl.value)
|
||||
family.value = nextFamily
|
||||
previewName.value = font.name
|
||||
previewUrl.value = URL.createObjectURL(pdf)
|
||||
})
|
||||
}
|
||||
async function activate(font: FontRecord) {
|
||||
await run(async () => {
|
||||
await loadDocumentFont(font, apiBase)
|
||||
await activateDocumentFont(font.id)
|
||||
await refresh()
|
||||
ElMessage.success('已设为默认字体,将用于新建签署任务')
|
||||
})
|
||||
}
|
||||
async function toggle(font: FontRecord) {
|
||||
await run(async () => {
|
||||
await setDocumentFontEnabled(font.id, !font.enabled)
|
||||
await refresh()
|
||||
})
|
||||
}
|
||||
onMounted(() => run(refresh))
|
||||
function closePreview() {
|
||||
if (previewUrl.value) URL.revokeObjectURL(previewUrl.value)
|
||||
previewUrl.value = ''
|
||||
}
|
||||
onBeforeUnmount(closePreview)
|
||||
</script>
|
||||
<template>
|
||||
<section class="font-settings" :aria-busy="busy">
|
||||
<h2>文书字体</h2>
|
||||
<p>用于医护端、患者端文书和正式 PDF。切换默认字体只影响新建签署任务,已有任务保留原字体。</p>
|
||||
<el-alert v-if="error" :title="error" type="error" :closable="false" />
|
||||
<p v-if="settings && !settings.defaultFontId">
|
||||
当前使用部署字体。文件状态:{{
|
||||
settings.legacyFileReadable ? '可读取' : '未配置或不可读取'
|
||||
}}。上传并启用后,新任务将使用统一字体。
|
||||
</p>
|
||||
<fieldset v-if="canEdit" :disabled="busy">
|
||||
<legend>上传字体</legend>
|
||||
<label
|
||||
>常规字体(TTF / TTC,最大 32 MiB)<input
|
||||
type="file"
|
||||
accept=".ttf,.ttc"
|
||||
@change="selectFile($event)"
|
||||
/></label>
|
||||
<el-select v-if="faces.length" v-model="face" aria-label="常规字体字面" :disabled="busy">
|
||||
<el-option
|
||||
v-for="item in faces"
|
||||
:key="item.index"
|
||||
:value="item.index"
|
||||
:label="`${item.name} · ${item.style}`"
|
||||
/>
|
||||
</el-select>
|
||||
<label
|
||||
>粗体字体(可选)<input type="file" accept=".ttf,.ttc" @change="selectFile($event, true)"
|
||||
/></label>
|
||||
<el-select
|
||||
v-if="boldFaces.length"
|
||||
v-model="boldFace"
|
||||
aria-label="粗体字体字面"
|
||||
:disabled="busy"
|
||||
>
|
||||
<el-option
|
||||
v-for="item in boldFaces"
|
||||
:key="item.index"
|
||||
:value="item.index"
|
||||
:label="`${item.name} · ${item.style}`"
|
||||
/>
|
||||
</el-select>
|
||||
<small>未上传粗体时,粗体位置使用常规字形。</small>
|
||||
<label
|
||||
><input v-model="licensed" type="checkbox" />我确认该字体允许 PDF 嵌入、Web
|
||||
分发及格式转换。</label
|
||||
>
|
||||
<el-button type="primary" :disabled="busy || !file || !licensed" @click="upload"
|
||||
>上传字体</el-button
|
||||
>
|
||||
</fieldset>
|
||||
<details v-if="settings?.nodes.length" class="node-health">
|
||||
<summary>各服务节点最近检测结果(超过 10 分钟需重新确认)</summary>
|
||||
<p v-for="node in settings.nodes" :key="`${node.fontId}:${node.node}`">
|
||||
{{ settings.fonts.find((item) => item.id === node.fontId)?.name }} · {{ node.node }}:
|
||||
{{ Date.now() - Date.parse(node.checkedAt) > 600000 ? '检测结果已过期' : node.message }}({{
|
||||
node.checkedAt
|
||||
}})
|
||||
</p>
|
||||
</details>
|
||||
<el-table :data="settings?.fonts ?? []" empty-text="暂无上传字体">
|
||||
<el-table-column label="字体" min-width="220"
|
||||
><template #default="{ row }"
|
||||
>{{ row.name }} <el-tag v-if="row.id === settings?.defaultFontId">默认</el-tag>
|
||||
<div class="muted">{{ row.boldSha256 ? '含粗体' : '仅常规字形' }}</div></template
|
||||
></el-table-column
|
||||
>
|
||||
<el-table-column label="状态" width="90"
|
||||
><template #default="{ row }">{{
|
||||
row.enabled ? '启用' : '停用'
|
||||
}}</template></el-table-column
|
||||
>
|
||||
<el-table-column label="检测" min-width="230"
|
||||
><template #default="{ row }"
|
||||
><span v-if="checks[row.id]" :class="{ failed: !checks[row.id]?.available }"
|
||||
>{{ checks[row.id]?.message
|
||||
}}<small class="muted"
|
||||
>节点 {{ checks[row.id]?.node }} · {{ checks[row.id]?.checkedAt }}</small
|
||||
></span
|
||||
><span v-else>尚未检测</span></template
|
||||
></el-table-column
|
||||
>
|
||||
<el-table-column label="操作" min-width="300"
|
||||
><template #default="{ row }">
|
||||
<el-button link :disabled="busy" @click="check(row)">检测</el-button>
|
||||
<el-button link :disabled="busy" @click="preview(row)">预览</el-button>
|
||||
<el-button
|
||||
v-if="canEdit"
|
||||
link
|
||||
type="primary"
|
||||
:disabled="busy || !row.enabled || row.id === settings?.defaultFontId"
|
||||
@click="activate(row)"
|
||||
>设为默认</el-button
|
||||
>
|
||||
<el-button
|
||||
v-if="canEdit"
|
||||
link
|
||||
:disabled="busy || row.id === settings?.defaultFontId"
|
||||
@click="toggle(row)"
|
||||
>{{ row.enabled ? '停用' : '启用' }}</el-button
|
||||
>
|
||||
</template></el-table-column
|
||||
>
|
||||
</el-table>
|
||||
<el-dialog
|
||||
:model-value="Boolean(previewUrl)"
|
||||
title="文书字体预览"
|
||||
width="900px"
|
||||
@close="closePreview"
|
||||
>
|
||||
<p>{{ previewName }}</p>
|
||||
<div class="sample" :style="{ fontFamily: family, fontSynthesis: 'none' }">
|
||||
<h3>知情同意书</h3>
|
||||
<p>患者姓名:张三 性别:男 年龄:36</p>
|
||||
<p>签署日期:2026-09-30 医师确认</p>
|
||||
</div>
|
||||
<iframe v-if="previewUrl" :src="previewUrl" title="后端生成的测试 PDF" class="pdf-preview" />
|
||||
</el-dialog>
|
||||
</section>
|
||||
</template>
|
||||
<style scoped>
|
||||
.font-settings {
|
||||
padding: 24px;
|
||||
background: white;
|
||||
border-radius: 10px;
|
||||
}
|
||||
h2 {
|
||||
margin-top: 0;
|
||||
font-size: 18px;
|
||||
}
|
||||
p,
|
||||
small {
|
||||
color: var(--mut);
|
||||
}
|
||||
fieldset {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
margin: 20px 0;
|
||||
padding: 18px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
}
|
||||
label {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
}
|
||||
.muted {
|
||||
display: block;
|
||||
color: var(--mut);
|
||||
font-size: 12px;
|
||||
}
|
||||
.failed {
|
||||
color: var(--err);
|
||||
}
|
||||
.sample {
|
||||
padding: 16px;
|
||||
border: 1px solid var(--line);
|
||||
}
|
||||
.pdf-preview {
|
||||
width: 100%;
|
||||
height: 480px;
|
||||
border: 0;
|
||||
margin-top: 12px;
|
||||
}
|
||||
</style>
|
||||
@@ -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<Array<{ key: SectionKey; label: string }>>(() => {
|
||||
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"
|
||||
/>
|
||||
|
||||
<DocumentFontSettings v-else-if="activeSection === 'fonts'" />
|
||||
|
||||
<SmsSettingsCard
|
||||
v-else-if="activeSection === 'sms'"
|
||||
:channel="smsChannel"
|
||||
|
||||
@@ -202,6 +202,7 @@ function clearSignature() {
|
||||
<!-- 公众号页把**完整文书**摆出来(原型 #mdWxPt 里就是整篇 docBody) -->
|
||||
<div v-if="isWx" class="mobile-paper">
|
||||
<DocumentPaper
|
||||
:document-font="task?.documentFont ?? null"
|
||||
:content-html="task.documentContentHtml ?? ''"
|
||||
:data="documentData"
|
||||
:interactive="false"
|
||||
|
||||
@@ -49,6 +49,7 @@ const documentData = computed<SigningDocumentData>(() => ({
|
||||
<!-- 宽度用 960 而不是 900:舞台内宽 960−48=912,820px 的纸才放得下、两侧留白也舒服。
|
||||
纸窄了(原 640)版心只剩 446px,用药信息表会被挤坏 -->
|
||||
<DocumentPreviewDialog
|
||||
:document-font="task?.documentFont ?? null"
|
||||
:visible="visible && Boolean(task)"
|
||||
title="在线预览(PDF)"
|
||||
:subtitle="subtitle"
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
/** Immutable font descriptor shared by clinical and patient document renderers. */
|
||||
export interface DocumentFont {
|
||||
id: string;
|
||||
name: string;
|
||||
regularSha256: string;
|
||||
boldSha256: string | null;
|
||||
}
|
||||
|
||||
const loaded = new Map<string, Promise<string>>();
|
||||
|
||||
export function loadDocumentFont(
|
||||
font: DocumentFont | null,
|
||||
apiBase: string,
|
||||
): Promise<string> {
|
||||
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;
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { DocumentFont } from '../../../packages/document-fonts'
|
||||
export interface ApiResponse<T> {
|
||||
code: number | string
|
||||
message: string
|
||||
@@ -92,6 +93,7 @@ export interface SigningContextTemplate {
|
||||
}
|
||||
|
||||
export interface SigningContextDocument {
|
||||
font?: DocumentFont | null
|
||||
contentHtml?: string | null
|
||||
contentSha256?: string | null
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ const props = withDefaults(
|
||||
defineProps<{
|
||||
/** 文书正文 HTML(含占位符),由 context 接口返回 */
|
||||
contentHtml: string
|
||||
fontFamily?: string
|
||||
fields?: SigningFormField[]
|
||||
patientAnswers?: Record<string, PatientAnswer>
|
||||
interactive?: boolean
|
||||
@@ -22,6 +23,7 @@ const props = withDefaults(
|
||||
signedAt?: string | null
|
||||
}>(),
|
||||
{
|
||||
fontFamily: '',
|
||||
answers: () => ({}),
|
||||
fields: () => [],
|
||||
patientAnswers: () => ({}),
|
||||
@@ -232,10 +234,22 @@ watch(
|
||||
正文来自医院自己的文书模板(经后端存储下发),不是用户输入,因此这里用 v-html 渲染。
|
||||
患者端不解析、不执行其中任何脚本 —— 后端下发前应已做清洗。
|
||||
-->
|
||||
<article ref="paperRef" class="patient-paper" v-html="contentHtml" />
|
||||
<article
|
||||
ref="paperRef"
|
||||
class="patient-paper"
|
||||
:class="{ 'managed-font': Boolean(fontFamily) }"
|
||||
:style="{ '--document-font': fontFamily }"
|
||||
v-html="contentHtml"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.managed-font,
|
||||
.managed-font :deep(*) {
|
||||
font-family: var(--document-font) !important;
|
||||
font-synthesis: none;
|
||||
}
|
||||
|
||||
.patient-paper {
|
||||
padding: 18px 16px 22px;
|
||||
color: #173b56;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { loadDocumentFont } from '../../../packages/document-fonts'
|
||||
import { reactive } from 'vue'
|
||||
|
||||
import {
|
||||
@@ -59,6 +60,8 @@ const initialState: SignFlowState = {
|
||||
age: '',
|
||||
visitNo: 'MZ20260827018',
|
||||
department: '',
|
||||
documentFontFamily: '',
|
||||
documentFontReady: false,
|
||||
documentTitle: '儿科急诊特殊检查知情同意书',
|
||||
templateVersion: 'v1.0.0',
|
||||
documentContentHtml: '',
|
||||
@@ -447,6 +450,12 @@ async function loadSigningContext(): Promise<SigningContextResponse | null> {
|
||||
try {
|
||||
const context = await fetchSigningContext(proof)
|
||||
applyContext(context)
|
||||
signingState.documentFontReady = false
|
||||
signingState.documentFontFamily = await loadDocumentFont(
|
||||
context.document?.font ?? null,
|
||||
import.meta.env.VITE_API_BASE_URL || '/api',
|
||||
)
|
||||
signingState.documentFontReady = true
|
||||
persistSession()
|
||||
return context
|
||||
} catch (error) {
|
||||
@@ -488,6 +497,7 @@ function applyDemoContext() {
|
||||
signingState.fields = [
|
||||
{ id: DEMO_PATIENT_FIELD_ID, label: '患者签名', kind: 'signature', required: true },
|
||||
]
|
||||
signingState.documentFontReady = true
|
||||
signingState.documentLoaded = true
|
||||
signingState.documentError = ''
|
||||
signingState.taskStatus = 'WAITING_SIGN'
|
||||
@@ -583,7 +593,9 @@ export async function submitRealSignature(input: {
|
||||
!signingState.taskId ||
|
||||
!signingState.deliveryId ||
|
||||
!signingState.uploadToken ||
|
||||
!input.consentAccepted
|
||||
!input.consentAccepted ||
|
||||
!signingState.documentLoaded ||
|
||||
!signingState.documentFontReady
|
||||
) {
|
||||
throw new Error('签署凭证或知情确认状态无效,请重新打开签署链接。')
|
||||
}
|
||||
|
||||
@@ -26,6 +26,8 @@ export interface SignFlowState {
|
||||
visitNo: string
|
||||
department: string
|
||||
|
||||
documentFontFamily: string
|
||||
documentFontReady: boolean
|
||||
documentTitle: string
|
||||
templateVersion: string
|
||||
/**
|
||||
|
||||
@@ -56,6 +56,7 @@ async function continueToSigner() {
|
||||
</section>
|
||||
|
||||
<DocumentPaper
|
||||
:font-family="signingState.documentFontFamily"
|
||||
:content-html="signingState.documentContentHtml"
|
||||
:answers="signingState.staffAnswers"
|
||||
:fields="signingState.form?.fields"
|
||||
|
||||
@@ -117,6 +117,7 @@ async function submitSigning() {
|
||||
|
||||
<!-- 签完的字会实时回填到文书里的签名位,患者能立刻看到自己签在哪一格 -->
|
||||
<DocumentPaper
|
||||
:font-family="signingState.documentFontFamily"
|
||||
:content-html="signingState.documentContentHtml"
|
||||
:answers="signingState.staffAnswers"
|
||||
:fields="signingState.form?.fields"
|
||||
|
||||
Reference in New Issue
Block a user