build(clinical-web): 新增富文本编辑与 Word 转换依赖

This commit is contained in:
yelan
2026-09-10 12:03:21 +08:00
parent 7bea008680
commit 23d85cf8ce
11 changed files with 1398 additions and 1063 deletions
@@ -0,0 +1,554 @@
<script setup lang="ts">
import '@wangeditor/editor/dist/css/style.css'
import { onBeforeUnmount, reactive, ref, shallowRef, watch } from 'vue'
import { Editor, Toolbar } from '@wangeditor/editor-for-vue'
import type { IDomEditor, IEditorConfig, IToolbarConfig } from '@wangeditor/editor'
import type { CampusRecord, DepartmentRecord } from '@/api/management/types'
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 转换;签署时由交互管线识别"勾选"表并转为签署域。
const editorRef = shallowRef<IDomEditor>()
const valueHtml = ref('<p><br></p>')
const editorMode = 'default'
const docxInput = ref<HTMLInputElement | null>(null)
const importing = ref(false)
const importMessage = ref('')
const toolbarConfig: Partial<IToolbarConfig> = {
excludeKeys: ['group-video', 'fullScreen'],
}
const editorConfig: Partial<IEditorConfig> = {
placeholder:
'将 Word / WPS 中的文书内容直接粘贴到此处(保留表格与基本格式),或点击上方「导入 .docx」自动转换。',
}
// 占位符在签署时由系统按患者与就诊信息自动填充;签署交互管线同时会识别"勾选"表格。
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 handleCreated(editor: IDomEditor) {
editorRef.value = editor
}
function insertPlaceholder(text: string) {
// slate 文本节点:{ text };由编辑器在当前光标处插入。
editorRef.value?.insertNode({ 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() {
const text = editorRef.value?.getText().trim()
if (!form.name.trim()) {
importMessage.value = '请填写文书名称'
return
}
if (!text) {
importMessage.value = '文书内容为空,请粘贴 Word 内容或导入 .docx'
return
}
form.contentHtml = valueHtml.value
emit('save', { ...form, contentHtml: valueHtml.value })
}
onBeforeUnmount(() => {
editorRef.value?.destroy()
})
</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="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">
<Toolbar
class="editor-toolbar"
:editor="editorRef"
:default-config="toolbarConfig"
:mode="editorMode"
/>
<Editor
v-model="valueHtml"
class="editor-content"
:default-config="editorConfig"
:mode="editorMode"
@on-created="handleCreated"
/>
</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;
}
.editor-area {
display: flex;
flex-direction: column;
flex: 1;
min-height: 380px;
overflow: hidden;
border: 1px solid var(--line);
border-radius: 8px;
}
.editor-toolbar {
border-bottom: 1px solid var(--line);
}
.editor-content {
flex: 1;
min-height: 340px;
overflow-y: auto;
}
.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>