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
@@ -1,497 +0,0 @@
<script setup lang="ts">
import { computed, reactive, watch } from 'vue'
import type { CampusRecord, DepartmentRecord } from '@/api/management/types'
import type { DocumentTemplate, TemplateEditorForm, TemplateVersionAction } from '../types'
const props = defineProps<{
visible: boolean
template: DocumentTemplate | null
campuses: CampusRecord[]
departments: DepartmentRecord[]
saving: boolean
workflowSaving: boolean
}>()
const emit = defineEmits<{
close: []
save: [form: TemplateEditorForm]
workflow: [action: TemplateVersionAction, comment?: string]
}>()
const form = reactive<TemplateEditorForm>(createEmptyForm())
const error = reactive({ message: '' })
const rejectComment = reactive({ value: '' })
const isNew = computed(() => !form.id)
const availableDepartments = computed(() =>
props.departments.filter((department) => !form.campusId || department.campusId === form.campusId),
)
const workflowActions = computed(() => {
if (!props.template || !form.id) {
return []
}
const actions: Array<{ action: TemplateVersionAction; label: string; tone: string }> = []
if (
props.template.backendStatus === 'DRAFT' ||
props.template.backendStatus === 'REJECTED' ||
(!props.template.backendStatus && props.template.status === 'draft')
) {
actions.push({ action: 'submit-review', label: '提交审核', tone: 'primary' })
}
if (
props.template.backendStatus === 'PENDING_REVIEW' ||
(!props.template.backendStatus && props.template.status === 'review')
) {
actions.push({ action: 'approve', label: '审核通过', tone: 'primary' })
actions.push({ action: 'reject', label: '驳回', tone: 'danger' })
}
if (props.template.backendStatus === 'APPROVED') {
actions.push({ action: 'publish', label: '发布版本', tone: 'primary' })
}
if (
props.template.backendStatus === 'PUBLISHED' ||
(!props.template.backendStatus && props.template.status === 'enabled')
) {
actions.push({ action: 'disable', label: '停用版本', tone: 'secondary' })
actions.push({ action: 'archive', label: '归档版本', tone: 'secondary' })
}
return actions
})
function createEmptyForm(): TemplateEditorForm {
return {
templateCode: '',
name: '',
description: '',
campusId: props.campuses[0]?.id ?? '',
departmentId: '',
category: '知情同意书',
versionNo: '',
contentHtml: '',
}
}
function resetForm() {
if (!props.template) {
Object.assign(form, createEmptyForm())
} else {
Object.assign(form, {
id: props.template.id,
templateCode: props.template.code,
name: props.template.name,
description: props.template.description,
campusId: props.template.campusId ?? props.campuses[0]?.id ?? '',
departmentId: props.template.departmentId ?? '',
category: props.template.category,
versionNo: '',
contentHtml: props.template.contentHtml ?? '',
})
}
error.message = ''
rejectComment.value = ''
}
function handleCampusChange() {
if (!availableDepartments.value.some((department) => department.id === form.departmentId)) {
form.departmentId = ''
}
}
function submit() {
error.message = ''
if (!form.templateCode.trim()) {
error.message = '请输入模板编号'
return
}
if (!form.name.trim()) {
error.message = '请输入模板名称'
return
}
if (!form.campusId) {
error.message = '请选择院区'
return
}
if (!form.contentHtml.trim()) {
error.message = '请输入模板 HTML 内容'
return
}
emit('save', {
...form,
templateCode: form.templateCode.trim(),
name: form.name.trim(),
description: form.description.trim(),
category: form.category.trim() || '知情同意书',
versionNo: form.versionNo.trim(),
contentHtml: form.contentHtml.trim(),
})
}
function runWorkflow(action: TemplateVersionAction) {
if (action === 'reject' && !rejectComment.value.trim()) {
error.message = '请输入驳回原因'
return
}
error.message = ''
emit('workflow', action, rejectComment.value.trim() || undefined)
}
watch(
() => props.visible,
(visible) => {
if (visible) {
resetForm()
}
},
)
watch(
() => props.template,
() => {
if (props.visible) {
resetForm()
}
},
)
</script>
<template>
<Teleport to="body">
<div v-if="visible" class="dialog-mask" @click.self="emit('close')">
<section class="editor-dialog" role="dialog" aria-modal="true" aria-labelledby="editor-title">
<header class="dialog-header">
<div>
<strong id="editor-title">{{ isNew ? '新增文档模板' : '编辑模板版本' }}</strong>
<p>
{{
isNew
? '创建模板并同时保存首个版本。'
: '编辑操作会创建新的文书版本,已发布版本不会被直接覆盖。'
}}
</p>
</div>
<button type="button" class="close-button" aria-label="关闭" @click="emit('close')">
×
</button>
</header>
<form class="dialog-body" @submit.prevent="submit">
<div class="form-grid">
<label>
<span>模板编号 <em>*</em></span>
<input
v-model="form.templateCode"
:disabled="!isNew"
placeholder="如:TPL-RAD-MRI-001"
/>
</label>
<label>
<span>模板名称 <em>*</em></span>
<input v-model="form.name" :disabled="!isNew" placeholder="请输入模板名称" />
</label>
<label>
<span>院区 <em>*</em></span>
<select v-model="form.campusId" :disabled="!isNew" @change="handleCampusChange">
<option value="" disabled>请选择院区</option>
<option v-for="campus in campuses" :key="campus.id" :value="campus.id">
{{ campus.name }}
</option>
</select>
</label>
<label>
<span>科室</span>
<select v-model="form.departmentId" :disabled="!isNew">
<option value="">全院通用</option>
<option
v-for="department in availableDepartments"
:key="department.id"
:value="department.id"
>
{{ department.name }}
</option>
</select>
</label>
<label>
<span>文档类别</span>
<input v-model="form.category" :disabled="!isNew" placeholder="如:知情同意书" />
</label>
<label>
<span>版本号</span>
<input v-model="form.versionNo" placeholder="如:V1.0,不填由服务端生成" />
</label>
</div>
<label class="full-field">
<span>模板说明</span>
<textarea
v-model="form.description"
:disabled="!isNew"
rows="2"
placeholder="请输入模板说明"
/>
</label>
<label class="full-field">
<span>模板 HTML <em>*</em></span>
<textarea
v-model="form.contentHtml"
rows="11"
placeholder="请输入服务端可渲染的 HTML 内容,不要包含脚本。"
/>
</label>
<p v-if="error.message" class="form-error">{{ error.message }}</p>
<p class="form-tip">
版本工作流由服务端校验权限和状态;此处不会将本地签名或患者数据写入模板内容。
</p>
</form>
<footer class="dialog-footer">
<div v-if="workflowActions.length" class="workflow-actions">
<span>当前版本:{{ template?.version }}</span>
<textarea
v-if="workflowActions.some((item) => item.action === 'reject')"
v-model="rejectComment.value"
rows="1"
placeholder="驳回原因(驳回时必填)"
/>
<button
v-for="item in workflowActions"
:key="item.action"
type="button"
class="footer-button"
:class="item.tone"
:disabled="saving || workflowSaving"
@click="runWorkflow(item.action)"
>
{{ item.label }}
</button>
</div>
<span class="footer-spacer" />
<button type="button" class="footer-button secondary" @click="emit('close')">取消</button>
<button type="button" class="footer-button primary" :disabled="saving" @click="submit">
{{ saving ? '保存中…' : isNew ? '创建模板' : '创建新版本' }}
</button>
</footer>
</section>
</div>
</Teleport>
</template>
<style scoped>
.dialog-mask {
position: fixed;
z-index: 30;
inset: 0;
display: flex;
padding: 16px;
overflow-y: auto;
background: rgb(9 40 52 / 45%);
}
.editor-dialog {
display: flex;
flex-direction: column;
width: 760px;
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: 14px 18px;
border-bottom: 1px solid var(--line);
}
.dialog-header strong {
color: var(--ink);
font-size: 15px;
}
.dialog-header p {
margin: 4px 0 0;
color: var(--mut);
font-size: 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);
}
.dialog-body {
min-height: 0;
padding: 16px 18px;
overflow-y: auto;
}
.form-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px 14px;
}
.form-grid label,
.full-field {
display: flex;
flex-direction: column;
gap: 6px;
color: var(--mut);
font-size: 12px;
}
.full-field {
margin-top: 12px;
}
label span em {
color: var(--err);
font-style: normal;
}
input,
select,
textarea {
width: 100%;
padding: 8px 10px;
color: var(--ink);
font: inherit;
background: #fff;
border: 1px solid var(--line);
border-radius: 6px;
resize: vertical;
}
input:focus,
select:focus,
textarea:focus {
outline: none;
border-color: var(--brand);
box-shadow: 0 0 0 3px rgb(14 110 140 / 10%);
}
input:disabled,
select:disabled,
textarea:disabled {
color: var(--mut);
background: #f5f8f9;
cursor: not-allowed;
}
.form-error {
margin: 12px 0 0;
color: var(--err);
font-size: 12px;
}
.form-tip {
margin: 12px 0 0;
color: var(--mut);
font-size: 11.5px;
line-height: 1.6;
}
.dialog-footer {
display: flex;
gap: 8px;
align-items: center;
flex-wrap: wrap;
padding: 10px 18px;
background: #f8fbfc;
border-top: 1px solid var(--line);
}
.workflow-actions {
display: flex;
gap: 8px;
align-items: center;
flex-wrap: wrap;
color: var(--mut);
font-size: 11.5px;
}
.workflow-actions textarea {
width: 170px;
min-height: 30px;
padding: 5px 8px;
}
.footer-spacer {
flex: 1;
}
.footer-button {
padding: 7px 13px;
font-size: 12px;
font-weight: 700;
border: 1px solid transparent;
border-radius: 7px;
}
.footer-button:disabled {
cursor: wait;
opacity: 0.55;
}
.footer-button.primary {
color: #fff;
background: var(--brand);
border-color: var(--brand);
}
.footer-button.secondary {
color: var(--brand);
background: #fff;
border-color: var(--brand);
}
.footer-button.danger {
color: var(--err);
background: var(--err-l);
border-color: rgb(194 73 73 / 25%);
}
.footer-button:hover:not(:disabled) {
filter: brightness(0.96);
}
@media (max-width: 680px) {
.form-grid {
grid-template-columns: 1fr;
}
.footer-spacer {
display: none;
}
}
</style>
@@ -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>