refactor(文书预览): 移除文书内容页签与 raw 渲染模式,统一走签署态预览

This commit is contained in:
yelan
2026-09-27 12:39:23 +08:00
parent 07b27ad6ca
commit 635e998946
4 changed files with 28 additions and 100 deletions
@@ -30,18 +30,12 @@ import {
type SigningFieldProgress, type SigningFieldProgress,
type SigningSignatureRequest, type SigningSignatureRequest,
} from '@/utils/signing-document' } from '@/utils/signing-document'
import { buildPaperSrcDoc, ensureDocumentPaperStyle } from '@/utils/document-paper' import { ensureDocumentPaperStyle } from '@/utils/document-paper'
const props = withDefaults( const props = withDefaults(
defineProps<{ defineProps<{
/** 文书正文(模板定稿 / 任务固化快照) */ /** 文书正文(模板定稿 / 任务固化快照) */
contentHtml: string contentHtml: string
/**
* 渲染模式。
* - `signing`(默认):按签署态渲染(占位符回填、勾选项交互化、签名域)
* - `raw`:原样展示定稿(iframe 隔离模板自带的 `<style>`),仅文书库的「文书内容」页签用
*/
mode?: 'signing' | 'raw'
/** 回填上下文:患者信息 / 签名 / 勾选 / 已填取值 */ /** 回填上下文:患者信息 / 签名 / 勾选 / 已填取值 */
data?: SigningDocumentData data?: SigningDocumentData
/** 填写项可否改动、签名位可否点击采集 */ /** 填写项可否改动、签名位可否点击采集 */
@@ -68,7 +62,6 @@ const props = withDefaults(
variant?: 'plain' | 'card' variant?: 'plain' | 'card'
}>(), }>(),
{ {
mode: 'signing',
data: () => ({}), data: () => ({}),
interactive: false, interactive: false,
signatureActionable: undefined, signatureActionable: undefined,
@@ -110,9 +103,6 @@ const processedHtml = computed(() =>
}), }),
) )
/** raw 模式的 iframe 文档(自带完整版式的正文原样返回) */
const rawSrcDoc = computed(() => buildPaperSrcDoc(props.contentHtml))
/** /**
* 从渲染后的 DOM 读回填写项取值与勾选清单并上报。 * 从渲染后的 DOM 读回填写项取值与勾选清单并上报。
* 生成 HTML 时已把取值写成属性/文本,这里再按取值同步一次 DOM 状态, * 生成 HTML 时已把取值写成属性/文本,这里再按取值同步一次 DOM 状态,
@@ -288,17 +278,8 @@ defineExpose({ getDocumentElement })
</script> </script>
<template> <template>
<!-- raw 模式:模板定稿可能自带 <style>,必须隔离,否则会污染整个应用 -->
<iframe
v-if="mode === 'raw'"
class="doc-paper-frame"
:srcdoc="rawSrcDoc"
sandbox=""
title="文书原始版式预览"
/>
<!-- eslint-disable vue/no-v-html -- 内容为本系统定稿的文书 HTML(编辑器产出、仅医务人员可改),非任意用户输入 --> <!-- eslint-disable vue/no-v-html -- 内容为本系统定稿的文书 HTML(编辑器产出、仅医务人员可改),非任意用户输入 -->
<article v-else class="doc-paper" :class="{ 'doc-paper--card': variant === 'card' }"> <article class="doc-paper" :class="{ 'doc-paper--card': variant === 'card' }">
<div <div
ref="containerRef" ref="containerRef"
class="doc-paper__body" class="doc-paper__body"
@@ -314,7 +295,7 @@ defineExpose({ getDocumentElement })
<style scoped> <style scoped>
/* 基础排版(p / h2 / h3 / table / th / td / img)在 utils/document-paper.ts 的 /* 基础排版(p / h2 / h3 / table / th / td / img)在 utils/document-paper.ts 的
DOCUMENT_PAPER_CSS 里,运行期注入 head —— 那里是唯一一份,v-html 与 iframe 共用。 DOCUMENT_PAPER_CSS 里,运行期注入 head —— 那里是唯一一份。
这里只保留容器自己的盒子。 */ 这里只保留容器自己的盒子。 */
.doc-paper { .doc-paper {
@@ -331,13 +312,4 @@ defineExpose({ getDocumentElement })
.doc-paper__body { .doc-paper__body {
min-width: 0; min-width: 0;
} }
.doc-paper-frame {
display: block;
width: 100%;
height: 100%;
min-height: 560px;
background: #fff;
border: 0;
}
</style> </style>
@@ -35,8 +35,6 @@ const props = withDefaults(
activeTab?: string activeTab?: string
/** 正文(模板定稿 / 任务固化快照) */ /** 正文(模板定稿 / 任务固化快照) */
contentHtml: string contentHtml: string
/** 渲染模式,透传给 DocumentPaper */
mode?: 'signing' | 'raw'
data?: SigningDocumentData data?: SigningDocumentData
interactive?: boolean interactive?: boolean
signatureActionable?: boolean signatureActionable?: boolean
@@ -71,7 +69,6 @@ const props = withDefaults(
badges: () => [], badges: () => [],
tabs: undefined, tabs: undefined,
activeTab: '', activeTab: '',
mode: 'signing',
data: () => ({}), data: () => ({}),
interactive: false, interactive: false,
signatureActionable: undefined, signatureActionable: undefined,
@@ -168,7 +165,6 @@ defineExpose({ getDocumentElement })
v-if="hasPaper" v-if="hasPaper"
ref="paperRef" ref="paperRef"
:content-html="contentHtml" :content-html="contentHtml"
:mode="mode"
:data="data" :data="data"
:interactive="interactive" :interactive="interactive"
:signature-actionable="signatureActionable" :signature-actionable="signatureActionable"
@@ -362,14 +358,6 @@ defineExpose({ getDocumentElement })
box-shadow: 0 6px 22px rgb(0 0 0 / 45%); 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 { .preview-state {
display: grid; display: grid;
min-height: 420px; min-height: 420px;
+11 -24
View File
@@ -1,15 +1,19 @@
/** /**
* 文书纸的基础排版(全项目唯一一份)。 * 文书纸的基础排版(全项目唯一一份)。
* *
* 为什么单独成文件、还要在运行期注入:同一份「文书纸」有**两种渲染通道**—— * 为什么单独成文件、还要在运行期注入:同一份「文书纸」原先有**两种渲染通道**——
* ① v-html 直接挂进页面(签署工作台、各类预览)→ 只能吃全局样式; * ① v-html 直接挂进页面(签署工作台、各类预览)→ 只能吃全局样式;
* ② iframe srcdoc(文书库的「文书内容」页签,隔离模板自带的 `<style>`)→ 只能吃字符串。 * ② iframe srcdoc(文书库的「文书内容」页签,隔离模板自带的 `<style>`)→ 只能吃字符串。
* 各写一份必然漂移:改版前实测已经有 5 份副本,表格边框在 `#52646e` 与 `#c8d7dd` 之间 * 各写一份必然漂移:改版前实测已经有 5 份副本,表格边框在 `#52646e` 与 `#c8d7dd` 之间
* 分叉、单元格内边距 `5px 7px` 与 `5px 9px` 不一致、只有两处带宋体。 * 分叉、单元格内边距 `5px 7px` 与 `5px 9px` 不一致、只有两处带宋体。
* 所以这里只维护一份字符串,两条通道共用。 * 所以这里只维护一份字符串。
* *
* 选择器一律以 `.doc-paper` 开头:v-html 通道把它挂在容器 div 上, * **2026-09-27 起只剩 ① 一条通道**:文书库预览去掉了「文书内容」页签,
* iframe 通道挂在 `<body>` 上,两边命中同一批规则。 * iframe/srcdoc 那条路连同 `DocumentPaper` 的 `raw` 模式一起删除。
* 仍然保留「运行期注入」而不是普通 css 文件,是因为注入的时机可控
* (setup 期,避免首帧"纸没排版"的闪烁),且规则文本同时供导出 PDF 之类的场景复用。
*
* 选择器一律以 `.doc-paper` 开头,挂在容器 div 上。
* *
* 注意:填写项胶囊 / 选项 ☑☐ / 系统页眉的样式**不在这里**, * 注意:填写项胶囊 / 选项 ☑☐ / 系统页眉的样式**不在这里**,
* 它们与「签署态」语义强相关,统一在 styles/signing-document.css。 * 它们与「签署态」语义强相关,统一在 styles/signing-document.css。
@@ -100,8 +104,9 @@ const STYLE_TAG_ID = 'document-paper-base-style'
/** /**
* 把基础排版注入 `document.head`(幂等)。 * 把基础排版注入 `document.head`(幂等)。
* *
* 用运行期注入而不是普通的全局 css 文件,是为了让 v-html 与 iframe 两条通道 * 用运行期注入而不是普通的全局 css 文件,是为了控制注入时机
* 吃**同一份**规则文本;写成 css 文件后 iframe 拿不到(srcdoc 无法引用应用样式表)。 * (setup 期而不是 onMounted,避免首帧"纸没排版"的闪烁),
* 并且让规则文本有唯一一份来源。
*/ */
export function ensureDocumentPaperStyle(): void { export function ensureDocumentPaperStyle(): void {
if (typeof document === 'undefined') { if (typeof document === 'undefined') {
@@ -117,21 +122,3 @@ export function ensureDocumentPaperStyle(): void {
style.textContent = DOCUMENT_PAPER_CSS style.textContent = DOCUMENT_PAPER_CSS
document.head.appendChild(style) 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('')
}
@@ -2,14 +2,17 @@
/** /**
* 文书库的模板预览。 * 文书库的模板预览。
* *
* 它只是**统一预览弹窗**在文书库这一处的配置:两个页签(文书内容 / 签署预览)+ * 它只是**统一预览弹窗**在文书库这一处的配置:签署预览 + 「用此模板发起签署」出口。
* 「用此模板发起签署」出口。纸的渲染、外框、页脚按钮都在 components/document/ 里, * 纸的渲染、外框、页脚按钮都在 components/document/ 里,
* 这里不再实现任何渲染逻辑 —— 改版前这一处自己抄了一份基础排版(表格边框、字号、宋体), * 这里不再实现任何渲染逻辑 —— 改版前这一处自己抄了一份基础排版(表格边框、字号、宋体),
* 与签署工作台那份已经分叉。 * 与签署工作台那份已经分叉。
* *
* 两个页签的区别只在渲染模式: * **只有签署预览一种形态**(2026-09-27 去掉了原来的「文书内容」页签):
* - 文书内容:`mode="raw"`,原样展示定稿(iframe 隔离模板自带的 `<style>`); * 按签署时效果渲染,占位符用示例患者填充、☑ / ☐ 可直接点。
* - 签署预览:`mode="signing"`,按签署时效果渲染,占位符用示例患者填充、☑/☐ 可直接点。 * 原来的「文书内容」走 `DocumentPaper` 的 `raw` 模式(iframe 原样展示定稿),
* 与签署态并排会让科室看到两份长得不一样的同一份文书,
* 也让人不知道该以哪一份为准 —— 已连同 `raw` 模式一起删除。
* 要看原始定稿请走卡片的「编辑正文」。
*/ */
import { computed, ref, watch } from 'vue' import { computed, ref, watch } from 'vue'
@@ -38,11 +41,8 @@ const emit = defineEmits<{
use: [template: DocumentTemplate] use: [template: DocumentTemplate]
}>() }>()
/** 预览页签:「文书内容」看原始定稿,「签署预览」看占位符填充与勾选交互化后的样子 */
const activeTab = ref('content')
/** /**
* 签署预览里的试填结果,仅用于预览反馈,不作为签署证据。 * 预览里的试填结果,仅用于预览反馈,不作为签署证据。
* 只在页脚展示一行汇总,所以只留计数与清单,不留整份取值。 * 只在页脚展示一行汇总,所以只留计数与清单,不留整份取值。
*/ */
const filledCount = ref(0) const filledCount = ref(0)
@@ -58,8 +58,7 @@ watch(
() => props.visible, () => props.visible,
(visible) => { (visible) => {
if (visible) { if (visible) {
// 每次打开都回到「文书内容」,并把上一次的试填结果清掉 // 每次打开都把上一次的试填结果清掉
activeTab.value = 'content'
filledCount.value = 0 filledCount.value = 0
totalCount.value = 0 totalCount.value = 0
selectedLabels.value = [] selectedLabels.value = []
@@ -67,7 +66,7 @@ watch(
}, },
) )
/** 状态口径统一走 library.ts:卡片、页签、预览三处必须是同一档 */ /** 状态口径统一走 library.ts:卡片、徽标、预览三处必须是同一档 */
const libraryStatus = computed<DocumentLibraryStatus>(() => const libraryStatus = computed<DocumentLibraryStatus>(() =>
props.template ? readLibraryStatus(props.template) : '草稿', props.template ? readLibraryStatus(props.template) : '草稿',
) )
@@ -91,26 +90,12 @@ const canInitiate = computed(
() => Boolean(props.template?.contentHtml) && canInitiateSigning(libraryStatus.value), () => Boolean(props.template?.contentHtml) && canInitiateSigning(libraryStatus.value),
) )
const tabs = computed(() => [
{ key: 'content', label: '文书内容' },
{
key: 'signing',
label: '签署预览',
disabled: !props.template?.contentHtml,
hint: '按签署时的效果渲染:占位符填充、勾选表交互化、签名域',
},
])
const badges = computed(() => [ const badges = computed(() => [
{ text: '类 Word 版式', tone: 'brand' as const }, { text: '类 Word 版式', tone: 'brand' as const },
{ text: libraryStatus.value, tone: statusTone.value }, { text: libraryStatus.value, tone: statusTone.value },
]) ])
const note = computed(() => const note = `按签署时效果渲染:占位符已用示例患者「${PREVIEW_PATIENT.patientName}」填充,空白处是待补录的下划线、☑ / ☐ 可直接点击确认交互效果(预览不保存结果)。`
activeTab.value === 'signing'
? `按签署时效果渲染:占位符已用示例患者「${PREVIEW_PATIENT.patientName}」填充,空白处是待补录的下划线、☑ / ☐ 可直接点击确认交互效果(预览不保存结果)。`
: '',
)
</script> </script>
<template> <template>
@@ -118,18 +103,14 @@ const note = computed(() =>
:visible="visible && Boolean(template)" :visible="visible && Boolean(template)"
:title="template?.name ?? ''" :title="template?.name ?? ''"
:badges="badges" :badges="badges"
:tabs="tabs"
:active-tab="activeTab"
:mode="activeTab === 'signing' ? 'signing' : 'raw'"
:content-html="template?.contentHtml ?? ''" :content-html="template?.contentHtml ?? ''"
:data="PREVIEW_PATIENT" :data="PREVIEW_PATIENT"
:interactive="activeTab === 'signing'" :interactive="true"
:note="note" :note="note"
:loading="loading" :loading="loading"
:error="error ? '模板内容加载失败,请关闭后重试。' : ''" :error="error ? '模板内容加载失败,请关闭后重试。' : ''"
:empty-text="`「${template?.name ?? ''}」暂无结构化正文`" :empty-text="`「${template?.name ?? ''}」暂无结构化正文`"
@close="emit('close')" @close="emit('close')"
@update:active-tab="activeTab = $event"
@field-change="handleFieldChange" @field-change="handleFieldChange"
@selection-change="selectedLabels = $event" @selection-change="selectedLabels = $event"
> >
@@ -176,7 +157,7 @@ const note = computed(() =>
</template> </template>
<template #footer> <template #footer>
<span v-if="activeTab === 'signing' && totalCount" class="preview-note-footer"> <span v-if="totalCount" class="preview-note-footer">
填写项 {{ filledCount }} / {{ totalCount }} 项已填<template v-if="selectedLabels.length" 填写项 {{ filledCount }} / {{ totalCount }} 项已填<template v-if="selectedLabels.length"
>・已勾选 {{ selectedLabels.length }} 项:{{ selectedLabels.join('、') }}</template >・已勾选 {{ selectedLabels.length }} 项:{{ selectedLabels.join('、') }}</template
> >