refactor(文书预览): 抽出统一的文书纸与预览弹窗,消除五处重复渲染
预览原先在 5 个地方各写一遍(文书库预览、编辑器签署预览、发起签署弹窗预览、 工作台在线预览、工作台文书区),基础排版已经分叉:表格边框 #52646e 与 #c8d7dd 两套色、单元格内边距 5px 7px 与 5px 9px 两个值、只有两处带宋体。 原型本身是「一处渲染(.paper + fillDocHtml)、两种外框」,这次按那个形状收口。 新增 - utils/document-paper.ts:基础排版的**唯一一份** CSS(p / h1-h4 / 表格 / 宋体)。 运行期幂等注入 head —— 因为同一份纸有两条渲染通道:v-html(吃全局样式)与 iframe srcdoc(隔离模板自带 style,只能吃字符串),各写一份必然漂移。 - components/document/DocumentPaper.vue:唯一的纸渲染器。 signing / raw 两模式、interactive、取值还原、☑☐ 就地切换、签名位请求采集、 填写项与勾选上报、getDocumentElement()(供导出 PDF)。 - components/document/DocumentPreviewDialog.vue:唯一的预览外框。 标题/副标题/徽标、可选页签、加载与错误态、纸舞台(浅色卡片 / 深灰 A4)、 #footer 与 #empty 插槽。 - styles/document-preview.css:页脚按钮等**插槽内容**要用的类。 插槽节点带的是父组件 scope id,弹窗的 scoped 样式命中不了 —— 与 signing-document.css 同一类问题,同一套解法。 迁移 - 文书库预览、编辑器签署预览、发起签署弹窗预览、工作台在线预览全部改走统一组件; SigningDocumentInteractive 的纸换成 DocumentPaper,只留任务外壳 (横幅 / 作废章 / 填写项进度)。 - 删除四处基础排版副本;SimSun 与 :deep(table) 现在各只剩一处。 行为保持 - 文书库预览仍是两个页签(文书内容走 iframe 隔离、签署预览走 v-html), 没有正文时仍给占位版式(占位符写成 {患者姓名} 原始形态,不预填示例值)。 - 工作台预览的深灰 A4 舞台、页脚说明与两个出口按钮不变。 - 作废章原先是纸的兄弟节点、DOM 顺序在前,纸改成自带白底后会被盖住 —— 给章补 z-index,并在探针里加命中测试锁死。 验证 - 新增 probe-doc-paper.cjs:31/31 PASS。核心是**计算样式**断言 (边框色 / 内边距 / 宋体 / border-collapse),并断言文书库预览与编辑器预览 两份纸的样式**逐项一致** —— 只看 DOM 存在证明不了"只有一份排版"。 - 回归:probe-new-task 104/104、probe-workbench-lib 46/46(新增作废章命中测试)、 probe-dept-confirm 36/36、probe-method-domain 14/14、probe-report-analytics 92/92。 - vue-tsc 与 eslint 0 错。 净减 888 行(309 增 / 1197 删)。
This commit is contained in:
@@ -0,0 +1,343 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
/**
|
||||||
|
* 文书纸:把一份文书正文按「签署态」渲染出来 —— **全项目唯一一处**。
|
||||||
|
*
|
||||||
|
* 为什么必须只有一个:同一份纸会出现在签署工作台、文书库预览、编辑器签署预览、
|
||||||
|
* 发起签署弹窗的文档预览、记录详情、以及导出 PDF 的截图源里。改版前每处各写一遍
|
||||||
|
* 基础排版,实测已经分叉(表格边框两套色、单元格内边距两套值、宋体只有两处有)。
|
||||||
|
* 版式一旦不一致,"预览看到的"和"签署时看到的"就不再是同一份东西,
|
||||||
|
* 而这类偏差不会报错,只会让人对不上。
|
||||||
|
*
|
||||||
|
* 组件只负责「纸」本身(排版 + 取值 + 交互 + 上报),不负责外框:
|
||||||
|
* 弹窗壳见 components/document/DocumentPreviewDialog.vue,
|
||||||
|
* 任务态外壳(横幅 / 页脚进度 / 作废章)见 components/signing/SigningDocumentInteractive.vue。
|
||||||
|
*/
|
||||||
|
import { computed, onMounted, ref, watch } from 'vue'
|
||||||
|
|
||||||
|
import type { SigningAnswers } from '@/api/workbench/types'
|
||||||
|
import {
|
||||||
|
applyAnswerToFieldHost,
|
||||||
|
applySelectedItemsToRoot,
|
||||||
|
applySignatureToRoot,
|
||||||
|
buildSigningDocumentHtml,
|
||||||
|
collectSelectedItemsFromRoot,
|
||||||
|
describeFieldProgress,
|
||||||
|
OPTION_TICK_SELECTOR,
|
||||||
|
readAnswersFromRoot,
|
||||||
|
SIGNATURE_ACTION_SELECTOR,
|
||||||
|
toggleOptionAt,
|
||||||
|
type SigningDocumentData,
|
||||||
|
type SigningFieldProgress,
|
||||||
|
type SigningSignatureRequest,
|
||||||
|
} from '@/utils/signing-document'
|
||||||
|
import { buildPaperSrcDoc, ensureDocumentPaperStyle } from '@/utils/document-paper'
|
||||||
|
|
||||||
|
const props = withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
/** 文书正文(模板定稿 / 任务固化快照) */
|
||||||
|
contentHtml: string
|
||||||
|
/**
|
||||||
|
* 渲染模式。
|
||||||
|
* - `signing`(默认):按签署态渲染(占位符回填、勾选项交互化、签名域)
|
||||||
|
* - `raw`:原样展示定稿(iframe 隔离模板自带的 `<style>`),仅文书库的「文书内容」页签用
|
||||||
|
*/
|
||||||
|
mode?: 'signing' | 'raw'
|
||||||
|
/** 回填上下文:患者信息 / 签名 / 勾选 / 已填取值 */
|
||||||
|
data?: SigningDocumentData
|
||||||
|
/** 填写项可否改动、签名位可否点击采集 */
|
||||||
|
interactive?: boolean
|
||||||
|
/**
|
||||||
|
* 签名位是否渲染成可点入口。
|
||||||
|
* 默认跟随 `interactive`;单独一个开关是因为「静态预览」也是 interactive 的
|
||||||
|
* (科室能试勾选),但那里没有事件宿主,渲染成可点按钮就是骗人。
|
||||||
|
*/
|
||||||
|
signatureActionable?: boolean
|
||||||
|
/** 已固化的勾选结果(存量勾选表) */
|
||||||
|
selectedItems?: string[]
|
||||||
|
/** 已固化的填写项取值 */
|
||||||
|
fieldAnswers?: SigningAnswers
|
||||||
|
/** 已固化的主签名图 */
|
||||||
|
signatureDataUrl?: string
|
||||||
|
/** 本次会话刚采到、还没提交的签名图(key 是字段键) */
|
||||||
|
signatureInjections?: SigningAnswers
|
||||||
|
/**
|
||||||
|
* 纸的盒子形态:
|
||||||
|
* - `plain`(默认):只有排版与白底,内边距/圆角/阴影由调用方给(工作台的外壳自己带)
|
||||||
|
* - `card`:白纸 + 内边距 + 圆角 + 阴影,预览弹窗用
|
||||||
|
*/
|
||||||
|
variant?: 'plain' | 'card'
|
||||||
|
}>(),
|
||||||
|
{
|
||||||
|
mode: 'signing',
|
||||||
|
data: () => ({}),
|
||||||
|
interactive: false,
|
||||||
|
signatureActionable: undefined,
|
||||||
|
selectedItems: () => [],
|
||||||
|
fieldAnswers: () => ({}),
|
||||||
|
signatureDataUrl: undefined,
|
||||||
|
signatureInjections: () => ({}),
|
||||||
|
variant: 'plain',
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
selectionChange: [items: string[]]
|
||||||
|
fieldChange: [answers: SigningAnswers, progress: SigningFieldProgress]
|
||||||
|
/** 请求采集某个签名位,由调用方打开签字板并把结果回灌到 signatureInjections */
|
||||||
|
signatureRequest: [request: SigningSignatureRequest]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const containerRef = ref<HTMLElement | null>(null)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 基础排版注入放在 setup 期而不是 onMounted:
|
||||||
|
* onMounted 时首次 DOM 已经 patch 完,会有一帧"纸没有排版"的闪烁。
|
||||||
|
* 注入是幂等的,早调无害。
|
||||||
|
*/
|
||||||
|
ensureDocumentPaperStyle()
|
||||||
|
|
||||||
|
/** 默认跟随 interactive:单独传才算覆盖 */
|
||||||
|
const actionable = computed(() => props.signatureActionable ?? props.interactive)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 签署态 HTML。
|
||||||
|
* 只挑渲染真正需要的字段显式映射,不直接透传整个对象:
|
||||||
|
* 渲染数据契约与业务对象是两回事,哪天字段改名也不会静默丢取值。
|
||||||
|
*/
|
||||||
|
const processedHtml = computed(() =>
|
||||||
|
buildSigningDocumentHtml(props.contentHtml, props.data, props.interactive, {
|
||||||
|
signatureActionable: actionable.value,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
/** raw 模式的 iframe 文档(自带完整版式的正文原样返回) */
|
||||||
|
const rawSrcDoc = computed(() => buildPaperSrcDoc(props.contentHtml))
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从渲染后的 DOM 读回填写项取值与勾选清单并上报。
|
||||||
|
* 生成 HTML 时已把取值写成属性/文本,这里再按取值同步一次 DOM 状态,
|
||||||
|
* 保证「签完再打开时填写项/勾选还在」这件事不依赖序列化细节。
|
||||||
|
*/
|
||||||
|
function syncFromDom() {
|
||||||
|
const container = containerRef.value
|
||||||
|
|
||||||
|
if (!container) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const answers = readAnswersFromRoot(container)
|
||||||
|
const progress = describeFieldProgress(container, answers)
|
||||||
|
const selected = collectSelectedItemsFromRoot(container)
|
||||||
|
|
||||||
|
emit('fieldChange', { ...answers }, { ...progress })
|
||||||
|
emit('selectionChange', [...selected])
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 按已固化的取值还原 DOM 状态(存量勾选表 + 填写项都覆盖) */
|
||||||
|
function applyStoredValues() {
|
||||||
|
const container = containerRef.value
|
||||||
|
|
||||||
|
if (!container) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
applySelectedItemsToRoot(container, props.selectedItems ?? [])
|
||||||
|
|
||||||
|
const answers: SigningAnswers = { ...(props.fieldAnswers ?? {}) }
|
||||||
|
|
||||||
|
if (props.signatureDataUrl && !answers.signature) {
|
||||||
|
answers.signature = props.signatureDataUrl
|
||||||
|
}
|
||||||
|
|
||||||
|
container.querySelectorAll<HTMLElement>('[data-sign-field]').forEach((host) => {
|
||||||
|
const id = host.getAttribute('data-sign-field')
|
||||||
|
const value = id ? answers[id] : undefined
|
||||||
|
|
||||||
|
if (id === undefined || value === undefined) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 读写的判据都在 signing-document 里(readAnswersFromRoot 的镜像),
|
||||||
|
// 这里不自己写 data-sign-* 选择器,否则选项组这类「同 DOM 不同语义」的控件会两处漂移
|
||||||
|
applyAnswerToFieldHost(host, value)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从事件目标往上找「可点击采集的签名位」。
|
||||||
|
* 用 closest 而不是直接比对 target:签名位胶囊里还有类型标签、虚线框等子节点,
|
||||||
|
* 点在这些子节点上时 target 并不是宿主节点本身。
|
||||||
|
*/
|
||||||
|
function resolveSignatureHost(target: EventTarget | null): HTMLElement | null {
|
||||||
|
if (!(target instanceof Element)) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const host = target.closest<HTMLElement>(SIGNATURE_ACTION_SELECTOR)
|
||||||
|
|
||||||
|
// closest 会一路向上穿过容器,事件也可能来自容器外,必须确认落在本文书里
|
||||||
|
return host && containerRef.value?.contains(host) ? host : null
|
||||||
|
}
|
||||||
|
|
||||||
|
function requestSignature(host: HTMLElement) {
|
||||||
|
const fieldId = host.getAttribute('data-sign-field') ?? ''
|
||||||
|
|
||||||
|
if (!fieldId) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
emit('signatureRequest', {
|
||||||
|
fieldId,
|
||||||
|
label: host.getAttribute('data-sign-label') ?? fieldId,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 点击文书里的 ☑ / ☐ 选项。
|
||||||
|
*
|
||||||
|
* 与签名位分开处理:签名位是「请求采集」(要弹签字板),选项是就地切换。
|
||||||
|
* 切换逻辑本身在 utils/signing-document 的 toggleOptionAt 里,
|
||||||
|
* 组件只负责把点击翻译成一次调用 —— 单选互斥的判据不该在组件里再推一遍。
|
||||||
|
*/
|
||||||
|
function handleDocumentClick(event: MouseEvent) {
|
||||||
|
if (!props.interactive) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const target = event.target
|
||||||
|
|
||||||
|
if (target instanceof Element) {
|
||||||
|
const option = target.closest<HTMLElement>(OPTION_TICK_SELECTOR)
|
||||||
|
const container = containerRef.value
|
||||||
|
|
||||||
|
if (option && container && toggleOptionAt(container, option)) {
|
||||||
|
syncFromDom()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const host = resolveSignatureHost(event.target)
|
||||||
|
|
||||||
|
if (host) {
|
||||||
|
requestSignature(host)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 签名位挂了 role=button 与 tabindex,键盘必须同样可用,否则只有鼠标点得动 */
|
||||||
|
function handleDocumentKeydown(event: KeyboardEvent) {
|
||||||
|
if (!props.interactive || (event.key !== 'Enter' && event.key !== ' ')) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const host = resolveSignatureHost(event.target)
|
||||||
|
|
||||||
|
if (host) {
|
||||||
|
// 空格默认会滚动容器,必须挡掉
|
||||||
|
event.preventDefault()
|
||||||
|
requestSignature(host)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 把外部采集到的签名图就地写进签名位,写完立刻回读 DOM 上报取值。
|
||||||
|
*
|
||||||
|
* 不重算 processedHtml:整篇重建会换掉 v-html 的 DOM,
|
||||||
|
* 操作人员正在输入的填写项会连同光标一起丢。
|
||||||
|
*/
|
||||||
|
function patchSignatures() {
|
||||||
|
const container = containerRef.value
|
||||||
|
|
||||||
|
if (!container) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const patched = Object.entries(props.signatureInjections ?? {}).some(
|
||||||
|
([fieldId, value]) =>
|
||||||
|
typeof value === 'string' && applySignatureToRoot(container, fieldId, value),
|
||||||
|
)
|
||||||
|
|
||||||
|
if (patched) {
|
||||||
|
syncFromDom()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
processedHtml,
|
||||||
|
() => {
|
||||||
|
applyStoredValues()
|
||||||
|
// 重渲染会丢掉就地写入的签名图,得重新贴一遍
|
||||||
|
patchSignatures()
|
||||||
|
},
|
||||||
|
{ flush: 'post' },
|
||||||
|
)
|
||||||
|
|
||||||
|
watch(() => props.signatureInjections, patchSignatures, { deep: true, flush: 'post' })
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
applyStoredValues()
|
||||||
|
patchSignatures()
|
||||||
|
syncFromDom()
|
||||||
|
})
|
||||||
|
|
||||||
|
/** 供「导出 PDF」使用:拿到已渲染的文书容器(含固定页眉与签署态取值) */
|
||||||
|
function getDocumentElement() {
|
||||||
|
return containerRef.value
|
||||||
|
}
|
||||||
|
|
||||||
|
defineExpose({ getDocumentElement })
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<!-- raw 模式:模板定稿可能自带 <style>,必须隔离,否则会污染整个应用 -->
|
||||||
|
<iframe
|
||||||
|
v-if="mode === 'raw'"
|
||||||
|
class="doc-paper-frame"
|
||||||
|
:srcdoc="rawSrcDoc"
|
||||||
|
sandbox=""
|
||||||
|
title="文书原始版式预览"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- eslint-disable vue/no-v-html -- 内容为本系统定稿的文书 HTML(编辑器产出、仅医务人员可改),非任意用户输入 -->
|
||||||
|
<article v-else class="doc-paper" :class="{ 'doc-paper--card': variant === 'card' }">
|
||||||
|
<div
|
||||||
|
ref="containerRef"
|
||||||
|
class="doc-paper__body"
|
||||||
|
@change="syncFromDom"
|
||||||
|
@input="syncFromDom"
|
||||||
|
@click="handleDocumentClick"
|
||||||
|
@keydown="handleDocumentKeydown"
|
||||||
|
v-html="processedHtml"
|
||||||
|
/>
|
||||||
|
</article>
|
||||||
|
<!-- eslint-enable vue/no-v-html -->
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
/* 基础排版(p / h2 / h3 / table / th / td / img)在 utils/document-paper.ts 的
|
||||||
|
DOCUMENT_PAPER_CSS 里,运行期注入 head —— 那里是唯一一份,v-html 与 iframe 共用。
|
||||||
|
这里只保留容器自己的盒子。 */
|
||||||
|
|
||||||
|
.doc-paper {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* card:预览弹窗里那张白纸 */
|
||||||
|
.doc-paper--card {
|
||||||
|
padding: 26px 32px 30px;
|
||||||
|
border-radius: var(--r);
|
||||||
|
box-shadow: var(--sh);
|
||||||
|
}
|
||||||
|
|
||||||
|
.doc-paper__body {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.doc-paper-frame {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
min-height: 560px;
|
||||||
|
background: #fff;
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,440 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
/**
|
||||||
|
* 文书预览弹窗:全项目**唯一**的预览外框。
|
||||||
|
*
|
||||||
|
* 原型里预览也是「一处渲染、两种外框」——`#mdTpl`(720px,类 Word 版式,页脚「用此模板发起签署」)
|
||||||
|
* 与 `#mdPdfView`(820px,深灰 A4 舞台,页眉「下载 PDF」)。两者共用同一份 `.paper`
|
||||||
|
* 与 `fillDocHtml`,只有外壳不同。本组件把这两种外壳收成一个:
|
||||||
|
* 标题栏 / 可选页签 / 可选提示 / 加载与错误态 / 纸舞台(浅色卡片或深灰 A4)/ 页脚插槽。
|
||||||
|
*
|
||||||
|
* 纸本身(排版、占位符回填、勾选交互)在 components/document/DocumentPaper.vue,
|
||||||
|
* 本组件不重复实现任何渲染逻辑。
|
||||||
|
*/
|
||||||
|
import { computed, ref } from 'vue'
|
||||||
|
|
||||||
|
import type { SigningAnswers } from '@/api/workbench/types'
|
||||||
|
import type {
|
||||||
|
SigningDocumentData,
|
||||||
|
SigningFieldProgress,
|
||||||
|
SigningSignatureRequest,
|
||||||
|
} from '@/utils/signing-document'
|
||||||
|
|
||||||
|
import DocumentPaper from './DocumentPaper.vue'
|
||||||
|
|
||||||
|
const props = withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
visible: boolean
|
||||||
|
/** 标题栏主标题(模板名 / 「在线预览(PDF)」这类固定文案) */
|
||||||
|
title: string
|
||||||
|
/** 标题右侧的次要信息(编号 · 患者等) */
|
||||||
|
subtitle?: string
|
||||||
|
/** 标题行的徽标(版式标记、文书状态等) */
|
||||||
|
badges?: { text: string; tone?: 'brand' | 'ok' | 'warn' | 'mut' }[]
|
||||||
|
/** 可选页签;不传即单页 */
|
||||||
|
tabs?: { key: string; label: string; disabled?: boolean; hint?: string }[]
|
||||||
|
activeTab?: string
|
||||||
|
/** 正文(模板定稿 / 任务固化快照) */
|
||||||
|
contentHtml: string
|
||||||
|
/** 渲染模式,透传给 DocumentPaper */
|
||||||
|
mode?: 'signing' | 'raw'
|
||||||
|
data?: SigningDocumentData
|
||||||
|
interactive?: boolean
|
||||||
|
signatureActionable?: boolean
|
||||||
|
selectedItems?: string[]
|
||||||
|
fieldAnswers?: SigningAnswers
|
||||||
|
signatureDataUrl?: string
|
||||||
|
signatureInjections?: SigningAnswers
|
||||||
|
/** 正文取回中 */
|
||||||
|
loading?: boolean
|
||||||
|
/** 正文取回失败的提示;给了就整块显示错误态 */
|
||||||
|
error?: string
|
||||||
|
/** 纸上方的一条提示(例如「已用示例患者信息回填」) */
|
||||||
|
note?: string
|
||||||
|
/** 正文为空时的提示 */
|
||||||
|
emptyText?: string
|
||||||
|
/**
|
||||||
|
* 纸的舞台:
|
||||||
|
* - `plain`(默认):浅色底、白纸带内边距,用于看版式
|
||||||
|
* - `stage`:深灰底、居中 A4,用于「在线预览(PDF)」(原型 #mdPdfView)
|
||||||
|
*/
|
||||||
|
stage?: 'plain' | 'stage'
|
||||||
|
/** 弹窗宽度(px) */
|
||||||
|
width?: number
|
||||||
|
/**
|
||||||
|
* 是否叠在另一个弹窗之上(发起签署弹窗里的文档预览)。
|
||||||
|
* 叠层时遮罩加深一档,否则两层弹窗看着像同一层。
|
||||||
|
*/
|
||||||
|
stacked?: boolean
|
||||||
|
}>(),
|
||||||
|
{
|
||||||
|
subtitle: '',
|
||||||
|
badges: () => [],
|
||||||
|
tabs: undefined,
|
||||||
|
activeTab: '',
|
||||||
|
mode: 'signing',
|
||||||
|
data: () => ({}),
|
||||||
|
interactive: false,
|
||||||
|
signatureActionable: undefined,
|
||||||
|
selectedItems: () => [],
|
||||||
|
fieldAnswers: () => ({}),
|
||||||
|
signatureDataUrl: undefined,
|
||||||
|
signatureInjections: () => ({}),
|
||||||
|
loading: false,
|
||||||
|
error: '',
|
||||||
|
note: '',
|
||||||
|
emptyText: '该文书暂无正文内容。',
|
||||||
|
stage: 'plain',
|
||||||
|
width: 720,
|
||||||
|
stacked: false,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
close: []
|
||||||
|
'update:activeTab': [key: string]
|
||||||
|
selectionChange: [items: string[]]
|
||||||
|
fieldChange: [answers: SigningAnswers, progress: SigningFieldProgress]
|
||||||
|
signatureRequest: [request: SigningSignatureRequest]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const paperRef = ref<InstanceType<typeof DocumentPaper> | null>(null)
|
||||||
|
|
||||||
|
/** 有正文、且没在加载 / 报错时才渲染纸 */
|
||||||
|
const hasPaper = computed(() => Boolean(props.contentHtml) && !props.loading && !props.error)
|
||||||
|
|
||||||
|
/** 供父级「导出 PDF / 生成只读副本」使用:拿到实际渲染文书的那一层 */
|
||||||
|
function getDocumentElement() {
|
||||||
|
return paperRef.value?.getDocumentElement() ?? null
|
||||||
|
}
|
||||||
|
|
||||||
|
defineExpose({ getDocumentElement })
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Teleport to="body">
|
||||||
|
<div
|
||||||
|
v-if="visible"
|
||||||
|
class="preview-mask"
|
||||||
|
:class="{ 'preview-mask--stacked': stacked }"
|
||||||
|
role="presentation"
|
||||||
|
@click.self="emit('close')"
|
||||||
|
>
|
||||||
|
<section
|
||||||
|
class="preview-card"
|
||||||
|
:style="{ width: `${width}px` }"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby="document-preview-title"
|
||||||
|
>
|
||||||
|
<header class="preview-header">
|
||||||
|
<strong id="document-preview-title" class="preview-title">{{ title }}</strong>
|
||||||
|
<span v-if="subtitle" class="preview-subtitle">{{ subtitle }}</span>
|
||||||
|
<span
|
||||||
|
v-for="badge in badges"
|
||||||
|
:key="badge.text"
|
||||||
|
class="preview-badge"
|
||||||
|
:class="`tone-${badge.tone ?? 'brand'}`"
|
||||||
|
>
|
||||||
|
{{ badge.text }}
|
||||||
|
</span>
|
||||||
|
<button type="button" class="preview-close" aria-label="关闭预览" @click="emit('close')">
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div v-if="tabs?.length" class="preview-tabs" role="tablist" aria-label="预览方式">
|
||||||
|
<button
|
||||||
|
v-for="tab in tabs"
|
||||||
|
:key="tab.key"
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
class="preview-tab"
|
||||||
|
:class="{ 'is-active': tab.key === activeTab }"
|
||||||
|
:aria-selected="tab.key === activeTab"
|
||||||
|
:disabled="tab.disabled"
|
||||||
|
:title="tab.hint"
|
||||||
|
@click="emit('update:activeTab', tab.key)"
|
||||||
|
>
|
||||||
|
{{ tab.label }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="preview-body" :class="{ 'preview-body--stage': stage === 'stage' }">
|
||||||
|
<p v-if="note && !loading && !error" class="preview-note">{{ note }}</p>
|
||||||
|
<div v-if="loading" class="preview-state">正在加载文书内容…</div>
|
||||||
|
<div v-else-if="error" class="preview-state preview-state--error">{{ error }}</div>
|
||||||
|
<slot v-else>
|
||||||
|
<DocumentPaper
|
||||||
|
v-if="hasPaper"
|
||||||
|
ref="paperRef"
|
||||||
|
:content-html="contentHtml"
|
||||||
|
:mode="mode"
|
||||||
|
:data="data"
|
||||||
|
:interactive="interactive"
|
||||||
|
:signature-actionable="signatureActionable"
|
||||||
|
:selected-items="selectedItems"
|
||||||
|
:field-answers="fieldAnswers"
|
||||||
|
:signature-data-url="signatureDataUrl"
|
||||||
|
:signature-injections="signatureInjections"
|
||||||
|
:variant="stage === 'stage' ? 'plain' : 'card'"
|
||||||
|
@selection-change="emit('selectionChange', $event)"
|
||||||
|
@field-change="(answers, progress) => emit('fieldChange', answers, progress)"
|
||||||
|
@signature-request="emit('signatureRequest', $event)"
|
||||||
|
/>
|
||||||
|
<!-- 正文取不到时的兜底版式由调用方给:工作台要用带任务信息的回退纸样,
|
||||||
|
而不是一句「暂无正文」—— 那会把「后端没给正文」和「这份文书本来就是空的」混为一谈 -->
|
||||||
|
<slot v-else name="empty">
|
||||||
|
<p class="preview-state preview-state--empty">{{ emptyText }}</p>
|
||||||
|
</slot>
|
||||||
|
</slot>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<footer class="preview-footer">
|
||||||
|
<slot name="footer">
|
||||||
|
<span class="preview-spacer" />
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="preview-button preview-button--ghost"
|
||||||
|
@click="emit('close')"
|
||||||
|
>
|
||||||
|
关闭
|
||||||
|
</button>
|
||||||
|
</slot>
|
||||||
|
</footer>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</Teleport>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.preview-mask {
|
||||||
|
position: fixed;
|
||||||
|
z-index: 20;
|
||||||
|
inset: 0;
|
||||||
|
display: flex;
|
||||||
|
padding: 16px;
|
||||||
|
overflow-y: auto;
|
||||||
|
background: rgb(9 40 52 / 45%);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 叠在另一个弹窗之上时遮罩再深一档,否则两层弹窗看着像同一层 */
|
||||||
|
.preview-mask--stacked {
|
||||||
|
z-index: 50;
|
||||||
|
background: rgb(9 40 52 / 55%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-card {
|
||||||
|
display: flex;
|
||||||
|
max-width: 96vw;
|
||||||
|
max-height: calc(100vh - 32px);
|
||||||
|
flex-direction: column;
|
||||||
|
margin: auto;
|
||||||
|
overflow: hidden;
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 14px;
|
||||||
|
box-shadow: var(--sh-modal);
|
||||||
|
animation: preview-pop 0.18s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-header {
|
||||||
|
display: flex;
|
||||||
|
flex-shrink: 0;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: baseline;
|
||||||
|
padding: 12px 18px;
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-title {
|
||||||
|
overflow: hidden;
|
||||||
|
color: var(--ink);
|
||||||
|
font-size: 15px;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-subtitle {
|
||||||
|
overflow: hidden;
|
||||||
|
color: var(--mut);
|
||||||
|
font-size: 12px;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-badge {
|
||||||
|
flex-shrink: 0;
|
||||||
|
padding: 3px 9px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
border-radius: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-badge.tone-brand {
|
||||||
|
color: var(--brand);
|
||||||
|
background: var(--brand-l);
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-badge.tone-ok {
|
||||||
|
color: var(--ok);
|
||||||
|
background: var(--ok-l);
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-badge.tone-warn {
|
||||||
|
color: var(--warn);
|
||||||
|
background: var(--warn-l);
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-badge.tone-mut {
|
||||||
|
color: #8a9aa3;
|
||||||
|
background: #eceef0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-close {
|
||||||
|
flex-shrink: 0;
|
||||||
|
padding: 0;
|
||||||
|
margin-left: auto;
|
||||||
|
color: var(--mut);
|
||||||
|
font-size: 20px;
|
||||||
|
line-height: 1;
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-close:hover {
|
||||||
|
color: var(--ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-tabs {
|
||||||
|
display: flex;
|
||||||
|
flex-shrink: 0;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 8px 18px;
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-tab {
|
||||||
|
padding: 5px 12px;
|
||||||
|
color: var(--mut);
|
||||||
|
font-size: 12.5px;
|
||||||
|
font-weight: 700;
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-tab:hover:not(:disabled) {
|
||||||
|
color: var(--brand);
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-tab.is-active {
|
||||||
|
color: var(--brand);
|
||||||
|
background: var(--brand-l);
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-tab:disabled {
|
||||||
|
color: #b6c3c9;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 唯一滚动区:纸在自己的框里滚,头尾常驻 */
|
||||||
|
.preview-body {
|
||||||
|
display: flex;
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 12px 18px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 深灰 A4 舞台(原型 #mdPdfView):纸居中、带阴影,像在看 PDF */
|
||||||
|
.preview-body--stage {
|
||||||
|
align-items: center;
|
||||||
|
padding: 24px;
|
||||||
|
background: #525659;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-body--stage :deep(.doc-paper) {
|
||||||
|
width: 640px;
|
||||||
|
max-width: 100%;
|
||||||
|
min-height: 905px;
|
||||||
|
flex: none;
|
||||||
|
padding: 34px 40px;
|
||||||
|
box-shadow: 0 6px 22px rgb(0 0 0 / 45%);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 深灰底上纸要自己撑满高度,否则短文书会缩成一条 */
|
||||||
|
.preview-body--stage :deep(.doc-paper-frame) {
|
||||||
|
width: 640px;
|
||||||
|
max-width: 100%;
|
||||||
|
height: 905px;
|
||||||
|
box-shadow: 0 6px 22px rgb(0 0 0 / 45%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-state {
|
||||||
|
display: grid;
|
||||||
|
min-height: 420px;
|
||||||
|
color: var(--mut);
|
||||||
|
font-size: 13px;
|
||||||
|
text-align: center;
|
||||||
|
place-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-state--error {
|
||||||
|
color: var(--err);
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-state--empty {
|
||||||
|
color: var(--mut);
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-note {
|
||||||
|
flex-shrink: 0;
|
||||||
|
padding: 8px 12px;
|
||||||
|
margin: 0;
|
||||||
|
color: var(--brand);
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.6;
|
||||||
|
background: var(--brand-l);
|
||||||
|
border-radius: 7px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-footer {
|
||||||
|
display: flex;
|
||||||
|
flex-shrink: 0;
|
||||||
|
gap: 10px;
|
||||||
|
align-items: center;
|
||||||
|
padding: 10px 18px;
|
||||||
|
color: var(--mut);
|
||||||
|
font-size: 12px;
|
||||||
|
background: #f8fbfc;
|
||||||
|
border-top: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-spacer {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 页脚按钮与说明(.preview-button / .preview-button--ghost / .preview-spacer /
|
||||||
|
.preview-note-footer)在 styles/document-preview.css(全局)。
|
||||||
|
为什么不做成 scoped:调用方通过 `#footer` 插槽塞进来的节点带的是**父组件**的
|
||||||
|
scope id,本组件的 scoped 选择器命中不了它们 —— 与 signing-document.css 同一类问题,
|
||||||
|
同一套解法。这样四处预览的按钮样式仍然只有一份定义。 */
|
||||||
|
|
||||||
|
@keyframes preview-pop {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: scale(0.96) translateY(8px);
|
||||||
|
}
|
||||||
|
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 680px) {
|
||||||
|
.preview-note-footer {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -4,9 +4,10 @@ import { ElMessage } from 'element-plus'
|
|||||||
|
|
||||||
import { isSigningMockEnabled } from '@/api/workbench/signing'
|
import { isSigningMockEnabled } from '@/api/workbench/signing'
|
||||||
import type { SigningTaskRecord, SigningTemplate } from '@/api/workbench/types'
|
import type { SigningTaskRecord, SigningTemplate } from '@/api/workbench/types'
|
||||||
|
import DocumentPreviewDialog from '@/components/document/DocumentPreviewDialog.vue'
|
||||||
import { useSigningTaskForm } from '@/composables/useSigningTaskForm'
|
import { useSigningTaskForm } from '@/composables/useSigningTaskForm'
|
||||||
import { useAppStore } from '@/stores/app'
|
import { useAppStore } from '@/stores/app'
|
||||||
import { buildSigningDocumentHtml, type SigningDocumentData } from '@/utils/signing-document'
|
import type { SigningDocumentData } from '@/utils/signing-document'
|
||||||
import { SIGNING_METHODS } from '@/utils/signing-method'
|
import { SIGNING_METHODS } from '@/utils/signing-method'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
@@ -107,12 +108,6 @@ const PREVIEW_SNAPSHOT: SigningDocumentData = {
|
|||||||
|
|
||||||
const previewTemplate = ref<SigningTemplate | null>(null)
|
const previewTemplate = ref<SigningTemplate | null>(null)
|
||||||
|
|
||||||
const previewHtml = computed(() => {
|
|
||||||
const contentHtml = previewTemplate.value?.contentHtml
|
|
||||||
|
|
||||||
return contentHtml ? buildSigningDocumentHtml(contentHtml, PREVIEW_SNAPSHOT, false) : ''
|
|
||||||
})
|
|
||||||
|
|
||||||
function openPreview(template: SigningTemplate) {
|
function openPreview(template: SigningTemplate) {
|
||||||
previewTemplate.value = template
|
previewTemplate.value = template
|
||||||
}
|
}
|
||||||
@@ -480,29 +475,17 @@ watch(
|
|||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 文档签署预览(原型 ntPreviewDoc → mdPdfView,只读) -->
|
<!-- 文档签署预览(原型 ntPreviewDoc → mdPdfView,只读):
|
||||||
<div v-if="previewTemplate" class="dialog-mask dialog-mask--stacked" @click.self="closePreview">
|
渲染与外壳都走统一的文书预览组件,别在这里再写一份纸样 -->
|
||||||
<section class="preview-card" role="dialog" aria-modal="true">
|
<DocumentPreviewDialog
|
||||||
<header class="dialog-header">
|
:visible="Boolean(previewTemplate)"
|
||||||
<strong>{{ previewTemplate.name }}.pdf · 签署预览</strong>
|
:title="`${previewTemplate?.name ?? ''}.pdf · 签署预览`"
|
||||||
<button type="button" class="dialog-close" aria-label="关闭预览" @click="closePreview">
|
:content-html="previewTemplate?.contentHtml ?? ''"
|
||||||
×
|
:data="PREVIEW_SNAPSHOT"
|
||||||
</button>
|
:stacked="true"
|
||||||
</header>
|
empty-text="该文档暂无结构化模板正文,发起签署后将按标准文书生成。"
|
||||||
<div class="preview-body">
|
@close="closePreview"
|
||||||
<!-- eslint-disable vue/no-v-html -- 内容为本系统定稿的文书 HTML(编辑器产出、仅医务人员可改),非任意用户输入 -->
|
/>
|
||||||
<div v-if="previewHtml" class="preview-paper" v-html="previewHtml" />
|
|
||||||
<!-- eslint-enable vue/no-v-html -->
|
|
||||||
<p v-else class="preview-empty">该文档暂无结构化模板正文,发起签署后将按标准文书生成。</p>
|
|
||||||
</div>
|
|
||||||
<footer class="dialog-footer">
|
|
||||||
<span class="footer-spacer" />
|
|
||||||
<button type="button" class="action-button action-button--ghost" @click="closePreview">
|
|
||||||
关闭
|
|
||||||
</button>
|
|
||||||
</footer>
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
</Teleport>
|
</Teleport>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -518,11 +501,6 @@ watch(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* 预览叠在发起弹窗之上,遮罩再深一档,避免两层弹窗看起来是同一层 */
|
/* 预览叠在发起弹窗之上,遮罩再深一档,避免两层弹窗看起来是同一层 */
|
||||||
.dialog-mask--stacked {
|
|
||||||
z-index: 60;
|
|
||||||
background: rgb(9 40 52 / 55%);
|
|
||||||
}
|
|
||||||
|
|
||||||
.dialog-card {
|
.dialog-card {
|
||||||
width: 680px;
|
width: 680px;
|
||||||
max-width: 96vw;
|
max-width: 96vw;
|
||||||
@@ -1298,92 +1276,8 @@ watch(
|
|||||||
border-radius: 5px;
|
border-radius: 5px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 预览弹窗 */
|
/* 预览弹窗的样式已全部收进 components/document/DocumentPreviewDialog.vue ——
|
||||||
|
纸的排版、外框、页脚按钮都在那里,这里不再保留任何预览样式。 */
|
||||||
.preview-card {
|
|
||||||
display: flex;
|
|
||||||
width: 900px;
|
|
||||||
max-width: 96vw;
|
|
||||||
max-height: calc(100vh - 32px);
|
|
||||||
flex-direction: column;
|
|
||||||
margin: auto;
|
|
||||||
overflow: hidden;
|
|
||||||
background: #fff;
|
|
||||||
border-radius: 14px;
|
|
||||||
box-shadow: var(--sh-modal);
|
|
||||||
animation: dialog-pop 0.18s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.preview-body {
|
|
||||||
min-height: 0;
|
|
||||||
padding: 16px 18px;
|
|
||||||
overflow-y: auto;
|
|
||||||
background: #f1f5f7;
|
|
||||||
}
|
|
||||||
|
|
||||||
.preview-paper {
|
|
||||||
padding: 26px 32px 30px;
|
|
||||||
background: #fff;
|
|
||||||
border-radius: var(--r);
|
|
||||||
box-shadow: var(--sh);
|
|
||||||
}
|
|
||||||
|
|
||||||
.preview-empty {
|
|
||||||
padding: 40px 16px;
|
|
||||||
margin: 0;
|
|
||||||
color: var(--mut);
|
|
||||||
font-size: 12.5px;
|
|
||||||
text-align: center;
|
|
||||||
background: #fff;
|
|
||||||
border-radius: var(--r);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 注入的文书 HTML 不携带 scoped 属性,统一用 :deep 覆盖基础文书样式 */
|
|
||||||
|
|
||||||
.preview-paper :deep(p) {
|
|
||||||
margin: 0 0 6px;
|
|
||||||
color: #1c2b33;
|
|
||||||
font-size: 13.5px;
|
|
||||||
line-height: 1.85;
|
|
||||||
}
|
|
||||||
|
|
||||||
.preview-paper :deep(h2) {
|
|
||||||
margin: 0 0 10px;
|
|
||||||
font-size: 18px;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.preview-paper :deep(h3) {
|
|
||||||
margin: 14px 0 6px;
|
|
||||||
font-size: 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.preview-paper :deep(table) {
|
|
||||||
width: 100%;
|
|
||||||
margin: 8px 0 12px;
|
|
||||||
border-collapse: collapse;
|
|
||||||
table-layout: auto;
|
|
||||||
font-size: 12.5px;
|
|
||||||
line-height: 1.6;
|
|
||||||
}
|
|
||||||
|
|
||||||
.preview-paper :deep(th),
|
|
||||||
.preview-paper :deep(td) {
|
|
||||||
padding: 5px 7px;
|
|
||||||
border: 1px solid #52646e;
|
|
||||||
vertical-align: middle;
|
|
||||||
overflow-wrap: anywhere;
|
|
||||||
word-break: break-word;
|
|
||||||
}
|
|
||||||
|
|
||||||
.preview-paper :deep(th) {
|
|
||||||
font-weight: 700;
|
|
||||||
background: #f2f6f8;
|
|
||||||
}
|
|
||||||
|
|
||||||
.preview-paper :deep(img) {
|
|
||||||
max-width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes dialog-pop {
|
@keyframes dialog-pop {
|
||||||
from {
|
from {
|
||||||
|
|||||||
@@ -1,26 +1,22 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted, ref, watch } from 'vue'
|
/**
|
||||||
|
* 签署工作台的任务态文书外壳。
|
||||||
|
*
|
||||||
|
* 它只负责**任务专属的那一圈**:顶部横幅(按哪个版本渲染 / 当前签署状态)、
|
||||||
|
* 作废章、底部填写项进度。纸本身(排版、占位符回填、勾选交互、签名位采集)
|
||||||
|
* 在 components/document/DocumentPaper.vue —— 那是全项目唯一一处,
|
||||||
|
* 工作台看到的版式必须与预览、与导出 PDF 完全一致。
|
||||||
|
*/
|
||||||
|
import { computed, ref } from 'vue'
|
||||||
|
|
||||||
import type { SigningAnswers, SigningTaskRecord } from '@/api/workbench/types'
|
import type { SigningAnswers, SigningTaskRecord } from '@/api/workbench/types'
|
||||||
import {
|
import DocumentPaper from '@/components/document/DocumentPaper.vue'
|
||||||
applyAnswerToFieldHost,
|
import type {
|
||||||
applySelectedItemsToRoot,
|
SigningDocumentData,
|
||||||
applySignatureToRoot,
|
SigningFieldProgress,
|
||||||
buildSigningDocumentHtml,
|
SigningSignatureRequest,
|
||||||
collectSelectedItemsFromRoot,
|
|
||||||
describeFieldProgress,
|
|
||||||
OPTION_TICK_SELECTOR,
|
|
||||||
readAnswersFromRoot,
|
|
||||||
SIGNATURE_ACTION_SELECTOR,
|
|
||||||
toggleOptionAt,
|
|
||||||
type SigningDocumentData,
|
|
||||||
type SigningFieldProgress,
|
|
||||||
type SigningSignatureRequest,
|
|
||||||
} from '@/utils/signing-document'
|
} from '@/utils/signing-document'
|
||||||
|
|
||||||
// 签署文书交互化渲染:填写项渲染(HIS 预填 + 现场确认)、签名回显、勾选收集见
|
|
||||||
// utils/signing-document 与 utils/signing-fields,与文书库的「签署预览」共用同一实现。
|
|
||||||
// 本组件只负责挂载 HTML、收集填写项取值并渲染签署态外壳。
|
|
||||||
const props = withDefaults(
|
const props = withDefaults(
|
||||||
defineProps<{
|
defineProps<{
|
||||||
task: SigningTaskRecord
|
task: SigningTaskRecord
|
||||||
@@ -28,7 +24,7 @@ const props = withDefaults(
|
|||||||
/**
|
/**
|
||||||
* 在文书里点击签名位采集到的签名图(key 是字段键,如 doctor)。
|
* 在文书里点击签名位采集到的签名图(key 是字段键,如 doctor)。
|
||||||
*
|
*
|
||||||
* 不走 task:task 是服务端快照,这里装的是"本次会话刚采到、还没提交"的取值。
|
* 不走 task:task 是服务端快照,这里装的是「本次会话刚采到、还没提交」的取值。
|
||||||
* 单独一份也让「整篇重渲染」不会把刚采的签名冲掉。
|
* 单独一份也让「整篇重渲染」不会把刚采的签名冲掉。
|
||||||
*/
|
*/
|
||||||
signatureInjections?: SigningAnswers
|
signatureInjections?: SigningAnswers
|
||||||
@@ -43,9 +39,8 @@ const emit = defineEmits<{
|
|||||||
'signature-request': [request: SigningSignatureRequest]
|
'signature-request': [request: SigningSignatureRequest]
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const containerRef = ref<HTMLElement | null>(null)
|
const paperRef = ref<InstanceType<typeof DocumentPaper> | null>(null)
|
||||||
const selectedItems = ref<string[]>([])
|
|
||||||
const fieldAnswers = ref<SigningAnswers>({})
|
|
||||||
const progress = ref<SigningFieldProgress>({
|
const progress = ref<SigningFieldProgress>({
|
||||||
total: 0,
|
total: 0,
|
||||||
filled: 0,
|
filled: 0,
|
||||||
@@ -54,8 +49,10 @@ const progress = ref<SigningFieldProgress>({
|
|||||||
pendingSignatures: [],
|
pendingSignatures: [],
|
||||||
})
|
})
|
||||||
|
|
||||||
// 只挑渲染真正需要的字段显式映射,不直接透传整个任务对象:
|
/**
|
||||||
// 渲染数据契约与任务记录是两回事,哪天字段改名也不会静默丢取值。
|
* 只挑渲染真正需要的字段显式映射,不直接透传整个任务对象:
|
||||||
|
* 渲染数据契约与任务记录是两回事,哪天字段改名也不会静默丢取值。
|
||||||
|
*/
|
||||||
const documentData = computed<SigningDocumentData>(() => ({
|
const documentData = computed<SigningDocumentData>(() => ({
|
||||||
patientName: props.task.patientName,
|
patientName: props.task.patientName,
|
||||||
sex: props.task.sex,
|
sex: props.task.sex,
|
||||||
@@ -68,21 +65,10 @@ const documentData = computed<SigningDocumentData>(() => ({
|
|||||||
fieldAnswers: props.task.fieldAnswers,
|
fieldAnswers: props.task.fieldAnswers,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
const processedHtml = computed(() =>
|
/** 文书里的填写项数量(含只读展示),用于提示「这份文书有几个填写项」 */
|
||||||
buildSigningDocumentHtml(
|
|
||||||
props.task.documentContentHtml ?? '',
|
|
||||||
documentData.value,
|
|
||||||
props.interactive,
|
|
||||||
// 本组件就是签名位的点击宿主(见 handleDocumentClick),
|
|
||||||
// 所以可采集与否只取决于文书当前是否可交互
|
|
||||||
{ signatureActionable: props.interactive },
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
/** 文书里的填写项数量(含只读展示),用于提示"这份文书有几个填写项" */
|
|
||||||
const fieldTotal = computed(() => progress.value.total)
|
const fieldTotal = computed(() => progress.value.total)
|
||||||
|
|
||||||
/** 签名/状态文案:作废要单独说清楚,不能被当成"待患者签署" */
|
/** 签名/状态文案:作废要单独说清楚,不能被当成「待患者签署」 */
|
||||||
const signatureStateLabel = computed(() => {
|
const signatureStateLabel = computed(() => {
|
||||||
if (props.task.status === 'void') {
|
if (props.task.status === 'void') {
|
||||||
return '已作废'
|
return '已作废'
|
||||||
@@ -92,189 +78,20 @@ const signatureStateLabel = computed(() => {
|
|||||||
return '已采集签名'
|
return '已采集签名'
|
||||||
}
|
}
|
||||||
|
|
||||||
return props.task.status === 'signed' ? '已签署' : '待患者签署'
|
return props.task.status === 'signed' || props.task.status === 'confirming'
|
||||||
|
? '已签署'
|
||||||
|
: '待患者签署'
|
||||||
})
|
})
|
||||||
|
|
||||||
/**
|
/** 纸把填写项与勾选结果上报上来,这里转给签署页,同时留一份给页脚用 */
|
||||||
* 从渲染后的 DOM 读回填写项取值与勾选清单。
|
function handleFieldChange(answers: SigningAnswers, next: SigningFieldProgress) {
|
||||||
* 生成 HTML 时已把取值写成属性/文本,这里再按取值同步一次 DOM 状态,
|
progress.value = next
|
||||||
* 保证「签完再打开时填写项/勾选还在」这件事不依赖序列化细节。
|
emit('field-change', answers, next)
|
||||||
*/
|
|
||||||
function syncFromDom() {
|
|
||||||
const container = containerRef.value
|
|
||||||
|
|
||||||
if (!container) {
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const answers = readAnswersFromRoot(container)
|
|
||||||
fieldAnswers.value = answers
|
|
||||||
progress.value = describeFieldProgress(container, answers)
|
|
||||||
selectedItems.value = collectSelectedItemsFromRoot(container)
|
|
||||||
|
|
||||||
emit('field-change', { ...answers }, { ...progress.value })
|
|
||||||
emit('selection-change', [...selectedItems.value])
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 按已固化的取值还原 DOM 状态(存量勾选表 + 新填写项都覆盖) */
|
|
||||||
function applyStoredValues() {
|
|
||||||
const container = containerRef.value
|
|
||||||
|
|
||||||
if (!container) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
applySelectedItemsToRoot(container, props.task.selectedItems ?? [])
|
|
||||||
|
|
||||||
const answers: SigningAnswers = { ...(props.task.fieldAnswers ?? {}) }
|
|
||||||
|
|
||||||
if (props.task.signatureDataUrl && !answers.signature) {
|
|
||||||
answers.signature = props.task.signatureDataUrl
|
|
||||||
}
|
|
||||||
|
|
||||||
container.querySelectorAll<HTMLElement>('[data-sign-field]').forEach((host) => {
|
|
||||||
const id = host.getAttribute('data-sign-field')
|
|
||||||
const value = id ? answers[id] : undefined
|
|
||||||
|
|
||||||
if (id === undefined || value === undefined) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// 读写的判据都在 signing-document 里(readAnswersFromRoot 的镜像),
|
|
||||||
// 这里不自己写 data-sign-* 选择器,否则选项组这类"同 DOM 不同语义"的控件会两处漂移
|
|
||||||
applyAnswerToFieldHost(host, value)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
watch(
|
|
||||||
() => props.task,
|
|
||||||
() => {
|
|
||||||
selectedItems.value = [...(props.task.selectedItems ?? [])]
|
|
||||||
fieldAnswers.value = { ...(props.task.fieldAnswers ?? {}) }
|
|
||||||
emit('selection-change', [...selectedItems.value])
|
|
||||||
},
|
|
||||||
{ immediate: true },
|
|
||||||
)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 从事件目标往上找「可点击采集的签名位」。
|
|
||||||
* 用 closest 而不是直接比对 target:签名位胶囊里还有类型标签、虚线框等子节点,
|
|
||||||
* 点在这些子节点上时 target 并不是宿主节点本身。
|
|
||||||
*/
|
|
||||||
function resolveSignatureHost(target: EventTarget | null): HTMLElement | null {
|
|
||||||
if (!(target instanceof Element)) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
const host = target.closest<HTMLElement>(SIGNATURE_ACTION_SELECTOR)
|
|
||||||
|
|
||||||
// closest 会一路向上穿过容器,事件也可能来自容器外,必须确认落在本文书里
|
|
||||||
return host && containerRef.value?.contains(host) ? host : null
|
|
||||||
}
|
|
||||||
|
|
||||||
function requestSignature(host: HTMLElement) {
|
|
||||||
const fieldId = host.getAttribute('data-sign-field') ?? ''
|
|
||||||
|
|
||||||
if (!fieldId) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
emit('signature-request', {
|
|
||||||
fieldId,
|
|
||||||
label: host.getAttribute('data-sign-label') ?? fieldId,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 点击文书里的 ☑ / ☐ 选项。
|
|
||||||
*
|
|
||||||
* 与签名位分开处理:签名位是"请求采集"(要弹签字板),选项是就地切换。
|
|
||||||
* 切换逻辑本身在 utils/signing-document 的 toggleOptionAt 里,
|
|
||||||
* 组件只负责把点击翻译成一次调用 —— 单选互斥的判据不该在组件里再推一遍。
|
|
||||||
*/
|
|
||||||
function handleDocumentClick(event: MouseEvent) {
|
|
||||||
if (!props.interactive) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const target = event.target
|
|
||||||
|
|
||||||
if (target instanceof Element) {
|
|
||||||
const option = target.closest<HTMLElement>(OPTION_TICK_SELECTOR)
|
|
||||||
const container = containerRef.value
|
|
||||||
|
|
||||||
if (option && container && toggleOptionAt(container, option)) {
|
|
||||||
syncFromDom()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const host = resolveSignatureHost(event.target)
|
|
||||||
|
|
||||||
if (host) {
|
|
||||||
requestSignature(host)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 签名位挂了 role=button 与 tabindex,键盘必须同样可用,否则只有鼠标点得动 */
|
|
||||||
function handleDocumentKeydown(event: KeyboardEvent) {
|
|
||||||
if (!props.interactive || (event.key !== 'Enter' && event.key !== ' ')) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const host = resolveSignatureHost(event.target)
|
|
||||||
|
|
||||||
if (host) {
|
|
||||||
// 空格默认会滚动容器,必须挡掉
|
|
||||||
event.preventDefault()
|
|
||||||
requestSignature(host)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 把外部采集到的签名图就地写进签名位,写完立刻回读 DOM 上报取值。
|
|
||||||
*
|
|
||||||
* 不重算 processedHtml:整篇重建会换掉 v-html 的 DOM,
|
|
||||||
* 操作人员正在输入的填写项会连同光标一起丢。
|
|
||||||
*/
|
|
||||||
function patchSignatures() {
|
|
||||||
const container = containerRef.value
|
|
||||||
|
|
||||||
if (!container) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const patched = Object.entries(props.signatureInjections).some(
|
|
||||||
([fieldId, value]) =>
|
|
||||||
typeof value === 'string' && applySignatureToRoot(container, fieldId, value),
|
|
||||||
)
|
|
||||||
|
|
||||||
if (patched) {
|
|
||||||
syncFromDom()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
watch(
|
|
||||||
processedHtml,
|
|
||||||
() => {
|
|
||||||
applyStoredValues()
|
|
||||||
// 重渲染会丢掉就地写入的签名图,得重新贴一遍
|
|
||||||
patchSignatures()
|
|
||||||
},
|
|
||||||
{ flush: 'post' },
|
|
||||||
)
|
|
||||||
|
|
||||||
watch(() => props.signatureInjections, patchSignatures, { deep: true, flush: 'post' })
|
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
applyStoredValues()
|
|
||||||
patchSignatures()
|
|
||||||
syncFromDom()
|
|
||||||
})
|
|
||||||
|
|
||||||
/** 供「导出 PDF」使用:拿到已渲染的文书容器(含固定页眉与签署态取值) */
|
/** 供「导出 PDF」使用:拿到已渲染的文书容器(含固定页眉与签署态取值) */
|
||||||
function getDocumentElement() {
|
function getDocumentElement() {
|
||||||
return containerRef.value
|
return paperRef.value?.getDocumentElement() ?? null
|
||||||
}
|
}
|
||||||
|
|
||||||
defineExpose({ getDocumentElement })
|
defineExpose({ getDocumentElement })
|
||||||
@@ -291,17 +108,20 @@ defineExpose({ getDocumentElement })
|
|||||||
|
|
||||||
<div v-if="task.status === 'void'" class="void-stamp">已 作 废</div>
|
<div v-if="task.status === 'void'" class="void-stamp">已 作 废</div>
|
||||||
|
|
||||||
<!-- eslint-disable vue/no-v-html -- 内容为本系统定稿的文书 HTML(编辑器产出、仅医务人员可改),非任意用户输入 -->
|
<DocumentPaper
|
||||||
<div
|
ref="paperRef"
|
||||||
ref="containerRef"
|
|
||||||
class="sign-doc-body"
|
class="sign-doc-body"
|
||||||
@change="syncFromDom"
|
:content-html="task.documentContentHtml ?? ''"
|
||||||
@input="syncFromDom"
|
:data="documentData"
|
||||||
@click="handleDocumentClick"
|
:interactive="interactive"
|
||||||
@keydown="handleDocumentKeydown"
|
:selected-items="task.selectedItems"
|
||||||
v-html="processedHtml"
|
:field-answers="task.fieldAnswers"
|
||||||
|
:signature-data-url="task.signatureDataUrl"
|
||||||
|
:signature-injections="signatureInjections"
|
||||||
|
@selection-change="emit('selection-change', $event)"
|
||||||
|
@field-change="handleFieldChange"
|
||||||
|
@signature-request="emit('signature-request', $event)"
|
||||||
/>
|
/>
|
||||||
<!-- eslint-enable vue/no-v-html -->
|
|
||||||
|
|
||||||
<footer v-if="interactive" class="sign-doc-footer">
|
<footer v-if="interactive" class="sign-doc-footer">
|
||||||
<span>
|
<span>
|
||||||
@@ -344,6 +164,7 @@ defineExpose({ getDocumentElement })
|
|||||||
/* 作废章压在文书上,位置与存量回退纸样(SigningDocumentPreview)保持一致 */
|
/* 作废章压在文书上,位置与存量回退纸样(SigningDocumentPreview)保持一致 */
|
||||||
.void-stamp {
|
.void-stamp {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
|
z-index: 2;
|
||||||
top: 52px;
|
top: 52px;
|
||||||
right: 32px;
|
right: 32px;
|
||||||
padding: 4px 12px;
|
padding: 4px 12px;
|
||||||
@@ -381,8 +202,11 @@ defineExpose({ getDocumentElement })
|
|||||||
background: #8a9aa3;
|
background: #8a9aa3;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 纸的盒子:DocumentPaper 只给排版与白底(variant=plain),
|
||||||
|
内边距与滚动由这里给。类名通过 fallthrough 落到纸的根元素上。 */
|
||||||
.sign-doc-body {
|
.sign-doc-body {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
padding: 26px 32px 30px;
|
padding: 26px 32px 30px;
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
}
|
}
|
||||||
@@ -392,8 +216,8 @@ defineExpose({ getDocumentElement })
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
padding: 10px 16px;
|
padding: 10px 16px;
|
||||||
font-size: 12px;
|
|
||||||
color: var(--mut);
|
color: var(--mut);
|
||||||
|
font-size: 12px;
|
||||||
background: #f8fbfc;
|
background: #f8fbfc;
|
||||||
border-top: 1px solid var(--line);
|
border-top: 1px solid var(--line);
|
||||||
}
|
}
|
||||||
@@ -418,43 +242,7 @@ defineExpose({ getDocumentElement })
|
|||||||
color: #a96900;
|
color: #a96900;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 注入的文书 HTML(v-html 内容不携带 scoped 属性,统一用 :deep 覆盖基础文书样式) */
|
/* 正文字号、表格边框、宋体等基础排版在 components/document/DocumentPaper.vue 的
|
||||||
.sign-doc-body :deep(p) {
|
DOCUMENT_PAPER_CSS 里(全项目唯一一份)。填写项胶囊、选项 ☑☐、系统页眉在
|
||||||
margin: 0 0 6px;
|
styles/signing-document.css。这里只保留"这份文书外壳"自己的东西。 */
|
||||||
font-size: 13.5px;
|
|
||||||
line-height: 1.85;
|
|
||||||
color: #1c2b33;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 文书表格按内容自适应列宽并允许换行,避免绝对列宽在窄容器里把整表裁掉 */
|
|
||||||
.sign-doc-body :deep(table) {
|
|
||||||
width: 100%;
|
|
||||||
margin: 8px 0 12px;
|
|
||||||
border-collapse: collapse;
|
|
||||||
table-layout: auto;
|
|
||||||
font-size: 12.5px;
|
|
||||||
line-height: 1.6;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sign-doc-body :deep(th),
|
|
||||||
.sign-doc-body :deep(td) {
|
|
||||||
padding: 5px 7px;
|
|
||||||
border: 1px solid #52646e;
|
|
||||||
vertical-align: middle;
|
|
||||||
overflow-wrap: anywhere;
|
|
||||||
word-break: break-word;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sign-doc-body :deep(th) {
|
|
||||||
font-weight: 700;
|
|
||||||
background: #f2f6f8;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sign-doc-body :deep(img) {
|
|
||||||
max-width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 填写项胶囊、选项 ☑ / ☐、系统页眉的样式在 styles/signing-document.css(全局):
|
|
||||||
它们同时出现在文书库预览、签署工作台与编辑器里,各写一份必然漂移。
|
|
||||||
这里只保留"这份文书外壳"自己的版式(正文字号、表格边框等)。 */
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
/**
|
||||||
|
* 文书预览弹窗的**公共类**(全局)。
|
||||||
|
*
|
||||||
|
* 为什么这几条必须放全局而不是 scoped:预览弹窗的页脚由调用方通过 `#footer` 插槽提供
|
||||||
|
* (文档库要「用此模板发起签署」、工作台要「只读副本 / 下载 PDF 原件」),
|
||||||
|
* 而插槽内容带的是**父组件**的 scope id —— 弹窗自己的 scoped 选择器命中不了它,
|
||||||
|
* 每个调用方各写一份按钮样式又会漂移。
|
||||||
|
* 这与 styles/signing-document.css 是同一类问题,用同一套解法。
|
||||||
|
*
|
||||||
|
* 因此这里的类名是预览弹窗对外的**公开契约**:
|
||||||
|
* .preview-button / .preview-button--ghost 页脚按钮
|
||||||
|
* .preview-spacer 把按钮推到右侧
|
||||||
|
* .preview-note-footer 页脚左侧说明(窄屏隐藏)
|
||||||
|
*
|
||||||
|
* 弹窗自身的框(遮罩 / 标题栏 / 页签 / 滚动区 / 纸张舞台)仍是组件内的 scoped 样式。
|
||||||
|
*/
|
||||||
|
|
||||||
|
.preview-button {
|
||||||
|
flex-shrink: 0;
|
||||||
|
padding: 7px 14px;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 12.5px;
|
||||||
|
font-weight: 700;
|
||||||
|
background: var(--brand);
|
||||||
|
border-radius: 7px;
|
||||||
|
transition: background 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-button:disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-button--ghost {
|
||||||
|
color: var(--brand);
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid var(--brand);
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-button--ghost:hover:not(:disabled) {
|
||||||
|
background: var(--brand-l);
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-spacer {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-note-footer {
|
||||||
|
overflow: hidden;
|
||||||
|
color: var(--mut);
|
||||||
|
font-size: 12px;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 680px) {
|
||||||
|
.preview-note-footer {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,9 @@
|
|||||||
/* 文书渲染(签署态)的通用样式:占位符胶囊、选项、系统页眉。
|
/* 文书渲染(签署态)的通用样式:占位符胶囊、选项、系统页眉。
|
||||||
文书库预览 / 签署工作台 / 编辑器三处共用一份,避免各写一套后配色漂移 */
|
文书库预览 / 签署工作台 / 编辑器三处共用一份,避免各写一套后配色漂移 */
|
||||||
@import './signing-document.css';
|
@import './signing-document.css';
|
||||||
|
/* 文书预览弹窗的公共类(页脚按钮等):插槽内容带父组件 scope id,
|
||||||
|
弹窗的 scoped 样式命中不了,只能放全局 */
|
||||||
|
@import './document-preview.css';
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
font-family: var(--font-sans);
|
font-family: var(--font-sans);
|
||||||
|
|||||||
@@ -0,0 +1,137 @@
|
|||||||
|
/**
|
||||||
|
* 文书纸的基础排版(全项目唯一一份)。
|
||||||
|
*
|
||||||
|
* 为什么单独成文件、还要在运行期注入:同一份「文书纸」有**两种渲染通道**——
|
||||||
|
* ① v-html 直接挂进页面(签署工作台、各类预览)→ 只能吃全局样式;
|
||||||
|
* ② iframe srcdoc(文书库的「文书内容」页签,隔离模板自带的 `<style>`)→ 只能吃字符串。
|
||||||
|
* 各写一份必然漂移:改版前实测已经有 5 份副本,表格边框在 `#52646e` 与 `#c8d7dd` 之间
|
||||||
|
* 分叉、单元格内边距 `5px 7px` 与 `5px 9px` 不一致、只有两处带宋体。
|
||||||
|
* 所以这里只维护一份字符串,两条通道共用。
|
||||||
|
*
|
||||||
|
* 选择器一律以 `.doc-paper` 开头:v-html 通道把它挂在容器 div 上,
|
||||||
|
* iframe 通道挂在 `<body>` 上,两边命中同一批规则。
|
||||||
|
*
|
||||||
|
* 注意:填写项胶囊 / 选项 ☑☐ / 系统页眉的样式**不在这里**,
|
||||||
|
* 它们与「签署态」语义强相关,统一在 styles/signing-document.css。
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const DOCUMENT_PAPER_CSS = `
|
||||||
|
.doc-paper {
|
||||||
|
color: #1c2b33;
|
||||||
|
font-family: 'SimSun', 'Songti SC', 'STSong', serif;
|
||||||
|
font-size: 13.5px;
|
||||||
|
line-height: 1.85;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.doc-paper p {
|
||||||
|
margin: 0 0 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.doc-paper h1,
|
||||||
|
.doc-paper h2 {
|
||||||
|
margin: 0 0 10px;
|
||||||
|
font-size: 18px;
|
||||||
|
text-align: center;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.doc-paper h3 {
|
||||||
|
margin: 14px 0 6px;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.doc-paper h4 {
|
||||||
|
margin: 10px 0 4px;
|
||||||
|
font-size: 13.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.doc-paper ol,
|
||||||
|
.doc-paper ul {
|
||||||
|
margin: 6px 0;
|
||||||
|
padding-left: 22px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.doc-paper li {
|
||||||
|
margin: 0 0 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 表格按内容自适应列宽并允许换行:绝对列宽在窄容器里会把整表裁掉 */
|
||||||
|
.doc-paper table {
|
||||||
|
width: 100%;
|
||||||
|
margin: 8px 0 12px;
|
||||||
|
border-collapse: collapse;
|
||||||
|
table-layout: auto;
|
||||||
|
font-size: 12.5px;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.doc-paper th,
|
||||||
|
.doc-paper td {
|
||||||
|
padding: 5px 7px;
|
||||||
|
vertical-align: middle;
|
||||||
|
border: 1px solid #52646e;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.doc-paper th {
|
||||||
|
font-weight: 700;
|
||||||
|
background: #f2f6f8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.doc-paper th p,
|
||||||
|
.doc-paper td p {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.doc-paper img {
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 系统页眉是 flex 排的一行患者信息,纸内边距由容器给,这里只补下间距 */
|
||||||
|
.doc-paper > .sign-doc-header:first-child {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
`
|
||||||
|
|
||||||
|
const STYLE_TAG_ID = 'document-paper-base-style'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 把基础排版注入 `document.head`(幂等)。
|
||||||
|
*
|
||||||
|
* 用运行期注入而不是普通的全局 css 文件,是为了让 v-html 与 iframe 两条通道
|
||||||
|
* 吃**同一份**规则文本;写成 css 文件后 iframe 拿不到(srcdoc 无法引用应用样式表)。
|
||||||
|
*/
|
||||||
|
export function ensureDocumentPaperStyle(): void {
|
||||||
|
if (typeof document === 'undefined') {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (document.getElementById(STYLE_TAG_ID)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const style = document.createElement('style')
|
||||||
|
style.id = STYLE_TAG_ID
|
||||||
|
style.textContent = DOCUMENT_PAPER_CSS
|
||||||
|
document.head.appendChild(style)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 把一段正文包成可直接塞进 iframe 的完整文档。
|
||||||
|
*
|
||||||
|
* 正文本身已经是完整 HTML 文档(预置模板自带版式)时原样返回——
|
||||||
|
* 那种情况下再套一层 body 会破坏它自己的 `html/body` 样式。
|
||||||
|
*/
|
||||||
|
export function buildPaperSrcDoc(contentHtml: string): string {
|
||||||
|
if (/^\s*<!doctype html|^\s*<html[\s>]/i.test(contentHtml)) {
|
||||||
|
return contentHtml
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'<!DOCTYPE html><html lang="zh-CN"><head><meta charset="utf-8" />',
|
||||||
|
`<style>${DOCUMENT_PAPER_CSS}</style></head>`,
|
||||||
|
`<body class="doc-paper">${contentHtml}</body></html>`,
|
||||||
|
].join('')
|
||||||
|
}
|
||||||
+138
-551
@@ -1,18 +1,29 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, nextTick, ref, watch } from 'vue'
|
/**
|
||||||
|
* 文书库的模板预览。
|
||||||
|
*
|
||||||
|
* 它只是**统一预览弹窗**在文书库这一处的配置:两个页签(文书内容 / 签署预览)+
|
||||||
|
* 「用此模板发起签署」出口。纸的渲染、外框、页脚按钮都在 components/document/ 里,
|
||||||
|
* 这里不再实现任何渲染逻辑 —— 改版前这一处自己抄了一份基础排版(表格边框、字号、宋体),
|
||||||
|
* 与签署工作台那份已经分叉。
|
||||||
|
*
|
||||||
|
* 两个页签的区别只在渲染模式:
|
||||||
|
* - 文书内容:`mode="raw"`,原样展示定稿(iframe 隔离模板自带的 `<style>`);
|
||||||
|
* - 签署预览:`mode="signing"`,按签署时效果渲染,占位符用示例患者填充、☑/☐ 可直接点。
|
||||||
|
*/
|
||||||
|
import { computed, ref, watch } from 'vue'
|
||||||
|
|
||||||
|
import DocumentPreviewDialog from '@/components/document/DocumentPreviewDialog.vue'
|
||||||
|
import type { SigningAnswers } from '@/api/workbench/types'
|
||||||
|
import type { SigningFieldProgress } from '@/utils/signing-document'
|
||||||
|
|
||||||
import {
|
import {
|
||||||
buildSigningDocumentHtml,
|
canInitiateSigning,
|
||||||
collectSelectedItemsFromRoot,
|
readLibraryStatus,
|
||||||
describeFieldProgress,
|
readStatusTone,
|
||||||
OPTION_TICK_SELECTOR,
|
type DocumentStatusTone,
|
||||||
readAnswersFromRoot,
|
} from '../library'
|
||||||
toggleOptionAt,
|
import { PREVIEW_PATIENT } from '../sample-patient'
|
||||||
type SigningFieldProgress,
|
|
||||||
} from '@/utils/signing-document'
|
|
||||||
|
|
||||||
import { canInitiateSigning, readLibraryStatus, readStatusTone } from '../library'
|
|
||||||
import { PREVIEW_PATIENT as previewPatient } from '../sample-patient'
|
|
||||||
import type { DocumentLibraryStatus, DocumentTemplate } from '../types'
|
import type { DocumentLibraryStatus, DocumentTemplate } from '../types'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
@@ -27,45 +38,31 @@ const emit = defineEmits<{
|
|||||||
use: [template: DocumentTemplate]
|
use: [template: DocumentTemplate]
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
/**
|
|
||||||
* 签署预览用的示例患者快照见 ../sample-patient(编辑器弹窗的「签署预览」共用同一份)。
|
|
||||||
*/
|
|
||||||
|
|
||||||
/** 预览页签:「文书内容」看原始定稿,「签署预览」看占位符填充与勾选交互化后的样子 */
|
/** 预览页签:「文书内容」看原始定稿,「签署预览」看占位符填充与勾选交互化后的样子 */
|
||||||
const activeTab = ref<'content' | 'signing'>('content')
|
const activeTab = ref('content')
|
||||||
/** 签署预览里科室试勾选的结果,仅用于预览反馈,不作为签署证据 */
|
|
||||||
const signingSelection = ref<string[]>([])
|
|
||||||
/** 签署预览里填写项的完成情况,同样只用于预览反馈 */
|
|
||||||
const signingProgress = ref<SigningFieldProgress>({
|
|
||||||
total: 0,
|
|
||||||
filled: 0,
|
|
||||||
missingRequired: [],
|
|
||||||
abnormal: [],
|
|
||||||
pendingSignatures: [],
|
|
||||||
})
|
|
||||||
const signingBodyRef = ref<HTMLElement | null>(null)
|
|
||||||
|
|
||||||
// 切到签署预览时才挂载文书 DOM,挂载后再统计填写项,否则进页面会显示「0 / 0 项」
|
/**
|
||||||
watch(activeTab, async (tab) => {
|
* 签署预览里的试填结果,仅用于预览反馈,不作为签署证据。
|
||||||
if (tab === 'signing') {
|
* 只在页脚展示一行汇总,所以只留计数与清单,不留整份取值。
|
||||||
await nextTick()
|
*/
|
||||||
syncPreviewFields()
|
const filledCount = ref(0)
|
||||||
|
const totalCount = ref(0)
|
||||||
|
const selectedLabels = ref<string[]>([])
|
||||||
|
|
||||||
|
function handleFieldChange(_answers: SigningAnswers, progress: SigningFieldProgress) {
|
||||||
|
filledCount.value = progress.filled
|
||||||
|
totalCount.value = progress.total
|
||||||
}
|
}
|
||||||
})
|
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => props.visible,
|
() => props.visible,
|
||||||
(visible) => {
|
(visible) => {
|
||||||
if (visible) {
|
if (visible) {
|
||||||
|
// 每次打开都回到「文书内容」,并把上一次的试填结果清掉
|
||||||
activeTab.value = 'content'
|
activeTab.value = 'content'
|
||||||
signingSelection.value = []
|
filledCount.value = 0
|
||||||
signingProgress.value = {
|
totalCount.value = 0
|
||||||
total: 0,
|
selectedLabels.value = []
|
||||||
filled: 0,
|
|
||||||
missingRequired: [],
|
|
||||||
abnormal: [],
|
|
||||||
pendingSignatures: [],
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -75,10 +72,16 @@ const libraryStatus = computed<DocumentLibraryStatus>(() =>
|
|||||||
props.template ? readLibraryStatus(props.template) : '草稿',
|
props.template ? readLibraryStatus(props.template) : '草稿',
|
||||||
)
|
)
|
||||||
|
|
||||||
const statusLabel = computed(() => libraryStatus.value)
|
/** 文书状态 → 预览弹窗徽标色调(弹窗只认 brand / ok / warn / mut 四档) */
|
||||||
|
const BADGE_TONES: Record<DocumentStatusTone, 'ok' | 'warn' | 'mut'> = {
|
||||||
|
enabled: 'ok',
|
||||||
|
review: 'warn',
|
||||||
|
draft: 'mut',
|
||||||
|
archived: 'mut',
|
||||||
|
stopped: 'mut',
|
||||||
|
}
|
||||||
|
|
||||||
/** 徽标配色与文书卡片同口径(见 library.ts 的 STATUS_TONES) */
|
const statusTone = computed(() => BADGE_TONES[readStatusTone(libraryStatus.value)])
|
||||||
const statusTone = computed(() => readStatusTone(libraryStatus.value))
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 与文书卡片保持同一口径:只有「启用中」的文书能发起签署。
|
* 与文书卡片保持同一口径:只有「启用中」的文书能发起签署。
|
||||||
@@ -88,189 +91,56 @@ const canInitiate = computed(
|
|||||||
() => Boolean(props.template?.contentHtml) && canInitiateSigning(libraryStatus.value),
|
() => Boolean(props.template?.contentHtml) && canInitiateSigning(libraryStatus.value),
|
||||||
)
|
)
|
||||||
|
|
||||||
// 预置文书的 contentHtml 是完整 HTML 文档(自带版式样式);
|
const tabs = computed(() => [
|
||||||
// Word 自维护文书存的是编辑器产出的 HTML 片段,需要叠加文书基础渲染样式
|
{ key: 'content', label: '文书内容' },
|
||||||
// (表格边框、正文字体等),使导入路径的"裸表格"等语义结构也有合理的文书外观。
|
{
|
||||||
const DOCUMENT_BASE_STYLE = `
|
key: 'signing',
|
||||||
body {
|
label: '签署预览',
|
||||||
margin: 0;
|
disabled: !props.template?.contentHtml,
|
||||||
padding: 24px 28px 36px;
|
hint: '按签署时的效果渲染:占位符填充、勾选表交互化、签名域',
|
||||||
color: #1c2b33;
|
},
|
||||||
font-family: 'SimSun', 'Songti SC', 'STSong', serif;
|
])
|
||||||
font-size: 14px;
|
|
||||||
line-height: 1.85;
|
|
||||||
background: #fff;
|
|
||||||
overflow-x: auto;
|
|
||||||
}
|
|
||||||
img {
|
|
||||||
max-width: 100%;
|
|
||||||
}
|
|
||||||
table {
|
|
||||||
width: 100%;
|
|
||||||
margin: 8px 0;
|
|
||||||
border-collapse: collapse;
|
|
||||||
table-layout: auto;
|
|
||||||
font-size: 12.5px;
|
|
||||||
line-height: 1.6;
|
|
||||||
}
|
|
||||||
th,
|
|
||||||
td {
|
|
||||||
padding: 5px 7px;
|
|
||||||
border: 1px solid #52646e;
|
|
||||||
vertical-align: middle;
|
|
||||||
overflow-wrap: anywhere;
|
|
||||||
word-break: break-word;
|
|
||||||
}
|
|
||||||
th p,
|
|
||||||
td p {
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
`
|
|
||||||
|
|
||||||
const previewSrcDoc = computed(() => {
|
const badges = computed(() => [
|
||||||
const contentHtml = props.template?.contentHtml ?? ''
|
{ text: '类 Word 版式', tone: 'brand' as const },
|
||||||
|
{ text: libraryStatus.value, tone: statusTone.value },
|
||||||
|
])
|
||||||
|
|
||||||
if (/^\s*<!doctype html|^\s*<html[\s>]/i.test(contentHtml)) {
|
const note = computed(() =>
|
||||||
return contentHtml
|
activeTab.value === 'signing'
|
||||||
}
|
? `按签署时效果渲染:占位符已用示例患者「${PREVIEW_PATIENT.patientName}」填充,空白处是待补录的下划线、☑ / ☐ 可直接点击确认交互效果(预览不保存结果)。`
|
||||||
|
: '',
|
||||||
return `<!DOCTYPE html><html lang="zh-CN"><head><meta charset="utf-8" /><style>${DOCUMENT_BASE_STYLE}</style></head><body>${contentHtml}</body></html>`
|
|
||||||
})
|
|
||||||
|
|
||||||
// 签署预览:复用签署端同一套渲染管线(占位符填充 → 签名域 → 勾选表交互化),
|
|
||||||
// 患者/就诊数据用示例快照。interactive 传 true,科室可以在预览里试勾选、确认交互效果。
|
|
||||||
// 这里不用 iframe:需要读取勾选结果做汇总,iframe 的 sandbox 也不便于表单交互。
|
|
||||||
const signingPreviewHtml = computed(() =>
|
|
||||||
buildSigningDocumentHtml(props.template?.contentHtml ?? '', previewPatient, true),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// 填写项与勾选汇总只在预览内展示(切页签、重开弹窗即重置),不作为签署证据。
|
|
||||||
function syncPreviewFields() {
|
|
||||||
const body = signingBodyRef.value
|
|
||||||
|
|
||||||
if (!body) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const answers = readAnswersFromRoot(body)
|
|
||||||
signingProgress.value = describeFieldProgress(body, answers)
|
|
||||||
signingSelection.value = collectSelectedItemsFromRoot(body)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 预览里的 ☑ / ☐ 就地切换。
|
|
||||||
* 选项渲染成文本(原型口径),不会触发 change 事件,必须自己接 click;
|
|
||||||
* 切换判据在 utils/signing-document 的 toggleOptionAt 里,与签署端共用一份。
|
|
||||||
*/
|
|
||||||
function handlePreviewClick(event: MouseEvent) {
|
|
||||||
const body = signingBodyRef.value
|
|
||||||
const target = event.target
|
|
||||||
|
|
||||||
if (!body || !(target instanceof Element)) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const option = target.closest<HTMLElement>(OPTION_TICK_SELECTOR)
|
|
||||||
|
|
||||||
if (option && toggleOptionAt(body, option)) {
|
|
||||||
syncPreviewFields()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<Teleport to="body">
|
<DocumentPreviewDialog
|
||||||
<div
|
:visible="visible && Boolean(template)"
|
||||||
v-if="visible && template"
|
:title="template?.name ?? ''"
|
||||||
class="dialog-mask"
|
:badges="badges"
|
||||||
role="presentation"
|
:tabs="tabs"
|
||||||
@click.self="emit('close')"
|
:active-tab="activeTab"
|
||||||
|
:mode="activeTab === 'signing' ? 'signing' : 'raw'"
|
||||||
|
:content-html="template?.contentHtml ?? ''"
|
||||||
|
:data="PREVIEW_PATIENT"
|
||||||
|
:interactive="activeTab === 'signing'"
|
||||||
|
:note="note"
|
||||||
|
:loading="loading"
|
||||||
|
:error="error ? '模板内容加载失败,请关闭后重试。' : ''"
|
||||||
|
:empty-text="`「${template?.name ?? ''}」暂无结构化正文`"
|
||||||
|
@close="emit('close')"
|
||||||
|
@update:active-tab="activeTab = $event"
|
||||||
|
@field-change="handleFieldChange"
|
||||||
|
@selection-change="selectedLabels = $event"
|
||||||
>
|
>
|
||||||
<section
|
<!-- 没有正文时的占位版式:这是**占位**,所以占位符写成 {患者姓名} 的原始形态,
|
||||||
class="preview-dialog"
|
不预填任何示例值 —— 预填会让人以为这份文书已经有正文了 -->
|
||||||
role="dialog"
|
<template #empty>
|
||||||
aria-modal="true"
|
<article class="placeholder-paper">
|
||||||
aria-labelledby="document-preview-title"
|
<h3>{{ template?.name }}</h3>
|
||||||
>
|
<p class="paper-number">
|
||||||
<header class="dialog-header">
|
模板编号:{{ template?.code || '未编号' }} · 版本 {{ template?.version }}
|
||||||
<div class="dialog-title">
|
|
||||||
<h2 id="document-preview-title">{{ template.name }}</h2>
|
|
||||||
<span class="word-badge">类 Word 版式</span>
|
|
||||||
<span class="status-badge" :class="'tone-' + statusTone">{{ statusLabel }}</span>
|
|
||||||
</div>
|
|
||||||
<button type="button" class="close-button" aria-label="关闭预览" @click="emit('close')">
|
|
||||||
×
|
|
||||||
</button>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<div class="preview-tabs" role="tablist" aria-label="预览方式">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
role="tab"
|
|
||||||
class="preview-tab"
|
|
||||||
:class="{ 'is-active': activeTab === 'content' }"
|
|
||||||
:aria-selected="activeTab === 'content'"
|
|
||||||
@click="activeTab = 'content'"
|
|
||||||
>
|
|
||||||
文书内容
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
role="tab"
|
|
||||||
class="preview-tab"
|
|
||||||
:class="{ 'is-active': activeTab === 'signing' }"
|
|
||||||
:aria-selected="activeTab === 'signing'"
|
|
||||||
:disabled="!template.contentHtml"
|
|
||||||
title="按签署时的效果渲染:占位符填充、勾选表交互化、签名域"
|
|
||||||
@click="activeTab = 'signing'"
|
|
||||||
>
|
|
||||||
签署预览
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="dialog-body">
|
|
||||||
<div v-if="loading" class="preview-state">正在加载模板内容…</div>
|
|
||||||
<div v-else-if="error" class="preview-state preview-state--error">
|
|
||||||
模板内容加载失败,请关闭后重试。
|
|
||||||
</div>
|
|
||||||
<template v-else-if="template.contentHtml && activeTab === 'signing'">
|
|
||||||
<p class="preview-note">
|
|
||||||
按签署时效果渲染:占位符已用示例患者「{{ previewPatient.patientName }}」填充,
|
|
||||||
空白处是待补录的下划线、☑ / ☐ 可直接点击确认交互效果(预览不保存结果)。
|
|
||||||
</p>
|
</p>
|
||||||
<!-- eslint-disable vue/no-v-html -- 内容为本系统定稿的文书 HTML(编辑器产出、仅医务人员可改),非任意用户输入 -->
|
|
||||||
<div
|
|
||||||
ref="signingBodyRef"
|
|
||||||
class="signing-preview"
|
|
||||||
@change="syncPreviewFields"
|
|
||||||
@input="syncPreviewFields"
|
|
||||||
@click="handlePreviewClick"
|
|
||||||
v-html="signingPreviewHtml"
|
|
||||||
/>
|
|
||||||
<!-- eslint-enable vue/no-v-html -->
|
|
||||||
<p class="signing-selection">
|
|
||||||
填写项 <strong>{{ signingProgress.filled }}</strong> /
|
|
||||||
{{ signingProgress.total }} 项已填<template v-if="signingSelection.length">
|
|
||||||
・已勾选 <strong>{{ signingSelection.length }}</strong> 项:{{
|
|
||||||
signingSelection.join('、')
|
|
||||||
}}</template
|
|
||||||
><template v-if="signingProgress.abnormal.length">
|
|
||||||
・异常项:<strong class="is-abnormal">{{
|
|
||||||
signingProgress.abnormal.join('、')
|
|
||||||
}}</strong></template
|
|
||||||
>
|
|
||||||
</p>
|
|
||||||
</template>
|
|
||||||
<iframe
|
|
||||||
v-else-if="template.contentHtml"
|
|
||||||
class="preview-frame"
|
|
||||||
:srcdoc="previewSrcDoc"
|
|
||||||
sandbox=""
|
|
||||||
title="文档模板内容预览"
|
|
||||||
/>
|
|
||||||
<article v-else class="preview-paper">
|
|
||||||
<h3>{{ template.name }}</h3>
|
|
||||||
<p class="paper-number">模板编号:{{ template.code }} · 版本 {{ template.version }}</p>
|
|
||||||
<p>
|
<p>
|
||||||
患者:<strong>{患者姓名}</strong> · 性别:<strong>{性别}</strong> · 年龄:<strong
|
患者:<strong>{患者姓名}</strong> · 性别:<strong>{性别}</strong> · 年龄:<strong
|
||||||
>{年龄}</strong
|
>{年龄}</strong
|
||||||
@@ -278,7 +148,7 @@ function handlePreviewClick(event: MouseEvent) {
|
|||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
门诊/住院号:<strong>{就诊号}</strong> · 科室:<strong>{{
|
门诊/住院号:<strong>{就诊号}</strong> · 科室:<strong>{{
|
||||||
template.department
|
template?.department
|
||||||
}}</strong>
|
}}</strong>
|
||||||
· 日期:<strong>{签署日期}</strong>
|
· 日期:<strong>{签署日期}</strong>
|
||||||
</p>
|
</p>
|
||||||
@@ -288,301 +158,77 @@ function handlePreviewClick(event: MouseEvent) {
|
|||||||
<p>
|
<p>
|
||||||
本文档采用电子方式签署,患者手写签名图片将合成至下方签名域,文档原件与签名原图双留存。
|
本文档采用电子方式签署,患者手写签名图片将合成至下方签名域,文档原件与签名原图双留存。
|
||||||
</p>
|
</p>
|
||||||
|
<div class="placeholder-signature">
|
||||||
<div class="signature-area">
|
|
||||||
<div>
|
<div>
|
||||||
<span>患者 / 授权委托人签名:</span>
|
<span>患者 / 授权委托人签名:</span>
|
||||||
<div class="signature-line">签名域(自动合成)</div>
|
<div class="placeholder-line">签名域(自动合成)</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span>日期:</span>
|
<span>日期:</span>
|
||||||
<div class="signature-line date-line">自动填充</div>
|
<div class="placeholder-line placeholder-line--date">自动填充</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<p class="placeholder-hint">
|
||||||
|
本文书尚无结构化正文,以上为占位版式。请在卡片上点「编辑正文」导入 .docx
|
||||||
|
或编写正文后再预览。
|
||||||
|
</p>
|
||||||
</article>
|
</article>
|
||||||
</div>
|
</template>
|
||||||
|
|
||||||
<footer class="dialog-footer">
|
<template #footer>
|
||||||
<p>文书版式取自科室真实知情同意书原件,{占位符}与勾选项在发起签署时由系统自动填充。</p>
|
<span v-if="activeTab === 'signing' && totalCount" class="preview-note-footer">
|
||||||
<button type="button" class="footer-button secondary" @click="emit('close')">关闭</button>
|
填写项 {{ filledCount }} / {{ totalCount }} 项已填<template v-if="selectedLabels.length"
|
||||||
|
>・已勾选 {{ selectedLabels.length }} 项:{{ selectedLabels.join('、') }}</template
|
||||||
|
>
|
||||||
|
</span>
|
||||||
|
<span v-else class="preview-note-footer">
|
||||||
|
文书版式取自科室真实知情同意书原件,{占位符}与勾选项在发起签署时由系统自动填充。
|
||||||
|
</span>
|
||||||
|
<span class="preview-spacer" />
|
||||||
|
<button type="button" class="preview-button preview-button--ghost" @click="emit('close')">
|
||||||
|
关闭
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="footer-button primary"
|
class="preview-button"
|
||||||
:disabled="!canInitiate"
|
:disabled="!canInitiate || !template"
|
||||||
:title="canInitiate ? '用此模板发起签署' : '仅已发布(审核通过)的模板可发起签署'"
|
:title="canInitiate ? '用此模板发起签署' : '仅「启用中」且已有正文的文书可发起签署'"
|
||||||
@click="emit('use', template)"
|
@click="template && emit('use', template)"
|
||||||
>
|
>
|
||||||
用此模板发起签署
|
用此模板发起签署
|
||||||
</button>
|
</button>
|
||||||
</footer>
|
</template>
|
||||||
</section>
|
</DocumentPreviewDialog>
|
||||||
</div>
|
|
||||||
</Teleport>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.dialog-mask {
|
/* 占位版式:只在文书还没有正文时出现,是"告诉人这里该有什么"的示意图,
|
||||||
position: fixed;
|
不是真实文书纸,所以不复用 DocumentPaper 的排版,样式就地写清楚。
|
||||||
z-index: 20;
|
真实文书的排版一律走 components/document/DocumentPaper.vue(唯一一份)。 */
|
||||||
inset: 0;
|
.placeholder-paper {
|
||||||
display: flex;
|
|
||||||
padding: 16px;
|
|
||||||
overflow-y: auto;
|
|
||||||
background: rgb(9 40 52 / 45%);
|
|
||||||
}
|
|
||||||
|
|
||||||
.preview-dialog {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
width: 720px;
|
|
||||||
max-width: 96vw;
|
|
||||||
max-height: calc(100vh - 32px);
|
|
||||||
margin: auto;
|
|
||||||
overflow: hidden;
|
|
||||||
background: #fff;
|
|
||||||
border-radius: 14px;
|
|
||||||
box-shadow: var(--sh-modal);
|
|
||||||
animation: dialog-pop 0.18s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes dialog-pop {
|
|
||||||
from {
|
|
||||||
opacity: 0;
|
|
||||||
transform: scale(0.96) translateY(8px);
|
|
||||||
}
|
|
||||||
|
|
||||||
to {
|
|
||||||
opacity: 1;
|
|
||||||
transform: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.dialog-header {
|
|
||||||
display: flex;
|
|
||||||
gap: 12px;
|
|
||||||
align-items: center;
|
|
||||||
padding: 12px 18px;
|
|
||||||
border-bottom: 1px solid var(--line);
|
|
||||||
}
|
|
||||||
|
|
||||||
.dialog-title {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 8px;
|
|
||||||
align-items: center;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dialog-title h2 {
|
|
||||||
margin: 0;
|
|
||||||
overflow: hidden;
|
|
||||||
color: var(--ink);
|
|
||||||
font-size: 15px;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.word-badge,
|
|
||||||
.status-badge {
|
|
||||||
padding: 3px 9px;
|
|
||||||
font-size: 11px;
|
|
||||||
font-weight: 700;
|
|
||||||
border-radius: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.word-badge {
|
|
||||||
color: var(--brand);
|
|
||||||
background: var(--brand-l);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 状态徽标配色:与文书卡片同一套色调(见 library.ts 的 STATUS_TONES) */
|
|
||||||
.status-badge.tone-draft,
|
|
||||||
.status-badge.tone-archived,
|
|
||||||
.status-badge.tone-stopped {
|
|
||||||
color: #8a9aa3;
|
|
||||||
background: #eceef0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-badge.tone-review {
|
|
||||||
color: var(--warn);
|
|
||||||
background: var(--warn-l);
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-badge.tone-enabled {
|
|
||||||
color: var(--ok);
|
|
||||||
background: var(--ok-l);
|
|
||||||
}
|
|
||||||
|
|
||||||
.close-button {
|
|
||||||
flex-shrink: 0;
|
|
||||||
margin-left: auto;
|
|
||||||
color: var(--mut);
|
|
||||||
font-size: 20px;
|
|
||||||
line-height: 1;
|
|
||||||
background: transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
.close-button:hover {
|
|
||||||
color: var(--ink);
|
|
||||||
}
|
|
||||||
|
|
||||||
.preview-tabs {
|
|
||||||
display: flex;
|
|
||||||
gap: 6px;
|
|
||||||
padding: 8px 18px;
|
|
||||||
border-bottom: 1px solid var(--line);
|
|
||||||
}
|
|
||||||
|
|
||||||
.preview-tab {
|
|
||||||
padding: 5px 12px;
|
|
||||||
color: var(--mut);
|
|
||||||
font-size: 12.5px;
|
|
||||||
font-weight: 700;
|
|
||||||
background: transparent;
|
|
||||||
border: 1px solid transparent;
|
|
||||||
border-radius: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.preview-tab:hover:not(:disabled) {
|
|
||||||
color: var(--brand);
|
|
||||||
}
|
|
||||||
|
|
||||||
.preview-tab.is-active {
|
|
||||||
color: var(--brand);
|
|
||||||
background: var(--brand-l);
|
|
||||||
}
|
|
||||||
|
|
||||||
.preview-tab:disabled {
|
|
||||||
color: #b6c3c9;
|
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
|
||||||
|
|
||||||
.preview-note {
|
|
||||||
padding: 8px 12px;
|
|
||||||
margin: 0 0 10px;
|
|
||||||
color: var(--brand);
|
|
||||||
font-size: 12px;
|
|
||||||
line-height: 1.6;
|
|
||||||
background: var(--brand-l);
|
|
||||||
border-radius: 7px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dialog-body {
|
|
||||||
min-height: 0;
|
|
||||||
padding: 12px 18px;
|
|
||||||
overflow-y: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.preview-state {
|
|
||||||
display: grid;
|
|
||||||
min-height: 420px;
|
|
||||||
color: var(--mut);
|
|
||||||
font-size: 13px;
|
|
||||||
place-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.preview-state--error {
|
|
||||||
color: var(--err);
|
|
||||||
}
|
|
||||||
|
|
||||||
.preview-frame {
|
|
||||||
display: block;
|
|
||||||
width: 100%;
|
|
||||||
min-height: 560px;
|
|
||||||
background: #fff;
|
|
||||||
border: 1px solid #eef4f6;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 签署预览用 v-html 渲染(需读取勾选结果,因此不用 sandbox iframe)。
|
|
||||||
下列基础文书样式需与 previewSrcDoc 使用的 DOCUMENT_BASE_STYLE 保持一致;
|
|
||||||
v-html 注入的节点不携带 scoped 属性,统一用 :deep 覆盖。 */
|
|
||||||
.signing-preview {
|
|
||||||
min-height: 560px;
|
|
||||||
padding: 24px 28px 36px;
|
|
||||||
color: #1c2b33;
|
|
||||||
font-family: 'SimSun', 'Songti SC', 'STSong', serif;
|
|
||||||
font-size: 14px;
|
|
||||||
line-height: 1.85;
|
|
||||||
background: #fff;
|
|
||||||
border: 1px solid #eef4f6;
|
|
||||||
overflow-x: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.signing-preview :deep(img) {
|
|
||||||
max-width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.signing-preview :deep(table) {
|
|
||||||
width: 100%;
|
|
||||||
margin: 8px 0;
|
|
||||||
border-collapse: collapse;
|
|
||||||
table-layout: auto;
|
|
||||||
font-size: 12.5px;
|
|
||||||
line-height: 1.6;
|
|
||||||
}
|
|
||||||
|
|
||||||
.signing-preview :deep(th),
|
|
||||||
.signing-preview :deep(td) {
|
|
||||||
padding: 5px 7px;
|
|
||||||
border: 1px solid #52646e;
|
|
||||||
vertical-align: middle;
|
|
||||||
overflow-wrap: anywhere;
|
|
||||||
word-break: break-word;
|
|
||||||
}
|
|
||||||
|
|
||||||
.signing-preview :deep(th p),
|
|
||||||
.signing-preview :deep(td p) {
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 填写项胶囊、选项 ☑ / ☐、系统页眉的样式在 styles/signing-document.css(全局):
|
|
||||||
同一份签署态文书会在文书库预览、签署工作台与编辑器三处出现,各写一份必然漂移。
|
|
||||||
这里只保留预览容器自己的版式。 */
|
|
||||||
|
|
||||||
.signing-selection {
|
|
||||||
padding: 8px 12px;
|
|
||||||
margin: 10px 0 0;
|
|
||||||
color: var(--mut);
|
|
||||||
font-size: 12px;
|
|
||||||
background: #f8fbfc;
|
|
||||||
border: 1px solid var(--line);
|
|
||||||
border-radius: 7px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.signing-selection strong {
|
|
||||||
color: var(--brand);
|
|
||||||
}
|
|
||||||
|
|
||||||
.signing-selection .is-abnormal {
|
|
||||||
color: var(--err);
|
|
||||||
}
|
|
||||||
|
|
||||||
.preview-paper {
|
|
||||||
min-height: 420px;
|
|
||||||
padding: 26px 34px;
|
padding: 26px 34px;
|
||||||
color: var(--ink);
|
color: var(--ink);
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
line-height: 1.9;
|
line-height: 1.9;
|
||||||
background: #fff;
|
background: #fff;
|
||||||
border: 1px solid #eef4f6;
|
border-radius: var(--r);
|
||||||
box-shadow: 0 1px 5px rgb(13 62 81 / 5%);
|
box-shadow: var(--sh);
|
||||||
}
|
}
|
||||||
|
|
||||||
.preview-paper h3 {
|
.placeholder-paper h3 {
|
||||||
margin: 0 0 6px;
|
margin: 0 0 6px;
|
||||||
color: var(--ink);
|
|
||||||
font-size: 17px;
|
font-size: 17px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
letter-spacing: 2px;
|
letter-spacing: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.preview-paper p {
|
.placeholder-paper p {
|
||||||
margin: 0 0 7px;
|
margin: 0 0 7px;
|
||||||
text-indent: 2em;
|
text-indent: 2em;
|
||||||
text-align: justify;
|
text-align: justify;
|
||||||
}
|
}
|
||||||
|
|
||||||
.preview-paper .paper-number {
|
.placeholder-paper .paper-number {
|
||||||
margin-bottom: 18px;
|
margin-bottom: 18px;
|
||||||
color: var(--mut);
|
color: var(--mut);
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
@@ -590,7 +236,7 @@ function handlePreviewClick(event: MouseEvent) {
|
|||||||
text-indent: 0;
|
text-indent: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.signature-area {
|
.placeholder-signature {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
gap: 40px;
|
gap: 40px;
|
||||||
@@ -598,14 +244,14 @@ function handlePreviewClick(event: MouseEvent) {
|
|||||||
margin-top: 22px;
|
margin-top: 22px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.signature-area > div > span {
|
.placeholder-signature > div > span {
|
||||||
display: block;
|
display: block;
|
||||||
margin-bottom: 6px;
|
margin-bottom: 6px;
|
||||||
color: var(--mut);
|
color: var(--mut);
|
||||||
font-size: 12.5px;
|
font-size: 12.5px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.signature-line {
|
.placeholder-line {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
@@ -615,77 +261,18 @@ function handlePreviewClick(event: MouseEvent) {
|
|||||||
border-bottom: 2px dashed var(--line);
|
border-bottom: 2px dashed var(--line);
|
||||||
}
|
}
|
||||||
|
|
||||||
.signature-line.date-line {
|
.placeholder-line--date {
|
||||||
min-width: 130px;
|
min-width: 130px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dialog-footer {
|
.placeholder-hint {
|
||||||
display: flex;
|
padding: 8px 12px;
|
||||||
gap: 10px;
|
margin: 20px 0 0 !important;
|
||||||
align-items: center;
|
color: var(--warn);
|
||||||
padding: 10px 18px;
|
|
||||||
background: #f8fbfc;
|
|
||||||
border-top: 1px solid var(--line);
|
|
||||||
}
|
|
||||||
|
|
||||||
.dialog-footer p {
|
|
||||||
max-width: 46%;
|
|
||||||
margin: 0;
|
|
||||||
overflow: hidden;
|
|
||||||
color: var(--mut);
|
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
text-overflow: ellipsis;
|
text-align: left !important;
|
||||||
white-space: nowrap;
|
text-indent: 0 !important;
|
||||||
}
|
background: var(--warn-l);
|
||||||
|
|
||||||
.footer-button {
|
|
||||||
padding: 7px 13px;
|
|
||||||
font-size: 12px;
|
|
||||||
font-weight: 700;
|
|
||||||
border-radius: 7px;
|
border-radius: 7px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.footer-button.secondary {
|
|
||||||
margin-left: auto;
|
|
||||||
color: var(--brand);
|
|
||||||
background: #fff;
|
|
||||||
border: 1px solid var(--brand);
|
|
||||||
}
|
|
||||||
|
|
||||||
.footer-button.primary {
|
|
||||||
color: #fff;
|
|
||||||
background: var(--brand);
|
|
||||||
border: 1px solid var(--brand);
|
|
||||||
}
|
|
||||||
|
|
||||||
.footer-button:hover {
|
|
||||||
filter: brightness(0.96);
|
|
||||||
}
|
|
||||||
|
|
||||||
.footer-button:disabled {
|
|
||||||
color: #aebbc0;
|
|
||||||
background: #f1f5f6;
|
|
||||||
border-color: #dce5e8;
|
|
||||||
cursor: not-allowed;
|
|
||||||
filter: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 680px) {
|
|
||||||
.preview-paper {
|
|
||||||
padding: 22px 18px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dialog-footer {
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dialog-footer p {
|
|
||||||
width: 100%;
|
|
||||||
max-width: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.footer-button.secondary {
|
|
||||||
margin-left: 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -10,7 +10,8 @@ import {
|
|||||||
SIGNING_FIELD_PRESETS,
|
SIGNING_FIELD_PRESETS,
|
||||||
upgradeFieldTokensToStructured,
|
upgradeFieldTokensToStructured,
|
||||||
} from '@/utils/signing-fields'
|
} from '@/utils/signing-fields'
|
||||||
import { buildSigningDocumentHtml } from '@/utils/signing-document'
|
|
||||||
|
import DocumentPaper from '@/components/document/DocumentPaper.vue'
|
||||||
|
|
||||||
import EditorWangPane from './editors/EditorWangPane.vue'
|
import EditorWangPane from './editors/EditorWangPane.vue'
|
||||||
import { DOC_TEMPLATE_PERMISSIONS } from '../permissions'
|
import { DOC_TEMPLATE_PERMISSIONS } from '../permissions'
|
||||||
@@ -338,9 +339,6 @@ const saveHints = computed(() => {
|
|||||||
|
|
||||||
/** 签署预览:把正文按签署时效果渲染一遍(原型 docTogglePreview) */
|
/** 签署预览:把正文按签署时效果渲染一遍(原型 docTogglePreview) */
|
||||||
const previewing = ref(false)
|
const previewing = ref(false)
|
||||||
const previewHtml = computed(() =>
|
|
||||||
previewing.value ? buildSigningDocumentHtml(valueHtml.value, PREVIEW_PATIENT, false) : '',
|
|
||||||
)
|
|
||||||
|
|
||||||
function togglePreview() {
|
function togglePreview() {
|
||||||
if (!previewing.value) {
|
if (!previewing.value) {
|
||||||
@@ -543,12 +541,15 @@ function requestClose() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- 签署预览:纸的渲染走统一组件,这里只给盒子(.preview-paper)。
|
||||||
|
改版前这里自己抄了一份基础排版,表格边框与工作台那份已经分叉 -->
|
||||||
<div v-if="previewing" class="preview-shell">
|
<div v-if="previewing" class="preview-shell">
|
||||||
<div class="preview-paper">
|
<DocumentPaper
|
||||||
<!-- eslint-disable vue/no-v-html -- 内容为本系统定稿的文书 HTML(编辑器产出、仅医务人员可改),非任意用户输入 -->
|
class="preview-paper"
|
||||||
<div v-html="previewHtml" />
|
:content-html="valueHtml"
|
||||||
<!-- eslint-enable vue/no-v-html -->
|
:data="PREVIEW_PATIENT"
|
||||||
</div>
|
:interactive="false"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else class="editor-shell">
|
<div v-else class="editor-shell">
|
||||||
@@ -1008,32 +1009,8 @@ function requestClose() {
|
|||||||
box-shadow: var(--sh);
|
box-shadow: var(--sh);
|
||||||
}
|
}
|
||||||
|
|
||||||
.preview-paper :deep(p) {
|
/* 基础排版(p / table / th / td / 宋体 …)在 components/document/DocumentPaper.vue
|
||||||
margin: 0 0 6px;
|
的 DOCUMENT_PAPER_CSS 里,全项目唯一一份。这里只留盒子。 */
|
||||||
}
|
|
||||||
|
|
||||||
.preview-paper :deep(table) {
|
|
||||||
width: 100%;
|
|
||||||
margin: 8px 0 12px;
|
|
||||||
border-collapse: collapse;
|
|
||||||
table-layout: auto;
|
|
||||||
font-size: 12.5px;
|
|
||||||
line-height: 1.7;
|
|
||||||
}
|
|
||||||
|
|
||||||
.preview-paper :deep(th),
|
|
||||||
.preview-paper :deep(td) {
|
|
||||||
padding: 5px 9px;
|
|
||||||
vertical-align: middle;
|
|
||||||
border: 1px solid #c8d7dd;
|
|
||||||
overflow-wrap: anywhere;
|
|
||||||
word-break: break-word;
|
|
||||||
}
|
|
||||||
|
|
||||||
.preview-paper :deep(th p),
|
|
||||||
.preview-paper :deep(td p) {
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ---- 底部 ---- */
|
/* ---- 底部 ---- */
|
||||||
|
|
||||||
|
|||||||
@@ -1,20 +1,20 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
/**
|
/**
|
||||||
* 已签署文书的在线预览(原型 openPdfView 的 mdPdfView)。
|
* 已签署 / 待科室确认文书的在线预览(原型 openPdfView 的 mdPdfView)。
|
||||||
*
|
*
|
||||||
* 用的是**同一个**文书渲染组件(SigningDocumentInteractive,只读态),
|
* 它只是**统一预览弹窗**在签署工作台这一处的配置:
|
||||||
* 而不是另写一套纸样:预览看到的版式必须与工作台里、与导出 PDF 的完全一致,
|
* 深灰 A4 舞台 + 「只读副本 / 下载 PDF 原件」两个出口。
|
||||||
* 三处各写一份必然漂移。
|
* 纸的渲染在 components/document/DocumentPaper.vue、弹窗外壳在 DocumentPreviewDialog.vue,
|
||||||
*
|
* 这里不重复实现任何渲染逻辑 —— 预览看到的版式必须与工作台里、与导出的 PDF 完全一致。
|
||||||
* 底部两个出口对应原型结果卡上的「下载」「只读副本」:
|
|
||||||
* - 下载 PDF 原件:优先取服务端产物,没有产物时(演示环境)由前端现场生成;
|
|
||||||
* - 只读副本:前端加带审计编号的水印后导出,对外提供时用这份。
|
|
||||||
*/
|
*/
|
||||||
import type { SigningTaskRecord } from '@/api/workbench/types'
|
import { computed } from 'vue'
|
||||||
import SigningDocumentInteractive from '@/components/signing/SigningDocumentInteractive.vue'
|
|
||||||
import SigningDocumentPreview from '@/components/signing/SigningDocumentPreview.vue'
|
|
||||||
|
|
||||||
defineProps<{
|
import type { SigningTaskRecord } from '@/api/workbench/types'
|
||||||
|
import DocumentPreviewDialog from '@/components/document/DocumentPreviewDialog.vue'
|
||||||
|
import SigningDocumentPreview from '@/components/signing/SigningDocumentPreview.vue'
|
||||||
|
import type { SigningDocumentData } from '@/utils/signing-document'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
visible: boolean
|
visible: boolean
|
||||||
task: SigningTaskRecord | null
|
task: SigningTaskRecord | null
|
||||||
/** 导出中:两个下载按钮一起禁用,避免重复点出多份 PDF */
|
/** 导出中:两个下载按钮一起禁用,避免重复点出多份 PDF */
|
||||||
@@ -26,38 +26,44 @@ const emit = defineEmits<{
|
|||||||
download: []
|
download: []
|
||||||
'download-readonly-copy': []
|
'download-readonly-copy': []
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
|
const subtitle = computed(() =>
|
||||||
|
props.task ? `${props.task.documentName} · ${props.task.id} · ${props.task.patientName}` : '',
|
||||||
|
)
|
||||||
|
|
||||||
|
/** 只挑渲染真正需要的字段显式映射,不直接透传整个任务对象 */
|
||||||
|
const documentData = computed<SigningDocumentData>(() => ({
|
||||||
|
patientName: props.task?.patientName ?? '',
|
||||||
|
sex: props.task?.sex ?? '',
|
||||||
|
age: props.task?.age ?? '',
|
||||||
|
visitNo: props.task?.visitNo ?? '',
|
||||||
|
visitDate: props.task?.visitDate ?? '',
|
||||||
|
signedAt: props.task?.signedAt ?? '',
|
||||||
|
signatureDataUrl: props.task?.signatureDataUrl,
|
||||||
|
selectedItems: props.task?.selectedItems,
|
||||||
|
fieldAnswers: props.task?.fieldAnswers,
|
||||||
|
}))
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<Teleport to="body">
|
<DocumentPreviewDialog
|
||||||
<div v-if="visible && task" class="preview-mask" @click.self="emit('close')">
|
:visible="visible && Boolean(task)"
|
||||||
<section
|
title="在线预览(PDF)"
|
||||||
class="preview-card"
|
:subtitle="subtitle"
|
||||||
role="dialog"
|
:content-html="task?.documentContentHtml ?? ''"
|
||||||
aria-modal="true"
|
:data="documentData"
|
||||||
aria-labelledby="signed-preview-title"
|
stage="stage"
|
||||||
|
:width="900"
|
||||||
|
@close="emit('close')"
|
||||||
>
|
>
|
||||||
<header class="preview-header">
|
<!-- 正文取不到时用带任务信息的回退纸样,而不是一句「暂无正文」:
|
||||||
<strong id="signed-preview-title">在线预览(PDF)</strong>
|
那会把「后端没给正文」和「这份文书本来就是空的」混为一谈 -->
|
||||||
<span class="preview-subtitle">
|
<template #empty>
|
||||||
{{ task.documentName }} · {{ task.id }} · {{ task.patientName }}
|
<SigningDocumentPreview v-if="task" :task="task" />
|
||||||
</span>
|
</template>
|
||||||
<button type="button" class="preview-close" aria-label="关闭" @click="emit('close')">
|
|
||||||
×
|
|
||||||
</button>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<div class="preview-body">
|
<template #footer>
|
||||||
<SigningDocumentInteractive
|
<span class="preview-note-footer">
|
||||||
v-if="task.documentContentHtml"
|
|
||||||
:task="task"
|
|
||||||
:interactive="false"
|
|
||||||
/>
|
|
||||||
<SigningDocumentPreview v-else :task="task" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<footer class="preview-footer">
|
|
||||||
<span class="preview-note">
|
|
||||||
正式文书以服务端生成的 PDF 原件为准;此处预览按任务固化的文书版本渲染。
|
正式文书以服务端生成的 PDF 原件为准;此处预览按任务固化的文书版本渲染。
|
||||||
</span>
|
</span>
|
||||||
<span class="preview-spacer" />
|
<span class="preview-spacer" />
|
||||||
@@ -70,152 +76,9 @@ const emit = defineEmits<{
|
|||||||
>
|
>
|
||||||
只读副本
|
只读副本
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button type="button" class="preview-button" :disabled="exporting" @click="emit('download')">
|
||||||
type="button"
|
|
||||||
class="preview-button"
|
|
||||||
:disabled="exporting"
|
|
||||||
@click="emit('download')"
|
|
||||||
>
|
|
||||||
{{ exporting ? '生成中…' : '下载 PDF 原件' }}
|
{{ exporting ? '生成中…' : '下载 PDF 原件' }}
|
||||||
</button>
|
</button>
|
||||||
</footer>
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
</Teleport>
|
|
||||||
</template>
|
</template>
|
||||||
|
</DocumentPreviewDialog>
|
||||||
<style scoped>
|
</template>
|
||||||
.preview-mask {
|
|
||||||
position: fixed;
|
|
||||||
z-index: 60;
|
|
||||||
inset: 0;
|
|
||||||
display: flex;
|
|
||||||
padding: 18px;
|
|
||||||
background: rgb(9 40 52 / 45%);
|
|
||||||
}
|
|
||||||
|
|
||||||
.preview-card {
|
|
||||||
display: flex;
|
|
||||||
width: 900px;
|
|
||||||
max-width: 96vw;
|
|
||||||
height: calc(100vh - 36px);
|
|
||||||
min-height: 0;
|
|
||||||
flex-direction: column;
|
|
||||||
margin: auto;
|
|
||||||
background: #fff;
|
|
||||||
border-radius: 14px;
|
|
||||||
box-shadow: var(--sh-modal);
|
|
||||||
animation: preview-pop 0.18s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.preview-header {
|
|
||||||
display: flex;
|
|
||||||
flex-shrink: 0;
|
|
||||||
gap: 10px;
|
|
||||||
align-items: baseline;
|
|
||||||
padding: 12px 18px;
|
|
||||||
border-bottom: 1px solid var(--line);
|
|
||||||
}
|
|
||||||
|
|
||||||
.preview-header strong {
|
|
||||||
font-size: 15px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.preview-subtitle {
|
|
||||||
overflow: hidden;
|
|
||||||
color: var(--mut);
|
|
||||||
font-size: 12px;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.preview-close {
|
|
||||||
flex-shrink: 0;
|
|
||||||
padding: 0;
|
|
||||||
margin-left: auto;
|
|
||||||
color: var(--mut);
|
|
||||||
font-size: 20px;
|
|
||||||
line-height: 1;
|
|
||||||
background: transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 唯一滚动区:文书在自己的框里滚,头尾常驻 */
|
|
||||||
.preview-body {
|
|
||||||
flex: 1;
|
|
||||||
min-height: 0;
|
|
||||||
padding: 14px;
|
|
||||||
background: #f2f6f8;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 让复用的文书外壳吃满弹窗高度,由它内部的 .sign-doc-body 滚动 */
|
|
||||||
.preview-body :deep(.sign-doc) {
|
|
||||||
height: 100%;
|
|
||||||
min-height: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.preview-footer {
|
|
||||||
display: flex;
|
|
||||||
flex-shrink: 0;
|
|
||||||
gap: 10px;
|
|
||||||
align-items: center;
|
|
||||||
padding: 10px 18px;
|
|
||||||
color: var(--mut);
|
|
||||||
font-size: 12px;
|
|
||||||
background: #f8fbfc;
|
|
||||||
border-top: 1px solid var(--line);
|
|
||||||
border-radius: 0 0 14px 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.preview-note {
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.preview-spacer {
|
|
||||||
flex: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.preview-button {
|
|
||||||
flex-shrink: 0;
|
|
||||||
padding: 8px 16px;
|
|
||||||
color: #fff;
|
|
||||||
font-size: 13px;
|
|
||||||
font-weight: 700;
|
|
||||||
background: var(--brand);
|
|
||||||
border-radius: 7px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.preview-button:disabled {
|
|
||||||
cursor: not-allowed;
|
|
||||||
opacity: 0.6;
|
|
||||||
}
|
|
||||||
|
|
||||||
.preview-button--ghost {
|
|
||||||
color: var(--brand);
|
|
||||||
background: #fff;
|
|
||||||
border: 1px solid var(--brand);
|
|
||||||
}
|
|
||||||
|
|
||||||
.preview-button--ghost:hover:not(:disabled) {
|
|
||||||
background: var(--brand-l);
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes preview-pop {
|
|
||||||
from {
|
|
||||||
opacity: 0;
|
|
||||||
transform: scale(0.97) translateY(8px);
|
|
||||||
}
|
|
||||||
|
|
||||||
to {
|
|
||||||
opacity: 1;
|
|
||||||
transform: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 680px) {
|
|
||||||
.preview-note {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|||||||
Reference in New Issue
Block a user