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

706 lines
18 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
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, reactive, ref, watch } from 'vue'
import { ElMessage } from 'element-plus'
import type { CampusRecord, DepartmentRecord } from '@/api/management/types'
import {
buildFieldToken,
collectFieldDefs,
findDuplicateFields,
SIGNING_FIELD_KIND_LABELS,
SIGNING_FIELD_PRESETS,
} from '@/utils/signing-fields'
import EditorWangPane from './editors/EditorWangPane.vue'
import type { DocumentTemplate, TemplateEditorForm } from '../types'
const props = defineProps<{
visible: boolean
/** null 表示新增文书 */
template: DocumentTemplate | null
campuses: CampusRecord[]
departments: DepartmentRecord[]
isMock: boolean
}>()
const emit = defineEmits<{
close: []
save: [form: TemplateEditorForm]
}>()
// Word 自维护方案:科室把文书从 Word/WPS 直接粘贴进来(保留表格与基本格式),
// 或上传 .docx 由 mammoth 转换;签署时由交互管线识别"勾选"表并转为签署域。
// 编辑引擎固定为 WangEditor(MIT)。更换引擎只需新增一个实现 EditorPaneHandle 的面板组件,
// 注意把 Word 粘贴的归一化处理重新挂到新引擎的粘贴钩子上(WangEditor 见 EditorWangPane 的 customPaste)。
interface EditorPaneHandle {
getText: () => string
insertText: (text: string) => void
}
const valueHtml = ref('<p><br></p>')
const docxInput = ref<HTMLInputElement | null>(null)
const importing = ref(false)
const importMessage = ref('')
const paneRef = ref<EditorPaneHandle | null>(null)
// 占位符在签署时按字段键取 HIS 与患者就诊信息自动填充,其中签名是独立控件类型:
// 签字后签名图作为该字段取值落回文书原本的签名位,不需要再替换文字。
// 目录见 utils/signing-fields 的 SIGNING_FIELD_PRESETS,与签署端渲染共用同一份。
const form = reactive<TemplateEditorForm>({
templateCode: '',
name: '',
description: '',
campusId: props.campuses[0]?.id ?? '',
departmentId: '',
category: '知情同意书',
versionNo: 'v1',
contentHtml: '',
})
const availableDepartments = ref<DepartmentRecord[]>([])
function syncDepartmentOptions() {
availableDepartments.value = form.campusId
? props.departments.filter((department) => department.campusId === form.campusId)
: [...props.departments]
}
watch(
() => props.visible,
(visible) => {
if (!visible) {
return
}
const template = props.template
Object.assign(form, {
id: template?.id,
templateCode: template?.code ?? '',
name: template?.name ?? '',
description: template?.description ?? '',
campusId: template?.campusId ?? props.campuses[0]?.id ?? '',
departmentId: template?.departmentId ?? '',
category: template?.category ?? '知情同意书',
versionNo: template?.version ?? 'v1',
contentHtml: template?.contentHtml ?? '',
})
valueHtml.value = template?.contentHtml || '<p><br></p>'
syncDepartmentOptions()
importMessage.value = ''
},
{ immediate: true },
)
function handleCampusChange() {
if (!availableDepartments.value.some((department) => department.id === form.departmentId)) {
form.departmentId = ''
}
}
// 勾选框与占位符走同一个 EditorPaneHandle.insertText:光标停在表格单元格里,
// 插入的内容就落在这个单元格,签署时由渲染管线转为可交互选项。
const choiceSnippets = [
{ label: '勾选框 □', text: '□', title: '在光标处插入一个勾选框' },
{ label: '二选一 □是 □否', text: '□是 □否', title: '插入一对互斥选项,可自行改文字' },
]
function insertContent(text: string) {
paneRef.value?.insertText(text)
}
function insertField(field: (typeof SIGNING_FIELD_PRESETS)[number]) {
insertContent(buildFieldToken(field))
}
/** 已插入的填写项:让科室在保存前确认签名位、日期位有没有漏 */
const detectedFields = computed(() => collectFieldDefs(valueHtml.value))
const placeholderHints = computed(() => {
const hints: string[] = []
const ids = new Set(detectedFields.value.map((field) => field.id))
if (!ids.has('signature')) {
hints.push('文书中没有签名占位符,签署后签名无处落位,建议插入「患者签名」。')
}
if (!ids.has('signedDate')) {
hints.push('文书中没有签署日期占位符,建议插入「签署日期」以便留痕。')
}
const duplicates = findDuplicateFields(valueHtml.value)
if (duplicates.length) {
hints.push(
`以下占位符出现多次:${duplicates.map((field) => field.label).join('、')}(同一字段共用一份取值)。`,
)
}
return hints
})
// 类型中文名与编辑器胶囊、签署端提示共用一份(utils/signing-fields)
const kindLabels = SIGNING_FIELD_KIND_LABELS
async function handleDocxChange(event: Event) {
const input = event.target as HTMLInputElement
const file = input.files?.[0]
if (!file) {
return
}
importing.value = true
importMessage.value = ''
try {
const arrayBuffer = await file.arrayBuffer()
const mammoth = await import('mammoth/mammoth.browser')
const result = await mammoth.convertToHtml({ arrayBuffer })
valueHtml.value = result.value || '<p><br></p>'
importMessage.value = result.messages.length
? `已导入,${result.messages.length} 条转换提示(部分 Word 特性可能未还原)`
: '导入成功'
} catch {
importMessage.value = '导入失败:文件无法解析,请确认为 .docx 格式'
} finally {
importing.value = false
input.value = ''
}
}
function save() {
// 引擎面板句柄可能因初始化时序暂不可用,此时用 valueHtml 兜底判空,避免误报"内容为空"。
const handleText = paneRef.value?.getText() ?? ''
const fallbackText = valueHtml.value
.replace(/<[^>]*>/g, ' ')
.replace(/&nbsp;/g, ' ')
.trim()
const text = handleText || fallbackText
if (!form.name.trim()) {
importMessage.value = '请填写文书名称'
ElMessage.error('请填写文书名称')
return
}
if (!text) {
importMessage.value = '文书内容为空,请粘贴 Word 内容或导入 .docx'
ElMessage.error('文书内容为空,请粘贴 Word 内容或导入 .docx')
return
}
form.contentHtml = valueHtml.value
emit('save', { ...form, contentHtml: valueHtml.value })
}
</script>
<template>
<Teleport to="body">
<div v-if="visible" class="dialog-mask" role="presentation" @click.self="emit('close')">
<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">{{ template ? '编辑 Word 文书' : '新增 Word 文书' }}</h2>
<span class="mode-badge">Word 自维护</span>
</div>
<button type="button" class="close-button" aria-label="关闭编辑" @click="emit('close')">
×
</button>
</header>
<div class="meta-grid">
<label class="meta-field">
<span>文书名称 *</span>
<input v-model="form.name" type="text" placeholder="如:XXX 科知情同意书" />
</label>
<label class="meta-field">
<span>文书编号</span>
<input
v-model="form.templateCode"
type="text"
placeholder="如 TPL-XXX-001"
:disabled="Boolean(template)"
:title="template ? '文书编号创建后不可修改' : undefined"
/>
</label>
<label class="meta-field">
<span>所属院区</span>
<select
v-model="form.campusId"
:disabled="Boolean(template)"
@change="handleCampusChange"
>
<option v-for="campus in campuses" :key="campus.id" :value="campus.id">
{{ campus.name }}
</option>
</select>
</label>
<label class="meta-field">
<span>所属科室</span>
<select v-model="form.departmentId" :disabled="Boolean(template)">
<option value="">全院通用</option>
<option
v-for="department in availableDepartments"
:key="department.id"
:value="department.id"
>
{{ department.name }}
</option>
</select>
</label>
<label class="meta-field">
<span>分类</span>
<input v-model="form.category" type="text" />
</label>
<label class="meta-field">
<span>版本号</span>
<input v-if="isMock" v-model="form.versionNo" type="text" />
<span v-else class="auto-version">由系统按 vN 自动生成</span>
</label>
</div>
<div class="meta-row">
<label class="meta-field grow">
<span>文书说明</span>
<input v-model="form.description" type="text" placeholder="显示在文书卡片上的简介" />
</label>
<div class="meta-field">
<span>插入签署占位符</span>
<div class="placeholder-row">
<button
v-for="field in SIGNING_FIELD_PRESETS"
:key="field.id"
type="button"
class="placeholder-chip"
:class="{ 'is-signature': field.kind === 'signature' }"
:title="`${kindLabels[field.kind]}字段;签署时按患者与就诊信息自动填充${field.required ? ',必填' : ''}`"
@click="insertField(field)"
>
{{ field.label }}{{ field.required ? ' *' : '' }}
</button>
</div>
</div>
<div class="meta-field">
<span>插入勾选框</span>
<div class="placeholder-row">
<button
v-for="snippet in choiceSnippets"
:key="snippet.label"
type="button"
class="placeholder-chip"
:title="snippet.title"
@click="insertContent(snippet.text)"
>
{{ snippet.label }}
</button>
</div>
</div>
</div>
<div class="placeholder-check">
<div class="placeholder-check__head">
<strong>占位符检查</strong>
<span>{{ detectedFields.length }} 个填写项</span>
</div>
<div v-if="detectedFields.length" class="placeholder-check__list">
<span v-for="field in detectedFields" :key="field.id" class="placeholder-check__item">
{{ field.label }}
<em>{{ kindLabels[field.kind] }}</em>
<i v-if="field.required">必填</i>
</span>
</div>
<p v-else class="placeholder-check__empty">
文书中还没有任何填写项,签署时不会采集患者填写内容。
</p>
<ul v-if="placeholderHints.length" class="placeholder-check__hints">
<li v-for="hint in placeholderHints" :key="hint">{{ hint }}</li>
</ul>
</div>
<div class="editor-shell">
<div class="docx-bar">
<button
type="button"
class="docx-button"
:disabled="importing"
@click="docxInput?.click()"
>
{{ importing ? '正在导入…' : '导入 .docx 文件' }}
</button>
<input
ref="docxInput"
type="file"
accept=".docx"
class="docx-input"
aria-label="选择 docx 文件"
@change="handleDocxChange"
/>
<span v-if="importMessage" class="docx-message">{{ importMessage }}</span>
<span v-else class="docx-hint">
建议从 Word/WPS
中全选复制文书后直接粘贴到下方编辑区;需要患者勾选的内容可点上方「插入勾选框」按钮插入(光标停在表格单元格里就插到该单元格),或用表头含「勾选」列的表格承载。
占位符与勾选框在编辑区会显示成带类型标签的胶囊,便于和正文区分。
</span>
</div>
<p class="docx-header-note">
患者姓名 / 性别 / 年龄 / 住院号·门诊号由系统在页眉自动带出(签署时按 HIS
数据填充),正文不必再写这几项; 导入 .docx 时 Word 自带的页眉 /
页脚不会被转换,请勿依赖它承载患者信息。
</p>
<div class="editor-area">
<EditorWangPane
ref="paneRef"
:model-value="valueHtml"
@update:model-value="valueHtml = $event"
/>
</div>
</div>
<footer class="dialog-footer">
<p>保存后进入文书库;签署时自动填充占位符,"勾选"表转为交互勾选。</p>
<button type="button" class="footer-button secondary" @click="emit('close')">取消</button>
<button type="button" class="footer-button primary" @click="save">保存文书</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: 1080px;
max-width: 96vw;
max-height: calc(100vh - 32px);
margin: auto;
overflow: hidden;
background: #fff;
border-radius: 14px;
box-shadow: var(--sh-modal);
}
.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;
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;
}
.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);
}
.meta-grid {
display: grid;
grid-template-columns: repeat(6, minmax(0, 1fr));
gap: 10px;
padding: 12px 18px 0;
}
.meta-row {
display: flex;
gap: 14px;
align-items: flex-end;
padding: 10px 18px 0;
}
.meta-field {
display: block;
min-width: 0;
}
.meta-field.grow {
flex: 1;
}
.meta-field > span {
display: block;
margin-bottom: 4px;
color: var(--ink);
font-size: 11.5px;
font-weight: 700;
}
.meta-field input,
.meta-field select {
width: 100%;
padding: 6px 8px;
color: var(--ink);
font-size: 12.5px;
border: 1px solid var(--line);
border-radius: 6px;
box-sizing: border-box;
}
.meta-field input:focus,
.meta-field select:focus {
border-color: var(--brand);
outline: none;
}
.placeholder-row {
display: flex;
flex-wrap: wrap;
gap: 4px;
max-width: 420px;
}
.placeholder-chip {
padding: 3px 7px;
color: var(--brand);
font-size: 11px;
background: var(--brand-l);
border: none;
border-radius: 10px;
}
.placeholder-chip:hover {
filter: brightness(0.95);
}
/* 签名类占位符用不同颜色区分:它决定签名落位,是文书里最不能漏的一项 */
.placeholder-chip.is-signature {
color: #a96900;
background: #fff4e2;
}
.placeholder-check {
padding: 10px 18px 0;
}
.placeholder-check__head {
display: flex;
gap: 10px;
align-items: center;
margin-bottom: 7px;
}
.placeholder-check__head strong {
color: var(--ink);
font-size: 11.5px;
}
.placeholder-check__head span {
color: var(--mut);
font-size: 11px;
}
.placeholder-check__list {
display: flex;
flex-wrap: wrap;
gap: 5px;
}
.placeholder-check__item {
display: inline-flex;
gap: 5px;
align-items: center;
padding: 2px 8px;
color: var(--ink);
font-size: 11.5px;
background: #f4f8fa;
border: 1px solid var(--line);
border-radius: 12px;
}
.placeholder-check__item em {
color: var(--brand);
font-size: 10.5px;
font-style: normal;
}
.placeholder-check__item i {
color: var(--err);
font-size: 10.5px;
font-style: normal;
}
.placeholder-check__empty {
margin: 0;
color: var(--mut);
font-size: 11.5px;
}
.placeholder-check__hints {
padding-left: 16px;
margin: 8px 0 0;
color: var(--warn);
font-size: 11.5px;
line-height: 1.7;
}
.editor-shell {
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
padding: 12px 18px;
}
.docx-bar {
display: flex;
flex-wrap: wrap;
gap: 10px;
align-items: center;
margin-bottom: 8px;
}
.docx-button {
padding: 6px 12px;
color: var(--brand);
font-size: 12px;
font-weight: 700;
background: #fff;
border: 1px solid var(--brand);
border-radius: 6px;
}
.docx-button:hover {
background: var(--brand-l);
}
.docx-button:disabled {
color: #aebbc0;
background: #f1f5f6;
border-color: #dce5e8;
cursor: not-allowed;
}
.docx-input {
display: none;
}
.docx-message {
color: var(--warn);
font-size: 12px;
}
.docx-hint {
color: var(--mut);
font-size: 12px;
}
/* 患者基本信息由系统页眉承载、且 Word 页眉不会被 mammoth 转换,这条提示常驻在编辑区上方 */
.docx-header-note {
padding: 6px 9px;
margin: 0 0 8px;
color: var(--warn);
font-size: 11.5px;
line-height: 1.7;
background: var(--warn-l);
border-radius: 6px;
}
.editor-area {
display: flex;
flex-direction: column;
flex: 1;
min-height: 420px;
overflow: hidden;
}
.dialog-footer {
display: flex;
gap: 10px;
align-items: center;
padding: 10px 18px;
background: #f8fbfc;
border-top: 1px solid var(--line);
}
.dialog-footer p {
max-width: 52%;
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 {
filter: brightness(0.96);
}
@media (max-width: 900px) {
.meta-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.meta-row {
flex-direction: column;
align-items: stretch;
}
}
</style>