Files
xh-medical-sign-web/clinical-web/src/views/management/documents/components/WordEditorDialog.vue
T

1308 lines
39 KiB
Vue
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { computed, nextTick, reactive, ref, watch } from 'vue'
import type { SigningFieldDef, SigningFieldKind } from '@/api/workbench/types'
import {
allocateCustomFieldId,
collectFieldDefs,
convertCheckboxColumnsToFields,
convertInlineBoxesToFields,
SIGNING_FIELD_KIND_LABELS,
SIGNING_FIELD_PRESETS,
upgradeFieldTokensToStructured,
} from '@/utils/signing-fields'
import DocumentPaper from '@/components/document/DocumentPaper.vue'
import SignaturePositionPreview from './SignaturePositionPreview.vue'
import type { SignaturePosition } from '@/api/management/signature-preview'
import EditorWangPane from './editors/EditorWangPane.vue'
import { DOC_TEMPLATE_PERMISSIONS } from '../permissions'
import { PREVIEW_PATIENT } from '../sample-patient'
import type { DocumentContentForm, DocumentTemplate } from '../types'
/**
* 文书正文编辑器(原型 V10.2 的 mdDocEd)。
*
* 只做正文:名称 / 文档类型 / 版本号 / 归属 / 说明全部在「编辑属性」弹窗里维护。
* 「属性与正文分开维护」是刻意的——合成一个大表单后,总会有人在一个弹窗里
* 顺手改另一个弹窗的字段,两边各存一次,最后不知道以哪份为准。
*/
const props = defineProps<{
visible: boolean
/** 要编辑正文的文书;id 必须有值(新增走属性弹窗,创建后再进这里) */
template: DocumentTemplate | null
saving: boolean
}>()
const emit = defineEmits<{
close: []
save: [form: DocumentContentForm]
}>()
/** 编辑正文用编辑权限;后端仍会再校验一次 */
const savePermission = DOC_TEMPLATE_PERMISSIONS.edit
// Word 自维护方案:科室把文书从 Word/WPS 直接粘贴进来(保留表格与基本格式),
// 或上传 .docx 由 mammoth 转换;勾选列在导入时转成结构化占位符。
// 编辑引擎固定为 WangEditor(MIT)。更换引擎只需新增一个实现 EditorPaneHandle 的面板组件,
// 注意把 Word 粘贴的归一化处理重新挂到新引擎的粘贴钩子上(WangEditor 见 EditorWangPane 的 customPaste),
// 并把占位符的元素节点与渲染钩子重新注册一遍(见 utils/signing-field-node)。
interface EditorPaneHandle {
getText: () => string
getHtml: () => string | undefined
insertField: (field: SigningFieldDef) => void
/** 就地改写某个字段的占位符,返回命中的节点数;0 表示正文里已经没有它 */
updateField: (fieldId: string, patch: Partial<Omit<SigningFieldDef, 'id'>>) => number
/** 就地删掉某个字段的占位符,返回命中的节点数 */
removeField: (fieldId: string) => number
}
const valueHtml = ref('<p><br></p>')
const needConfirm = ref(false)
const docxInput = ref<HTMLInputElement | null>(null)
const importing = ref(false)
const paneRef = ref<EditorPaneHandle | null>(null)
/** 顶部提示条:导入结果 / 校验失败都走这里,与原型 doced-msg 同一处 */
const message = reactive({ text: '', tone: '' as '' | 'ok' | 'warn' })
/**
* 插入占位符的两组按钮(原型 doced-tb 第二行)。
*
* 分两组的依据是**取值来源**,不是重要性:
* - HIS 自动填充:签署时按患者与就诊快照自动填,科室只需要把位置放对;
* - 自行填写 / 签名:签名与勾选必须现场采集,没有系统取值来源。
*
* 患者姓名 / 性别 / 年龄 / 就诊号同样保留:正文里写"患者 X 因……"这类句子很常见,
* 那种位置必须有占位符,否则签署时不会回填(系统不会另外在文书上插患者信息)。
*/
const AUTO_FILL_LABELS = ['患者姓名', '性别', '年龄', '就诊号', '与患者关系', '签署日期']
/**
* 自行填写 / 签名按钮。三项的取值都来自现场采集,没有系统来源,因此都不查目录、
* 一律走自定义项(custom:…,字段键由 insertField 按正文占用情况当场分配):
* - 签名:插入独立签名位,可重复插入并改名;
* - 勾选项:勾选 / 单选控件,选项在右侧面板里配置;
* - 填写项:自由文本输入框,签署时由患者或家属现场填写。
*
* kind 必须逐项写死:这三项在目录里都查不到,兜底出来的类型不会自己变成想要的那个
* (findPreset 只认目录与签名),一律兜成 checkbox 会让填写项插出来是个勾选框。
*/
const MANUAL_BUTTONS: Array<{
/** 按钮文案(也是 findPreset 的查找键) */
label: string
kind: SigningFieldKind
title: string
/**
* 插入时的默认字段名。
*
* 勾选项刻意给空串:正文里就只显示一个方框,不挂名称——这一处在勾什么由旁边的
* 文字交代(表格的其它列、正文的上一句),自带名称只会把版面挤开。想写名字时
* 在「填写项配置」里填即可(见 applyFieldDraft 对选择类放开名称校验)。
*/
fieldLabel?: string
}> = [
{
label: '签名',
kind: 'signature',
title: '插入独立签名位置,可改名为患者签名、家属签名或医生签名',
},
{
label: '勾选项',
kind: 'checkbox',
title: '签署时由患者逐项勾选;默认不带名称,点正文里的胶囊可配名称与多个选项',
fieldLabel: '',
},
{
label: '填写项',
kind: 'text',
title: '签署时由患者或家属现场填写;点正文里的胶囊可改名称与是否必填',
},
]
function findPreset(label: string): SigningFieldDef | null {
if (label === '签名') {
return { id: 'signature', label, kind: 'signature', required: false, options: [] }
}
return SIGNING_FIELD_PRESETS.find((field) => field.label === label) ?? null
}
const autoFillButtons = computed(() =>
AUTO_FILL_LABELS.map((label) => ({
label,
field: findPreset(label) ?? {
id: `custom:${label}`,
label,
kind: 'text' as SigningFieldKind,
required: false,
options: [],
},
})),
)
const manualButtons = computed(() =>
MANUAL_BUTTONS.map(({ label, kind, title, fieldLabel }) => ({
label,
title,
field: findPreset(label) ?? {
id: `custom:${fieldLabel ?? label}`,
label: fieldLabel ?? label,
kind,
required: false,
options: [],
},
})),
)
/** 已插入的填写项:正文里的口径就是这里的口径(collectFieldDefs 走同一份解析) */
const detectedFields = computed(() => collectFieldDefs(valueHtml.value))
/** 正在配置的填写项字段键;空串表示没有选中 */
const selectedFieldId = ref('')
const selectedField = computed(
() => detectedFields.value.find((field) => field.id === selectedFieldId.value) ?? null,
)
/**
* 配置草稿(原型 fpLabel / fpKind / fpReq / fpRisk / fpMulti / fpOptList)。
*
* 与 selectedField 分开保存:面板上改到一半、还没点「更新该项」时,
* 正文(valueHtml)不能跟着变——那会让编辑区每次敲键盘都重排一次。
*/
const fieldDraft = reactive({
label: '',
kind: 'text' as SigningFieldKind,
required: false,
/** 选项逐项编辑:原型是每行一个输入框 + ✕,不是一串顿号分隔的文字 */
options: [] as string[],
multi: false,
risk: '',
pdfPage: '',
pdfX: '',
pdfY: '',
pdfWidth: '',
pdfHeight: '',
pdfCoordinateSystem: 'TOP_LEFT' as 'TOP_LEFT' | 'PDF_BOTTOM_LEFT',
})
const signaturePreviewHtml = computed(() => {
const document = new DOMParser().parseFromString(valueHtml.value, 'text/html')
// A field converted to “signature” must be locatable before its first coordinates exist.
for (const node of document.querySelectorAll('[data-field]')) {
if (
node.getAttribute('data-field') === selectedFieldId.value &&
fieldDraft.kind === 'signature'
)
node.setAttribute('data-kind', 'signature')
}
return document.documentElement.outerHTML
})
const signaturePositionEmpty = computed(() =>
[
fieldDraft.pdfPage,
fieldDraft.pdfX,
fieldDraft.pdfY,
fieldDraft.pdfWidth,
fieldDraft.pdfHeight,
].every((value) => !value.trim()),
)
const previewPosition = computed<SignaturePosition>(() => ({
page: Number(fieldDraft.pdfPage),
x: Number(fieldDraft.pdfX),
y: Number(fieldDraft.pdfY),
width: Number(fieldDraft.pdfWidth),
height: Number(fieldDraft.pdfHeight),
coordinateSystem: fieldDraft.pdfCoordinateSystem,
}))
function changePosition(position: SignaturePosition) {
fieldDraft.pdfPage = String(position.page)
fieldDraft.pdfX = String(position.x)
fieldDraft.pdfY = String(position.y)
fieldDraft.pdfWidth = String(position.width)
fieldDraft.pdfHeight = String(position.height)
fieldDraft.pdfCoordinateSystem = position.coordinateSystem
// Keep this position when switching to another field; other draft settings still require “更新该项”.
if (selectedField.value)
paneRef.value?.updateField(selectedField.value.id, {
kind: fieldDraft.kind,
pdfPosition: position,
})
}
/** 类型下拉的取值(原型 fpKind 的 5 个选项) */
const KIND_OPTIONS = Object.entries(SIGNING_FIELD_KIND_LABELS) as Array<[SigningFieldKind, string]>
/** 选项行与高危值行只在选项类出现(原型 docFpKind) */
const isOptionKind = computed(() => fieldDraft.kind === 'choice' || fieldDraft.kind === 'checkbox')
function syncDraft(field: SigningFieldDef | null) {
fieldDraft.label = field?.label ?? ''
fieldDraft.kind = field?.kind ?? 'text'
fieldDraft.required = field?.required ?? false
fieldDraft.options = [...(field?.options ?? [])]
// 缺省口径与 signing-fields 的 SigningFieldDef.multi 一致:勾选类默认可多选
fieldDraft.multi = field?.multi ?? field?.kind === 'checkbox'
fieldDraft.risk = field?.risk ?? ''
fieldDraft.pdfPage = field?.pdfPosition ? String(field.pdfPosition.page) : ''
fieldDraft.pdfX = field?.pdfPosition ? String(field.pdfPosition.x) : ''
fieldDraft.pdfY = field?.pdfPosition ? String(field.pdfPosition.y) : ''
fieldDraft.pdfWidth = field?.pdfPosition ? String(field.pdfPosition.width) : ''
fieldDraft.pdfHeight = field?.pdfPosition ? String(field.pdfPosition.height) : ''
fieldDraft.pdfCoordinateSystem = field?.pdfPosition?.coordinateSystem ?? 'TOP_LEFT'
}
/**
* 只在"选中项变了"时对齐草稿。不要 watch selectedField —— 它是 computed,
* 正文每敲一个字都会重算并换掉对象引用,那样会把面板上没提交的编辑冲掉。
*/
watch(
() => selectedFieldId.value,
(fieldId) => {
syncDraft(detectedFields.value.find((field) => field.id === fieldId) ?? null)
},
{ immediate: true },
)
// 类型切换时把选项补齐:原型 docFieldClick 对没配选项的 choice 默认给「否 / 是」,
// 否则科室切到「选择」后面对的是一组空选项,还要自己想有哪些值
watch(
() => fieldDraft.kind,
(kind) => {
if (
(kind === 'choice' || kind === 'checkbox') &&
!fieldDraft.options.length &&
kind === 'choice'
) {
fieldDraft.options = ['否', '是']
}
},
)
watch(
() => props.visible,
(visible) => {
if (!visible) {
return
}
const rawContent = props.template?.contentHtml || '<p><br></p>'
// 存量正文里的占位符是纯文本 {患者姓名},删一个要按五六次退格。
// 打开时统一升级成结构化占位符(原子节点、一次退格整块删除)。
// 升级沿用同一套解析口径,字段键前后完全一致,历史 fieldAnswers 不受影响;
// 正文里没有 { 时这一步原样返回,不会白白做一次 DOM 往返。
//
// 再把手打的方框(□)也换成结构化勾选项:这一步会改写正文,但存量模板里的 □
// 在签署渲染期本来就会被 convertInlineOptions 转成勾选控件,换成占位符只是把
// 同一件事提前到编辑期做,让科室在保存前就能核对、能改名称。
valueHtml.value = convertInlineBoxesToFields(upgradeFieldTokensToStructured(rawContent))
needConfirm.value = Boolean(props.template?.needConfirm)
message.text = ''
message.tone = ''
selectedFieldId.value = ''
},
{ immediate: true },
)
function setMessage(text: string, tone: '' | 'ok' | 'warn' = '') {
message.text = text
message.tone = tone
}
/** 点正文里的胶囊:直接选中并刷新草稿(同一个胶囊连点两次也应把草稿复原) */
function selectFieldFromEditor(field: SigningFieldDef) {
selectedFieldId.value = field.id
syncDraft(field)
}
function insertField(field: SigningFieldDef) {
// 自定义文本项每插一个就是独立填写项,字段键必须当场取一个没被占用的
// (见 allocateCustomFieldId)——两个文本框是两处要填的内容,共用一个键才是错的
const target =
field.kind === 'signature' && detectedFields.value.some((item) => item.id === field.id)
? {
...field,
id: `sign_${globalThis.crypto?.randomUUID?.() ?? `${Date.now().toString(36)}_${Math.random().toString(36).slice(2)}`}`,
required: false,
}
: field.id.startsWith('custom:')
? {
...field,
// 空名称(勾选项默认无名)也要有一个可读、唯一的字段键做身份:用类型名兜底,
// 避免落成 `custom:` 这种空键——读回时容易被当成名称,也说不清是哪一项。
id: allocateCustomFieldId(valueHtml.value, field.label || field.kind),
}
: field
paneRef.value?.insertField(target)
}
function addOption() {
fieldDraft.options.push('')
}
function removeOption(index: number) {
fieldDraft.options.splice(index, 1)
}
function applyFieldDraft() {
const field = selectedField.value
if (!field) {
return false
}
const label = fieldDraft.label.trim()
// 勾选 / 选择类允许无名(正文里只显示一个方框);文本、日期、签名仍必须有名,
// 否则签署端根本认不出这一项是什么。
if (!label && !isOptionKind.value) {
setMessage('请填写填写项名称', 'warn')
return false
}
const options = fieldDraft.options.map((option) => option.trim()).filter(Boolean)
let pdfPosition: SigningFieldDef['pdfPosition']
if (fieldDraft.kind === 'signature') {
const page = Number(fieldDraft.pdfPage)
const x = Number(fieldDraft.pdfX)
const y = Number(fieldDraft.pdfY)
const width = Number(fieldDraft.pdfWidth)
const height = Number(fieldDraft.pdfHeight)
if (
!fieldDraft.pdfPage.trim() ||
!fieldDraft.pdfX.trim() ||
!fieldDraft.pdfY.trim() ||
!fieldDraft.pdfWidth.trim() ||
!fieldDraft.pdfHeight.trim() ||
![page, x, y, width, height].every(Number.isFinite) ||
!Number.isInteger(page) ||
page < 1 ||
x < 0 ||
y < 0 ||
width <= 0 ||
height <= 0
) {
setMessage('签名位置须填写有效页码及坐标', 'warn')
return false
}
pdfPosition = { page, x, y, width, height, coordinateSystem: fieldDraft.pdfCoordinateSystem }
}
if (isOptionKind.value && new Set(options).size !== options.length) {
setMessage('存在重复选项,请修改后重试', 'warn')
return false
}
if (fieldDraft.kind === 'choice' && options.length < 2) {
setMessage(
`选择项「${label || SIGNING_FIELD_KIND_LABELS[fieldDraft.kind]}」至少需要两个选项`,
'warn',
)
return false
}
// 字段键(field.id)刻意保持不变:id 是取值的归属,改叫法不该让历史取值对不上。
// 自定义文本项也一样——它的键里虽然带着旧名字,但那只是个内部标识。
//
// 改的是**编辑器模型**而不是 valueHtml 字符串:字符串往返要经过 setHtml,
// 会重建整篇文档——用户此刻的光标还停在正文里,旧选区在新 DOM 上找不到位置就抛异常,
// 而且光标也会丢。改模型两个问题都没有。
const changed = paneRef.value?.updateField(field.id, {
label,
kind: fieldDraft.kind,
required: fieldDraft.required,
options,
multi: options.length ? fieldDraft.multi : undefined,
risk: fieldDraft.risk.trim() || undefined,
pdfPosition,
})
if (!changed) {
const name = field.label || SIGNING_FIELD_KIND_LABELS[field.kind]
setMessage(`正文里已经没有「${name}」了,请重新选择要修改的填写项`, 'warn')
selectedFieldId.value = ''
return false
}
setMessage(
`填写项已更新(正文里 ${changed} 处)` +
(options.length
? `(${options.length} 个选项 · ${fieldDraft.multi ? '多选' : '单选'})`
: ''),
'ok',
)
return true
}
function removeField() {
const field = selectedField.value
if (!field) {
return
}
// 整项作废:正文里它的每一处出现都删掉(签名位在每页都出现是常态)
const changed = paneRef.value?.removeField(field.id) ?? 0
selectedFieldId.value = ''
const name = field.label || SIGNING_FIELD_KIND_LABELS[field.kind]
setMessage(`已删除「${name}」(正文里 ${changed} 处)`, 'ok')
}
/** 正文字数与填写项个数(原型 deStat) */
const stats = computed(() => {
const text = valueHtml.value
.replace(/<[^>]*>/g, '')
.replace(/&nbsp;/g, '')
.replace(/\s/g, '')
return { chars: text.length, fields: detectedFields.value.length }
})
/**
* 保存前的校验(原型 docValidate 的口径)。
* 只挡"确定是错的":选项类少于两个选项、同名占位符写重复。
* 不挡"没有签名位"这类业务判断——那属于提示,不该拦住保存。
*/
function validate(requireSignaturePosition = true): string | null {
const broken = detectedFields.value.find(
(field) => (field.kind === 'choice' || field.kind === 'checkbox') && field.options.length === 1,
)
if (broken) {
const name = broken.label || SIGNING_FIELD_KIND_LABELS[broken.kind]
return `「${name}」只配了一个选项,勾选类至少要两个(或删掉选项改成单个勾选框)`
}
const signatures = detectedFields.value.filter((field) => field.kind === 'signature')
if (
requireSignaturePosition &&
(!signatures.length || signatures.some((field) => !field.pdfPosition))
) {
return '请为每个签名占位符识别并保存 PDF 签名位置'
}
return null
}
/** 保存前提醒:文书中没有签名位 / 签署日期时给一句提示,不拦保存 */
const saveHints = computed(() => {
const ids = new Set(detectedFields.value.map((field) => field.id))
const hints: string[] = []
if (!detectedFields.value.some((field) => field.kind === 'signature')) {
hints.push('文书中没有签名占位符,签署后签名无处落位,建议插入「签名」')
}
if (!ids.has('signedDate')) {
hints.push('文书中没有签署日期占位符,建议插入「签署日期」以便留痕')
}
return hints
})
/** 签署预览:把正文按签署时效果渲染一遍(原型 docTogglePreview) */
const previewing = ref(false)
function togglePreview() {
if (!previewing.value) {
const error = validate(false)
if (error) {
setMessage(error, 'warn')
return
}
setMessage('签署预览:以示例患者信息回填填写项;实际签署时按真实患者信息与签名自动填充。', 'ok')
previewing.value = true
return
}
previewing.value = false
setMessage('')
}
async function handleDocxChange(event: Event) {
const input = event.target as HTMLInputElement
const file = input.files?.[0]
if (!file) {
return
}
importing.value = true
setMessage('')
try {
const arrayBuffer = await file.arrayBuffer()
const mammoth = await import('mammoth/mammoth.browser')
const result = await mammoth.convertToHtml({ arrayBuffer })
// 表头含「勾选」的表格在导入时就转成结构化占位符(原型 docCheckboxize 的口径),
// 这样科室在保存前就能点开核对每一项勾选,而不是留一张看不出要勾哪儿的空表格。
// 再扫一遍手打的方框(表头不叫「勾选」、或直接写在格子里的 □),同样换成结构化勾选项。
const html = convertInlineBoxesToFields(
convertCheckboxColumnsToFields(result.value || '<p><br></p>'),
)
valueHtml.value = html
previewing.value = false
setMessage(
result.messages.length
? `已导入,${result.messages.length} 条转换提示(部分 Word 特性可能未还原);表格「勾选」列已转为可配置的勾选项`
: '导入成功;表格「勾选」列已转为可配置的勾选项',
result.messages.length ? 'warn' : 'ok',
)
} catch {
setMessage('导入失败:文件无法解析,请确认为 .docx 格式', 'warn')
} finally {
importing.value = false
input.value = ''
}
}
async function save() {
if (!props.template) {
return
}
// 引擎面板句柄可能因初始化时序暂不可用,此时用 valueHtml 兜底判空,避免误报"内容为空"。
const handleText = paneRef.value?.getText() ?? ''
const fallbackText = valueHtml.value
.replace(/<[^>]*>/g, ' ')
.replace(/&nbsp;/g, ' ')
.trim()
const text = handleText || fallbackText
if (!text) {
setMessage('文书内容为空,请粘贴 Word 内容或导入 .docx', 'warn')
return
}
if (selectedField.value?.kind === 'signature') {
if (!applyFieldDraft()) return
await nextTick()
valueHtml.value = paneRef.value?.getHtml() ?? valueHtml.value
}
const error = validate()
if (error) {
setMessage(error, 'warn')
return
}
emit('save', {
id: props.template.id,
contentHtml: valueHtml.value,
needConfirm: needConfirm.value,
})
}
/** 关闭前先退出预览态:预览里正文是只读渲染,直接关掉会让人以为编辑内容没了 */
function requestClose() {
previewing.value = false
emit('close')
}
</script>
<template>
<Teleport to="body">
<div v-if="visible" class="dialog-mask" role="presentation" @click.self="requestClose">
<section
class="word-editor"
role="dialog"
aria-modal="true"
aria-labelledby="word-editor-title"
>
<header class="dialog-header">
<div class="dialog-title">
<h2 id="word-editor-title">编辑 Word 文书</h2>
<span class="mode-badge">编辑正文 · Word 自维护</span>
<span v-if="template" class="doc-name" :title="template.name">{{ template.name }}</span>
</div>
<button type="button" class="close-button" aria-label="关闭编辑" @click="requestClose">
×
</button>
</header>
<div class="dialog-body">
<div class="editor-actions">
<button
type="button"
class="action-button"
:disabled="importing"
@click="docxInput?.click()"
>
📄 {{ importing ? '正在导入…' : '导入 .docx 文件' }}
</button>
<input
ref="docxInput"
type="file"
accept=".docx"
class="docx-input"
aria-label="选择 docx 文件"
@change="handleDocxChange"
/>
<button type="button" class="action-button" @click="togglePreview">
{{ previewing ? '✏️ 返回编辑' : '👁 签署预览' }}
</button>
<span class="editor-stats"
>正文 {{ stats.chars }} 字 · {{ stats.fields }} 个填写项</span
>
<label class="confirm-setting">
<input v-model="needConfirm" type="checkbox" />
患者签署后需科室确认
</label>
<span class="editor-tip">
勾选项支持配置多个选项(可多选);导入 Word 后表格「勾选」列自动转为交互勾选项
</span>
</div>
<p
v-if="message.text"
class="editor-message"
:class="message.tone ? `is-${message.tone}` : ''"
>
{{ message.text }}
</p>
<p v-else-if="saveHints.length" class="editor-message is-warn">
{{ saveHints.join(';') }}
</p>
<!-- 填写项设置(原型 deFieldPanel):点正文里的占位符胶囊打开 -->
<div v-if="selectedField && !previewing" class="field-panel">
<div class="fp-row">
<b class="fp-title">填写项设置</b>
<label class="fp-field">
<span>名称</span>
<input v-model="fieldDraft.label" type="text" maxlength="60" />
</label>
<label class="fp-field">
<span>类型</span>
<select v-model="fieldDraft.kind">
<option v-for="[kind, text] in KIND_OPTIONS" :key="kind" :value="kind">
{{ text }}
</option>
</select>
</label>
<label class="fp-field">
<span>必填</span>
<select v-model="fieldDraft.required">
<option :value="true">是</option>
<option :value="false">否</option>
</select>
</label>
<label v-if="isOptionKind" class="fp-field">
<span>高危值(异常提醒)</span>
<input v-model="fieldDraft.risk" type="text" maxlength="20" placeholder="如 否" />
</label>
<span class="fp-spacer" />
<button type="button" class="fp-button ghost" @click="removeField">删除该项</button>
<button type="button" class="fp-button primary" @click="applyFieldDraft">
更新该项
</button>
</div>
<div v-if="isOptionKind" class="fp-row">
<b class="fp-title">选项设置(支持多个)</b>
<span class="fp-hint">选项逐项展示为可勾选框;勾选类默认可多选,选择类默认单选</span>
<span class="fp-spacer" />
<label class="fp-field">
<span>选择方式</span>
<select v-model="fieldDraft.multi">
<option :value="true">多选(可勾多个)</option>
<option :value="false">单选(互斥)</option>
</select>
</label>
</div>
<div v-if="fieldDraft.kind === 'signature'" class="fp-row signature-position-row">
<b class="fp-title">签名位置</b>
<span class="fp-hint">PDF point;页码从 1 开始</span>
<label class="fp-field"
><span>页码</span><input v-model="fieldDraft.pdfPage" inputmode="numeric"
/></label>
<label class="fp-field"
><span>X</span><input v-model="fieldDraft.pdfX" inputmode="decimal"
/></label>
<label class="fp-field"
><span>Y</span><input v-model="fieldDraft.pdfY" inputmode="decimal"
/></label>
<label class="fp-field"
><span>宽</span><input v-model="fieldDraft.pdfWidth" inputmode="decimal"
/></label>
<label class="fp-field"
><span>高</span><input v-model="fieldDraft.pdfHeight" inputmode="decimal"
/></label>
<label class="fp-field">
<span>坐标系</span>
<select v-model="fieldDraft.pdfCoordinateSystem">
<option value="TOP_LEFT">左上为原点</option>
<option value="PDF_BOTTOM_LEFT">左下为原点</option>
</select>
</label>
</div>
<SignaturePositionPreview
v-if="template && fieldDraft.kind === 'signature'"
:key="`${template.id}:${selectedField.id}`"
:field-id="selectedField.id"
:template-id="template.id"
:content-html="signaturePreviewHtml"
:position="previewPosition"
:position-empty="signaturePositionEmpty"
@change="changePosition"
/>
<div v-if="isOptionKind" class="fp-row">
<span v-for="(_, index) in fieldDraft.options" :key="index" class="option-item">
<input
v-model="fieldDraft.options[index]"
type="text"
maxlength="40"
placeholder="选项内容"
/>
<button
type="button"
class="option-remove"
title="删除选项"
@click="removeOption(index)"
>
✕
</button>
</span>
<button type="button" class="option-add" @click="addOption">+ 添加选项</button>
</div>
</div>
<!-- 签署预览:纸的渲染走统一组件,这里只给盒子(.preview-paper)。
改版前这里自己抄了一份基础排版,表格边框与工作台那份已经分叉 -->
<div v-if="previewing" class="preview-shell">
<DocumentPaper
class="preview-paper"
:content-html="valueHtml"
:data="PREVIEW_PATIENT"
:interactive="false"
/>
</div>
<div v-else class="editor-shell">
<EditorWangPane
ref="paneRef"
:model-value="valueHtml"
@update:model-value="valueHtml = $event"
@select-field="selectFieldFromEditor"
>
<template #toolbar-extra>
<div class="placeholder-bar">
<span class="pb-label">插入签署占位符:</span>
<span class="pb-group is-auto">HIS 自动填充:</span>
<button
v-for="button in autoFillButtons"
:key="button.label"
type="button"
class="pb-chip is-auto"
:title="`签署时按患者与就诊信息自动填充`"
@click="insertField(button.field)"
>
[{{ button.label }}]
</button>
<span class="pb-group is-manual">自行填写 / 签名:</span>
<button
v-for="button in manualButtons"
:key="button.label"
type="button"
class="pb-chip"
:class="`is-${button.field.kind}`"
:title="button.title"
@click="insertField(button.field)"
>
[{{ button.label }}]
</button>
<span class="pb-note">
签名可重复插入并自定义名称;勾选项与填写项在签署时填写
</span>
</div>
</template>
</EditorWangPane>
</div>
</div>
<footer class="dialog-footer">
<p>点正文里的占位符胶囊即可修改它的名称 / 类型 / 选项;一次退格整块删除。</p>
<button type="button" class="footer-button secondary" @click="requestClose">取消</button>
<button
v-permission.disable="savePermission"
type="button"
class="footer-button primary"
:disabled="saving || previewing"
@click="save"
>
{{ saving ? '保存中…' : '💾 保存正文' }}
</button>
</footer>
</section>
</div>
</Teleport>
</template>
<style scoped>
.dialog-mask {
position: fixed;
z-index: 20;
inset: 0;
display: flex;
padding: 16px;
overflow-y: auto;
background: rgb(9 40 52 / 45%);
}
.word-editor {
display: flex;
flex-direction: column;
width: 1240px;
max-width: 97vw;
max-height: calc(100vh - 32px);
margin: auto;
overflow: hidden;
background: #fff;
border-radius: 14px;
box-shadow: var(--sh-modal);
}
.dialog-header {
display: flex;
flex-shrink: 0;
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;
color: var(--ink);
font-size: 15px;
}
.mode-badge {
padding: 3px 9px;
color: var(--brand);
font-size: 11px;
font-weight: 700;
background: var(--brand-l);
border-radius: 12px;
}
.doc-name {
overflow: hidden;
color: var(--mut);
font-size: 12px;
text-overflow: ellipsis;
white-space: nowrap;
}
.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);
}
/* 弹窗内单一滚动区:头部与底部常驻,中间只有这里滚(别做嵌套滚动条) */
.dialog-body {
display: flex;
flex: 1;
min-height: 0;
flex-direction: column;
padding: 14px 18px;
overflow-y: auto;
}
.editor-actions {
display: flex;
flex-shrink: 0;
flex-wrap: wrap;
gap: 10px;
align-items: center;
margin-bottom: 8px;
}
.action-button {
padding: 5px 11px;
color: var(--ink);
font-size: 12px;
font-weight: 700;
background: #fff;
border: 1px solid var(--line);
border-radius: 6px;
}
.action-button:hover:not(:disabled) {
color: var(--brand);
border-color: var(--brand);
}
.action-button:disabled {
color: #aebbc0;
cursor: not-allowed;
background: #f1f5f6;
}
.docx-input {
display: none;
}
.editor-stats {
color: var(--mut);
font-size: 12px;
}
.editor-tip {
color: #c07f00;
font-size: 11.5px;
}
/* 提示条:导入结果 / 校验失败 / 保存前提醒都走这一处(原型 doced-msg) */
.editor-message {
flex-shrink: 0;
padding: 8px 12px;
margin: 0 0 8px;
color: #8a6116;
font-size: 12.5px;
line-height: 1.7;
background: #fff8e6;
border: 1px solid #f0d9a0;
border-radius: 8px;
}
.editor-message.is-ok {
color: #0b7a4b;
background: #eafaf1;
border-color: #bfe8d2;
}
.editor-message.is-warn {
color: #a04a1a;
background: #fff3ec;
border-color: #f2c4ad;
}
/* ---- 填写项设置面板(原型 .doced-fp) ---- */
.field-panel {
display: flex;
flex-shrink: 0;
flex-wrap: wrap;
gap: 10px;
padding: 8px 12px;
margin-bottom: 8px;
background: #f2f9fc;
border: 1px solid var(--brand-l);
border-radius: 10px;
}
.fp-row {
display: flex;
flex-wrap: wrap;
gap: 10px;
align-items: center;
width: 100%;
}
.fp-row + .fp-row {
padding-top: 8px;
margin-top: 8px;
border-top: 1px dashed var(--line);
}
.fp-title {
color: var(--brand);
font-size: 12px;
}
.fp-hint {
color: var(--mut);
font-size: 12px;
}
.fp-spacer {
flex: 1;
}
.fp-field {
display: flex;
gap: 6px;
align-items: center;
color: var(--mut);
font-size: 12px;
}
.fp-field input,
.fp-field select {
padding: 5px 8px;
color: var(--ink);
font-size: 12.5px;
background: #fff;
border: 1px solid var(--line);
border-radius: 6px;
}
.fp-field input:focus,
.fp-field select:focus {
border-color: var(--brand);
outline: none;
}
.fp-button {
padding: 5px 11px;
font-size: 12px;
font-weight: 700;
border-radius: 6px;
}
.fp-button.ghost {
color: var(--ink);
background: #fff;
border: 1px solid var(--line);
}
.fp-button.ghost:hover {
color: var(--brand);
border-color: var(--brand);
}
.fp-button.primary {
color: #fff;
background: var(--brand);
border: 1px solid var(--brand);
}
.option-item {
display: flex;
gap: 6px;
align-items: center;
}
.option-item input {
width: 170px;
padding: 5px 8px;
color: var(--ink);
font-size: 12.5px;
background: #fff;
border: 1px solid var(--line);
border-radius: 6px;
}
.option-item input:focus {
border-color: var(--brand);
outline: none;
}
.option-remove {
padding: 2px 4px;
color: var(--mut);
font-size: 14px;
background: none;
}
.option-remove:hover {
color: var(--err);
}
.option-add {
padding: 4px 10px;
color: var(--brand);
font-size: 12px;
background: #fff;
border: 1px dashed var(--brand);
border-radius: 6px;
}
.option-add:hover {
background: var(--brand-l);
}
/* ---- 编辑器 ---- */
.editor-shell {
display: flex;
flex: 1;
min-height: 420px;
flex-direction: column;
overflow: hidden;
}
/* 占位符按钮行:视觉上接在 WangEditor 工具栏下面(原型 doced-tb 第二行) */
.placeholder-bar {
display: flex;
flex-wrap: wrap;
gap: 4px;
align-items: center;
padding: 6px 8px;
background: #f6fafc;
border-right: 1px solid var(--line);
border-bottom: 1px solid var(--line);
border-left: 1px solid var(--line);
}
.pb-label {
color: var(--mut);
font-size: 12px;
font-weight: 700;
}
.pb-group {
margin-left: 8px;
font-size: 12px;
}
.pb-group.is-auto {
color: var(--brand);
}
.pb-group.is-manual {
color: #9a5f00;
}
/* 占位符按钮:颜色与正文里胶囊的配色一一对应(蓝=文本、橙=签名、绿=勾选),
点之前就知道会插进一个什么样的胶囊。文本蓝同时用于 HIS 自动填充项与「填写项」:
两者落进正文都是同一个文本胶囊,颜色按控件类型走,不按取值来源分 */
.pb-chip {
padding: 3px 7px;
font-size: 12px;
background: #fff;
border: 1px dashed var(--brand);
border-radius: 5px;
}
.pb-chip.is-auto {
color: var(--brand);
}
.pb-chip.is-signature {
color: #9a5f00;
border-color: #c07f00;
}
.pb-chip.is-checkbox,
.pb-chip.is-choice {
color: #0b7a4b;
border-color: #0f9d6c;
}
.pb-chip.is-text {
color: var(--brand);
}
.pb-chip:hover {
background: var(--brand-l);
}
.pb-note {
margin-left: auto;
color: var(--mut);
font-size: 11.5px;
}
/* ---- 签署预览 ---- */
.preview-shell {
flex: 1;
min-height: 420px;
padding: 18px;
overflow: auto;
background: #eef3f6;
border: 1px solid var(--line);
border-radius: 10px;
}
/* 纸的比例与页边距由 DocumentPaper 的 .doc-paper--a4 给(统一 A4 规格,
见 utils/document-paper.ts),这里只留「桌子」和纸的观感 —— 别再写 max-width/padding,
写了就会和 A4 规格打架(% 页边距以包含块宽度为基准,纸一被收窄就算错)。 */
.preview-paper {
border-radius: var(--r);
box-shadow: var(--sh);
}
/* 基础排版(p / table / th / td / 字体 …)在 components/document/DocumentPaper.vue
的 DOCUMENT_PAPER_CSS 里,全项目唯一一份。这里只留盒子。 */
/* ---- 底部 ---- */
.dialog-footer {
display: flex;
flex-shrink: 0;
gap: 10px;
align-items: center;
padding: 10px 18px;
background: #f8fbfc;
border-top: 1px solid var(--line);
}
.dialog-footer p {
max-width: 58%;
margin: 0;
overflow: hidden;
color: var(--mut);
font-size: 12px;
text-overflow: ellipsis;
white-space: nowrap;
}
.footer-button {
padding: 7px 13px;
font-size: 12px;
font-weight: 700;
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:not(:disabled) {
filter: brightness(0.96);
}
.footer-button:disabled {
color: #aebbc0;
cursor: not-allowed;
background: #f1f5f6;
border-color: #dce5e8;
filter: none;
}
@media (max-width: 900px) {
.dialog-footer p {
display: none;
}
}
</style>