626 lines
15 KiB
Vue
626 lines
15 KiB
Vue
<script setup lang="ts">
|
||
import { reactive, ref, watch } from 'vue'
|
||
import { ElMessage } from 'element-plus'
|
||
|
||
import type { CampusRecord, DepartmentRecord } from '@/api/management/types'
|
||
|
||
import EditorCkPane from './editors/EditorCkPane.vue'
|
||
import EditorTipTapPane from './editors/EditorTipTapPane.vue'
|
||
import EditorTinyPane from './editors/EditorTinyPane.vue'
|
||
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[]
|
||
}>()
|
||
|
||
const emit = defineEmits<{
|
||
close: []
|
||
save: [form: TemplateEditorForm]
|
||
}>()
|
||
|
||
// Word 自维护方案:科室把文书从 Word/WPS 直接粘贴进来(保留表格与基本格式),
|
||
// 或上传 .docx 由 mammoth 转换;签署时由交互管线识别"勾选"表并转为签署域。
|
||
// 富文本引擎做成可切换的三套(对比 Word 粘贴保真度),存储统一为编辑器产出的 HTML。
|
||
type EditorEngine = 'wangeditor' | 'ckeditor5' | 'tinymce' | 'tiptap'
|
||
|
||
interface EditorPaneHandle {
|
||
getText: () => string
|
||
insertText: (text: string) => void
|
||
}
|
||
|
||
const engineComponents = {
|
||
wangeditor: EditorWangPane,
|
||
ckeditor5: EditorCkPane,
|
||
tinymce: EditorTinyPane,
|
||
tiptap: EditorTipTapPane,
|
||
}
|
||
|
||
const engineOptions: Array<{ key: EditorEngine; label: string; note: string }> = [
|
||
{
|
||
key: 'wangeditor',
|
||
label: 'WangEditor',
|
||
note: '已接管粘贴,尽量保留内联样式(MIT)',
|
||
},
|
||
{
|
||
key: 'ckeditor5',
|
||
label: 'CKEditor 5',
|
||
note: 'PasteFromOffice 插件,Word 粘贴保真最强(GPL2)',
|
||
},
|
||
{
|
||
key: 'tinymce',
|
||
label: 'TinyMCE',
|
||
note: '均衡之选(MIT)',
|
||
},
|
||
{
|
||
key: 'tiptap',
|
||
label: 'TipTap',
|
||
note: 'ProseMirror 架构,可自定义签署域节点;粘贴走 schema,样式有损(MIT)',
|
||
},
|
||
]
|
||
|
||
const engine = ref<EditorEngine>('wangeditor')
|
||
const engineNote = ref(engineOptions[0]?.note ?? '')
|
||
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)
|
||
|
||
// 占位符在签署时由系统按患者与就诊信息自动填充;签署交互管线同时会识别"勾选"表格。
|
||
const placeholders = [
|
||
'{患者姓名}',
|
||
'{性别}',
|
||
'{年龄}',
|
||
'{就诊号}',
|
||
'{签署日期}',
|
||
'{患者签名}',
|
||
'{与患者关系}',
|
||
]
|
||
|
||
const form = reactive<TemplateEditorForm>({
|
||
templateCode: '',
|
||
name: '',
|
||
description: '',
|
||
campusId: props.campuses[0]?.id ?? '',
|
||
departmentId: '',
|
||
category: '知情同意书',
|
||
versionNo: 'v1.0.0',
|
||
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.0.0',
|
||
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 = ''
|
||
}
|
||
}
|
||
|
||
function selectEngine(next: EditorEngine) {
|
||
engine.value = next
|
||
engineNote.value = engineOptions.find((option) => option.key === next)?.note ?? ''
|
||
}
|
||
|
||
function insertPlaceholder(text: string) {
|
||
paneRef.value?.insertText(text)
|
||
}
|
||
|
||
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" />
|
||
</label>
|
||
<label class="meta-field">
|
||
<span>所属院区</span>
|
||
<select v-model="form.campusId" @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">
|
||
<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-model="form.versionNo" type="text" />
|
||
</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="item in placeholders"
|
||
:key="item"
|
||
type="button"
|
||
class="placeholder-chip"
|
||
@click="insertPlaceholder(item)"
|
||
>
|
||
{{ item }}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="editor-shell">
|
||
<div class="engine-bar">
|
||
<span class="engine-label">编辑器引擎</span>
|
||
<button
|
||
v-for="option in engineOptions"
|
||
:key="option.key"
|
||
type="button"
|
||
class="engine-button"
|
||
:class="{ active: engine === option.key }"
|
||
@click="selectEngine(option.key)"
|
||
>
|
||
{{ option.label }}
|
||
</button>
|
||
<span class="engine-note">{{ engineNote }}</span>
|
||
</div>
|
||
<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>
|
||
<div class="editor-area">
|
||
<component
|
||
:is="engineComponents[engine]"
|
||
:key="engine"
|
||
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);
|
||
}
|
||
|
||
.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;
|
||
}
|
||
|
||
.engine-bar {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 8px;
|
||
align-items: center;
|
||
margin-bottom: 8px;
|
||
}
|
||
|
||
.engine-label {
|
||
color: var(--ink);
|
||
font-size: 12px;
|
||
font-weight: 700;
|
||
}
|
||
|
||
.engine-button {
|
||
padding: 5px 12px;
|
||
color: var(--mut);
|
||
font-size: 12px;
|
||
font-weight: 700;
|
||
background: #fff;
|
||
border: 1px solid var(--line);
|
||
border-radius: 14px;
|
||
}
|
||
|
||
.engine-button:hover {
|
||
border-color: var(--brand);
|
||
color: var(--brand);
|
||
}
|
||
|
||
.engine-button.active {
|
||
color: #fff;
|
||
background: var(--brand);
|
||
border-color: var(--brand);
|
||
}
|
||
|
||
.engine-note {
|
||
color: var(--mut);
|
||
font-size: 11.5px;
|
||
}
|
||
|
||
.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>
|