Merge branch 'master' of https://gitea.xinyuxi.com/zhanggaoming/xh-medical-sign-web
This commit is contained in:
@@ -117,3 +117,9 @@ patient-h5/dist/ → 患者手机签署网页
|
|||||||
3. 再接入 `packages` 中的公共类型和接口客户端;
|
3. 再接入 `packages` 中的公共类型和接口客户端;
|
||||||
4. 验证电子病历 iframe、单点登录和签字板调用;
|
4. 验证电子病历 iframe、单点登录和签字板调用;
|
||||||
5. 补充收费拦截、动态补签和急诊例外流程。
|
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),
|
createdAt: formatApiDateTime(dto.createdAt),
|
||||||
updatedAt: formatApiDateTime(dto.updatedAt),
|
updatedAt: formatApiDateTime(dto.updatedAt),
|
||||||
rowVersion: dto.rowVersion,
|
rowVersion: dto.rowVersion,
|
||||||
|
documentFont: dto.documentFont ?? null,
|
||||||
backendStatus: dto.status,
|
backendStatus: dto.status,
|
||||||
signedAt: formatApiDateTime(dto.signedAt) || undefined,
|
signedAt: formatApiDateTime(dto.signedAt) || undefined,
|
||||||
voidReason: dto.voidReason ?? undefined,
|
voidReason: dto.voidReason ?? undefined,
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import type { DocumentFont } from '../../../../packages/document-fonts'
|
||||||
import type { ApiResponse, PageQuery, PageResult } from '@/types/common'
|
import type { ApiResponse, PageQuery, PageResult } from '@/types/common'
|
||||||
|
|
||||||
export type WorkbenchCampus = string
|
export type WorkbenchCampus = string
|
||||||
@@ -274,6 +275,7 @@ export type SigningFieldValue = string | boolean | string[]
|
|||||||
export type SigningAnswers = Record<string, SigningFieldValue>
|
export type SigningAnswers = Record<string, SigningFieldValue>
|
||||||
|
|
||||||
export interface SigningTaskRecord extends WorkbenchTask {
|
export interface SigningTaskRecord extends WorkbenchTask {
|
||||||
|
documentFont?: DocumentFont | null
|
||||||
templateVersionId: string
|
templateVersionId: string
|
||||||
campus: WorkbenchCampus
|
campus: WorkbenchCampus
|
||||||
campusId?: string
|
campusId?: string
|
||||||
@@ -532,6 +534,7 @@ export interface SignTaskResponseDto {
|
|||||||
visitSnapshotSchemaVersion: string
|
visitSnapshotSchemaVersion: string
|
||||||
visitSnapshotCapturedAt: string
|
visitSnapshotCapturedAt: string
|
||||||
visitSnapshotHash: string
|
visitSnapshotHash: string
|
||||||
|
documentFont?: DocumentFont | null
|
||||||
templateContentSha256: string
|
templateContentSha256: string
|
||||||
templateFileSha256: string
|
templateFileSha256: string
|
||||||
smsMobileMasked: string | null
|
smsMobileMasked: string | null
|
||||||
|
|||||||
@@ -14,6 +14,9 @@
|
|||||||
*/
|
*/
|
||||||
import { computed, onMounted, ref, watch } from 'vue'
|
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 type { SigningAnswers } from '@/api/workbench/types'
|
||||||
import {
|
import {
|
||||||
applyAnswerToFieldHost,
|
applyAnswerToFieldHost,
|
||||||
@@ -36,6 +39,8 @@ const props = withDefaults(
|
|||||||
defineProps<{
|
defineProps<{
|
||||||
/** 文书正文(模板定稿 / 任务固化快照) */
|
/** 文书正文(模板定稿 / 任务固化快照) */
|
||||||
contentHtml: string
|
contentHtml: string
|
||||||
|
/** undefined: template preview uses default; null: historical task retains legacy font. */
|
||||||
|
documentFont?: DocumentFont | null
|
||||||
/** 回填上下文:患者信息 / 签名 / 勾选 / 已填取值 */
|
/** 回填上下文:患者信息 / 签名 / 勾选 / 已填取值 */
|
||||||
data?: SigningDocumentData
|
data?: SigningDocumentData
|
||||||
/** 填写项可否改动、签名位可否点击采集 */
|
/** 填写项可否改动、签名位可否点击采集 */
|
||||||
@@ -62,6 +67,7 @@ const props = withDefaults(
|
|||||||
variant?: 'plain' | 'card'
|
variant?: 'plain' | 'card'
|
||||||
}>(),
|
}>(),
|
||||||
{
|
{
|
||||||
|
documentFont: undefined,
|
||||||
data: () => ({}),
|
data: () => ({}),
|
||||||
interactive: false,
|
interactive: false,
|
||||||
signatureActionable: undefined,
|
signatureActionable: undefined,
|
||||||
@@ -80,6 +86,26 @@ const emit = defineEmits<{
|
|||||||
signatureRequest: [request: SigningSignatureRequest]
|
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)
|
const containerRef = ref<HTMLElement | null>(null)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -300,6 +326,7 @@ onMounted(() => {
|
|||||||
|
|
||||||
/** 供「导出 PDF」使用:拿到已渲染的文书容器(含固定页眉与签署态取值) */
|
/** 供「导出 PDF」使用:拿到已渲染的文书容器(含固定页眉与签署态取值) */
|
||||||
function getDocumentElement() {
|
function getDocumentElement() {
|
||||||
|
if (!fontReady.value) return null
|
||||||
return containerRef.value
|
return containerRef.value
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -310,7 +337,16 @@ defineExpose({ getDocumentElement })
|
|||||||
<!-- eslint-disable vue/no-v-html -- 内容为本系统定稿的文书 HTML(编辑器产出、仅医务人员可改),非任意用户输入 -->
|
<!-- eslint-disable vue/no-v-html -- 内容为本系统定稿的文书 HTML(编辑器产出、仅医务人员可改),非任意用户输入 -->
|
||||||
<!-- doc-paper--a4:无论哪种 variant 都是「一页 A4」(比例 + 一般文书页边距),
|
<!-- doc-paper--a4:无论哪种 variant 都是「一页 A4」(比例 + 一般文书页边距),
|
||||||
variant 只决定盒子装饰 —— card 自带圆角阴影,plain 交给调用方 -->
|
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
|
<div
|
||||||
ref="containerRef"
|
ref="containerRef"
|
||||||
class="doc-paper__body"
|
class="doc-paper__body"
|
||||||
@@ -325,6 +361,12 @@ defineExpose({ getDocumentElement })
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<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 的
|
/* 基础排版(p / h2 / h3 / table / th / td / img)在 utils/document-paper.ts 的
|
||||||
DOCUMENT_PAPER_CSS 里,运行期注入 head —— 那里是唯一一份。
|
DOCUMENT_PAPER_CSS 里,运行期注入 head —— 那里是唯一一份。
|
||||||
这里只保留容器自己的盒子。 */
|
这里只保留容器自己的盒子。 */
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import type {
|
|||||||
} from '@/utils/signing-document'
|
} from '@/utils/signing-document'
|
||||||
|
|
||||||
import DocumentPaper from './DocumentPaper.vue'
|
import DocumentPaper from './DocumentPaper.vue'
|
||||||
|
import type { DocumentFont } from '../../../../packages/document-fonts'
|
||||||
|
|
||||||
const props = withDefaults(
|
const props = withDefaults(
|
||||||
defineProps<{
|
defineProps<{
|
||||||
@@ -35,6 +36,7 @@ const props = withDefaults(
|
|||||||
activeTab?: string
|
activeTab?: string
|
||||||
/** 正文(模板定稿 / 任务固化快照) */
|
/** 正文(模板定稿 / 任务固化快照) */
|
||||||
contentHtml: string
|
contentHtml: string
|
||||||
|
documentFont?: DocumentFont | null
|
||||||
data?: SigningDocumentData
|
data?: SigningDocumentData
|
||||||
interactive?: boolean
|
interactive?: boolean
|
||||||
signatureActionable?: boolean
|
signatureActionable?: boolean
|
||||||
@@ -65,6 +67,7 @@ const props = withDefaults(
|
|||||||
stacked?: boolean
|
stacked?: boolean
|
||||||
}>(),
|
}>(),
|
||||||
{
|
{
|
||||||
|
documentFont: undefined,
|
||||||
subtitle: '',
|
subtitle: '',
|
||||||
badges: () => [],
|
badges: () => [],
|
||||||
tabs: undefined,
|
tabs: undefined,
|
||||||
@@ -164,6 +167,7 @@ defineExpose({ getDocumentElement })
|
|||||||
<DocumentPaper
|
<DocumentPaper
|
||||||
v-if="hasPaper"
|
v-if="hasPaper"
|
||||||
ref="paperRef"
|
ref="paperRef"
|
||||||
|
:document-font="documentFont"
|
||||||
:content-html="contentHtml"
|
:content-html="contentHtml"
|
||||||
:data="data"
|
:data="data"
|
||||||
:interactive="interactive"
|
:interactive="interactive"
|
||||||
|
|||||||
@@ -114,6 +114,7 @@ defineExpose({ getDocumentElement })
|
|||||||
<div class="sign-doc-body">
|
<div class="sign-doc-body">
|
||||||
<DocumentPaper
|
<DocumentPaper
|
||||||
ref="paperRef"
|
ref="paperRef"
|
||||||
|
:document-font="task.documentFont ?? null"
|
||||||
:content-html="task.documentContentHtml ?? ''"
|
:content-html="task.documentContentHtml ?? ''"
|
||||||
:data="documentData"
|
:data="documentData"
|
||||||
:interactive="interactive"
|
: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 去卡片描述、单列铺满)。
|
* 只显示所选分区(V5.7 加索引 → V5.8 改单选切换 → V6.0 去卡片描述、单列铺满)。
|
||||||
*
|
*
|
||||||
* 分区与接口现状:
|
* 分区与接口现状:
|
||||||
* - 「菜单设置」是唯一接了真实接口的分区(菜单资源 CRUD,见 MenuResourceSettings),
|
* - 「菜单设置」和「文书字体」接入真实接口;菜单分区(菜单资源 CRUD,见 MenuResourceSettings),
|
||||||
* 本页只负责把它放进索引,**不改它的实现**;无 `system:config:menu` 权限时整项不出现。
|
* 本页只负责把它放进索引,**不改它的实现**;无 `system:config:menu` 权限时整项不出现。
|
||||||
* - 其余分区(签署参数 / 短信模板 / 签署设备管理 / 签署状态推送 / 第三方系统嵌入 /
|
* - 其余分区(签署参数 / 短信模板 / 签署设备管理 / 签署状态推送 / 第三方系统嵌入 /
|
||||||
* 文档类型设置)**暂时都没有后端接口**,全部用页面内演示数据渲染,刷新即回初值。
|
* 文档类型设置)**暂时都没有后端接口**,全部用页面内演示数据渲染,刷新即回初值。
|
||||||
@@ -17,6 +17,7 @@ import { ElMessage } from 'element-plus'
|
|||||||
|
|
||||||
import { useLoginStore } from '@/stores/login'
|
import { useLoginStore } from '@/stores/login'
|
||||||
|
|
||||||
|
import DocumentFontSettings from './components/DocumentFontSettings.vue'
|
||||||
import DeviceSettingsCard from './components/DeviceSettingsCard.vue'
|
import DeviceSettingsCard from './components/DeviceSettingsCard.vue'
|
||||||
import DocTypeSettingsCard from './components/DocTypeSettingsCard.vue'
|
import DocTypeSettingsCard from './components/DocTypeSettingsCard.vue'
|
||||||
import EmbedSettingsCard from './components/EmbedSettingsCard.vue'
|
import EmbedSettingsCard from './components/EmbedSettingsCard.vue'
|
||||||
@@ -55,11 +56,12 @@ const EMBED_URL =
|
|||||||
const loginStore = useLoginStore()
|
const loginStore = useLoginStore()
|
||||||
const canManageMenus = computed(() => loginStore.hasPermission('system:config:menu'))
|
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 sections = computed<Array<{ key: SectionKey; label: string }>>(() => {
|
||||||
const list: Array<{ key: SectionKey; label: string }> = [
|
const list: Array<{ key: SectionKey; label: string }> = [
|
||||||
{ key: 'sign', label: '签署参数' },
|
{ key: 'sign', label: '签署参数' },
|
||||||
|
{ key: 'fonts', label: '文书字体' },
|
||||||
{ key: 'sms', label: '短信模板' },
|
{ key: 'sms', label: '短信模板' },
|
||||||
{ key: 'device', label: '签署设备管理' },
|
{ key: 'device', label: '签署设备管理' },
|
||||||
{ key: 'push', label: '签署状态推送' },
|
{ key: 'push', label: '签署状态推送' },
|
||||||
@@ -282,6 +284,8 @@ function removeDocType(name: string) {
|
|||||||
@toggle="toggleSetting"
|
@toggle="toggleSetting"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<DocumentFontSettings v-else-if="activeSection === 'fonts'" />
|
||||||
|
|
||||||
<SmsSettingsCard
|
<SmsSettingsCard
|
||||||
v-else-if="activeSection === 'sms'"
|
v-else-if="activeSection === 'sms'"
|
||||||
:channel="smsChannel"
|
:channel="smsChannel"
|
||||||
|
|||||||
@@ -202,6 +202,7 @@ function clearSignature() {
|
|||||||
<!-- 公众号页把**完整文书**摆出来(原型 #mdWxPt 里就是整篇 docBody) -->
|
<!-- 公众号页把**完整文书**摆出来(原型 #mdWxPt 里就是整篇 docBody) -->
|
||||||
<div v-if="isWx" class="mobile-paper">
|
<div v-if="isWx" class="mobile-paper">
|
||||||
<DocumentPaper
|
<DocumentPaper
|
||||||
|
:document-font="task?.documentFont ?? null"
|
||||||
:content-html="task.documentContentHtml ?? ''"
|
:content-html="task.documentContentHtml ?? ''"
|
||||||
:data="documentData"
|
:data="documentData"
|
||||||
:interactive="false"
|
:interactive="false"
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ const documentData = computed<SigningDocumentData>(() => ({
|
|||||||
<!-- 宽度用 960 而不是 900:舞台内宽 960−48=912,820px 的纸才放得下、两侧留白也舒服。
|
<!-- 宽度用 960 而不是 900:舞台内宽 960−48=912,820px 的纸才放得下、两侧留白也舒服。
|
||||||
纸窄了(原 640)版心只剩 446px,用药信息表会被挤坏 -->
|
纸窄了(原 640)版心只剩 446px,用药信息表会被挤坏 -->
|
||||||
<DocumentPreviewDialog
|
<DocumentPreviewDialog
|
||||||
|
:document-font="task?.documentFont ?? null"
|
||||||
:visible="visible && Boolean(task)"
|
:visible="visible && Boolean(task)"
|
||||||
title="在线预览(PDF)"
|
title="在线预览(PDF)"
|
||||||
:subtitle="subtitle"
|
: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> {
|
export interface ApiResponse<T> {
|
||||||
code: number | string
|
code: number | string
|
||||||
message: string
|
message: string
|
||||||
@@ -92,6 +93,7 @@ export interface SigningContextTemplate {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface SigningContextDocument {
|
export interface SigningContextDocument {
|
||||||
|
font?: DocumentFont | null
|
||||||
contentHtml?: string | null
|
contentHtml?: string | null
|
||||||
contentSha256?: string | null
|
contentSha256?: string | null
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ const props = withDefaults(
|
|||||||
defineProps<{
|
defineProps<{
|
||||||
/** 文书正文 HTML(含占位符),由 context 接口返回 */
|
/** 文书正文 HTML(含占位符),由 context 接口返回 */
|
||||||
contentHtml: string
|
contentHtml: string
|
||||||
|
fontFamily?: string
|
||||||
fields?: SigningFormField[]
|
fields?: SigningFormField[]
|
||||||
patientAnswers?: Record<string, PatientAnswer>
|
patientAnswers?: Record<string, PatientAnswer>
|
||||||
interactive?: boolean
|
interactive?: boolean
|
||||||
@@ -22,6 +23,7 @@ const props = withDefaults(
|
|||||||
signedAt?: string | null
|
signedAt?: string | null
|
||||||
}>(),
|
}>(),
|
||||||
{
|
{
|
||||||
|
fontFamily: '',
|
||||||
answers: () => ({}),
|
answers: () => ({}),
|
||||||
fields: () => [],
|
fields: () => [],
|
||||||
patientAnswers: () => ({}),
|
patientAnswers: () => ({}),
|
||||||
@@ -232,10 +234,22 @@ watch(
|
|||||||
正文来自医院自己的文书模板(经后端存储下发),不是用户输入,因此这里用 v-html 渲染。
|
正文来自医院自己的文书模板(经后端存储下发),不是用户输入,因此这里用 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>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
.managed-font,
|
||||||
|
.managed-font :deep(*) {
|
||||||
|
font-family: var(--document-font) !important;
|
||||||
|
font-synthesis: none;
|
||||||
|
}
|
||||||
|
|
||||||
.patient-paper {
|
.patient-paper {
|
||||||
padding: 18px 16px 22px;
|
padding: 18px 16px 22px;
|
||||||
color: #173b56;
|
color: #173b56;
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { loadDocumentFont } from '../../../packages/document-fonts'
|
||||||
import { reactive } from 'vue'
|
import { reactive } from 'vue'
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -59,6 +60,8 @@ const initialState: SignFlowState = {
|
|||||||
age: '',
|
age: '',
|
||||||
visitNo: 'MZ20260827018',
|
visitNo: 'MZ20260827018',
|
||||||
department: '',
|
department: '',
|
||||||
|
documentFontFamily: '',
|
||||||
|
documentFontReady: false,
|
||||||
documentTitle: '儿科急诊特殊检查知情同意书',
|
documentTitle: '儿科急诊特殊检查知情同意书',
|
||||||
templateVersion: 'v1.0.0',
|
templateVersion: 'v1.0.0',
|
||||||
documentContentHtml: '',
|
documentContentHtml: '',
|
||||||
@@ -447,6 +450,12 @@ async function loadSigningContext(): Promise<SigningContextResponse | null> {
|
|||||||
try {
|
try {
|
||||||
const context = await fetchSigningContext(proof)
|
const context = await fetchSigningContext(proof)
|
||||||
applyContext(context)
|
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()
|
persistSession()
|
||||||
return context
|
return context
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -488,6 +497,7 @@ function applyDemoContext() {
|
|||||||
signingState.fields = [
|
signingState.fields = [
|
||||||
{ id: DEMO_PATIENT_FIELD_ID, label: '患者签名', kind: 'signature', required: true },
|
{ id: DEMO_PATIENT_FIELD_ID, label: '患者签名', kind: 'signature', required: true },
|
||||||
]
|
]
|
||||||
|
signingState.documentFontReady = true
|
||||||
signingState.documentLoaded = true
|
signingState.documentLoaded = true
|
||||||
signingState.documentError = ''
|
signingState.documentError = ''
|
||||||
signingState.taskStatus = 'WAITING_SIGN'
|
signingState.taskStatus = 'WAITING_SIGN'
|
||||||
@@ -583,7 +593,9 @@ export async function submitRealSignature(input: {
|
|||||||
!signingState.taskId ||
|
!signingState.taskId ||
|
||||||
!signingState.deliveryId ||
|
!signingState.deliveryId ||
|
||||||
!signingState.uploadToken ||
|
!signingState.uploadToken ||
|
||||||
!input.consentAccepted
|
!input.consentAccepted ||
|
||||||
|
!signingState.documentLoaded ||
|
||||||
|
!signingState.documentFontReady
|
||||||
) {
|
) {
|
||||||
throw new Error('签署凭证或知情确认状态无效,请重新打开签署链接。')
|
throw new Error('签署凭证或知情确认状态无效,请重新打开签署链接。')
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,6 +26,8 @@ export interface SignFlowState {
|
|||||||
visitNo: string
|
visitNo: string
|
||||||
department: string
|
department: string
|
||||||
|
|
||||||
|
documentFontFamily: string
|
||||||
|
documentFontReady: boolean
|
||||||
documentTitle: string
|
documentTitle: string
|
||||||
templateVersion: string
|
templateVersion: string
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -56,6 +56,7 @@ async function continueToSigner() {
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<DocumentPaper
|
<DocumentPaper
|
||||||
|
:font-family="signingState.documentFontFamily"
|
||||||
:content-html="signingState.documentContentHtml"
|
:content-html="signingState.documentContentHtml"
|
||||||
:answers="signingState.staffAnswers"
|
:answers="signingState.staffAnswers"
|
||||||
:fields="signingState.form?.fields"
|
:fields="signingState.form?.fields"
|
||||||
|
|||||||
@@ -117,6 +117,7 @@ async function submitSigning() {
|
|||||||
|
|
||||||
<!-- 签完的字会实时回填到文书里的签名位,患者能立刻看到自己签在哪一格 -->
|
<!-- 签完的字会实时回填到文书里的签名位,患者能立刻看到自己签在哪一格 -->
|
||||||
<DocumentPaper
|
<DocumentPaper
|
||||||
|
:font-family="signingState.documentFontFamily"
|
||||||
:content-html="signingState.documentContentHtml"
|
:content-html="signingState.documentContentHtml"
|
||||||
:answers="signingState.staffAnswers"
|
:answers="signingState.staffAnswers"
|
||||||
:fields="signingState.form?.fields"
|
:fields="signingState.form?.fields"
|
||||||
|
|||||||
Reference in New Issue
Block a user