706 lines
18 KiB
Vue
706 lines
18 KiB
Vue
<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(/ /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>
|