feat(font): 新增字体设置并统一医护端与患者端文书字体

This commit is contained in:
xy
2026-09-30 08:35:02 +08:00
parent fe8cb01d4d
commit c2984bfd98
18 changed files with 511 additions and 5 deletions
@@ -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"