1281 lines
38 KiB
Vue
1281 lines
38 KiB
Vue
<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<{ label: string; kind: SigningFieldKind; title: string }> = [
|
||
{
|
||
label: '签名',
|
||
kind: 'signature',
|
||
title: '插入独立签名位置,可改名为患者签名、家属签名或医生签名',
|
||
},
|
||
{
|
||
label: '勾选项',
|
||
kind: 'checkbox',
|
||
title: '签署时由患者逐项勾选;点正文里的胶囊可配置多个选项',
|
||
},
|
||
{
|
||
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 }) => ({
|
||
label,
|
||
title,
|
||
field: findPreset(label) ?? {
|
||
id: `custom:${label}`,
|
||
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, id: allocateCustomFieldId(valueHtml.value, field.label) }
|
||
: 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) {
|
||
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}」至少需要两个选项`, '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) {
|
||
setMessage(`正文里已经没有「${field.label}」了,请重新选择要修改的填写项`, '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 = ''
|
||
setMessage(`已删除「${field.label}」(正文里 ${changed} 处)`, 'ok')
|
||
}
|
||
|
||
/** 正文字数与填写项个数(原型 deStat) */
|
||
const stats = computed(() => {
|
||
const text = valueHtml.value
|
||
.replace(/<[^>]*>/g, '')
|
||
.replace(/ /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) {
|
||
return `「${broken.label}」只配了一个选项,勾选类至少要两个(或删掉选项改成单个勾选框)`
|
||
}
|
||
|
||
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(/ /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>
|