build(clinical-web): 新增富文本编辑与 Word 转换依赖
This commit is contained in:
@@ -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>
|
||||
Reference in New Issue
Block a user