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

626 lines
15 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 { 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(/&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" />
</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>