build(deps): 新增 CKEditor 与 TinyMCE 依赖
This commit is contained in:
Generated
+2589
File diff suppressed because it is too large
Load Diff
@@ -13,12 +13,16 @@
|
||||
"format:check": "prettier . --check"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ckeditor/ckeditor5-vue": "^8.2.0",
|
||||
"@tinymce/tinymce-vue": "^6.3.0",
|
||||
"@wangeditor/editor": "^5.1.23",
|
||||
"@wangeditor/editor-for-vue": "^5.1.12",
|
||||
"axios": "^1.20.0",
|
||||
"ckeditor5": "^48.5.0",
|
||||
"element-plus": "^2.14.5",
|
||||
"mammoth": "^1.12.2",
|
||||
"pinia": "^4.0.3",
|
||||
"tinymce": "^8.9.1",
|
||||
"vue": "^3.5.41",
|
||||
"vue-router": "^5.2.0"
|
||||
},
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
<script setup lang="ts">
|
||||
import type { DepartmentTab, DocumentStatus, StatusTab } from '../types'
|
||||
import type { DepartmentTab, DocumentFilterStatus, StatusTab } from '../types'
|
||||
|
||||
defineProps<{
|
||||
department: string | null
|
||||
status: DocumentStatus
|
||||
status: DocumentFilterStatus
|
||||
departments: DepartmentTab[]
|
||||
statuses: StatusTab[]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:department': [value: string | null]
|
||||
'update:status': [value: DocumentStatus]
|
||||
'update:status': [value: DocumentFilterStatus]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
|
||||
@@ -23,6 +23,47 @@ const statusLabel = computed(() => {
|
||||
|
||||
return documentStatusLabels[props.template.status]
|
||||
})
|
||||
|
||||
// 预置文书的 contentHtml 是完整 HTML 文档(自带版式样式);
|
||||
// Word 自维护文书存的是编辑器产出的 HTML 片段,需要叠加文书基础渲染样式
|
||||
// (表格边框、正文字体等),使导入路径的"裸表格"等语义结构也有合理的文书外观。
|
||||
const DOCUMENT_BASE_STYLE = `
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 24px 28px 36px;
|
||||
color: #1c2b33;
|
||||
font-family: 'SimSun', 'Songti SC', 'STSong', serif;
|
||||
font-size: 14px;
|
||||
line-height: 1.85;
|
||||
background: #fff;
|
||||
}
|
||||
img {
|
||||
max-width: 100%;
|
||||
}
|
||||
table {
|
||||
width: 100%;
|
||||
margin: 8px 0;
|
||||
border-collapse: collapse;
|
||||
font-size: 12.5px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
th,
|
||||
td {
|
||||
padding: 5px 7px;
|
||||
border: 1px solid #52646e;
|
||||
vertical-align: middle;
|
||||
}
|
||||
`
|
||||
|
||||
const previewSrcDoc = computed(() => {
|
||||
const contentHtml = props.template?.contentHtml ?? ''
|
||||
|
||||
if (/^\s*<!doctype html|^\s*<html[\s>]/i.test(contentHtml)) {
|
||||
return contentHtml
|
||||
}
|
||||
|
||||
return `<!DOCTYPE html><html lang="zh-CN"><head><meta charset="utf-8" /><style>${DOCUMENT_BASE_STYLE}</style></head><body>${contentHtml}</body></html>`
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -58,7 +99,7 @@ const statusLabel = computed(() => {
|
||||
<iframe
|
||||
v-else-if="template.contentHtml"
|
||||
class="preview-frame"
|
||||
:srcdoc="template.contentHtml"
|
||||
:srcdoc="previewSrcDoc"
|
||||
sandbox=""
|
||||
title="文档模板内容预览"
|
||||
/>
|
||||
|
||||
@@ -12,6 +12,7 @@ const emit = defineEmits<{
|
||||
preview: [template: DocumentTemplate]
|
||||
edit: [template: DocumentTemplate]
|
||||
initiate: [template: DocumentTemplate]
|
||||
remove: [template: DocumentTemplate]
|
||||
}>()
|
||||
|
||||
const statusLabel = computed(() => documentStatusLabels[props.template.status])
|
||||
@@ -49,6 +50,14 @@ const statusLabel = computed(() => documentStatusLabels[props.template.status])
|
||||
>
|
||||
发起
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="card-button danger"
|
||||
title="删除此文书"
|
||||
@click="emit('remove', template)"
|
||||
>
|
||||
删除
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
@@ -224,6 +233,15 @@ const statusLabel = computed(() => documentStatusLabels[props.template.status])
|
||||
background: #f1f5f6;
|
||||
}
|
||||
|
||||
.card-button.danger {
|
||||
color: #b3442f;
|
||||
border-color: #cf7c68;
|
||||
}
|
||||
|
||||
.card-button.danger:hover {
|
||||
background: #fbeee9;
|
||||
}
|
||||
|
||||
@media (max-width: 680px) {
|
||||
.template-footer {
|
||||
align-items: flex-start;
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
<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 { reactive, ref, watch } from 'vue'
|
||||
|
||||
import type { CampusRecord, DepartmentRecord } from '@/api/management/types'
|
||||
|
||||
import EditorCkPane from './editors/EditorCkPane.vue'
|
||||
import EditorTinyPane from './editors/EditorTinyPane.vue'
|
||||
import EditorWangPane from './editors/EditorWangPane.vue'
|
||||
import type { DocumentTemplate, TemplateEditorForm } from '../types'
|
||||
|
||||
const props = defineProps<{
|
||||
@@ -24,21 +23,45 @@ const emit = defineEmits<{
|
||||
|
||||
// Word 自维护方案:科室把文书从 Word/WPS 直接粘贴进来(保留表格与基本格式),
|
||||
// 或上传 .docx 由 mammoth 转换;签署时由交互管线识别"勾选"表并转为签署域。
|
||||
const editorRef = shallowRef<IDomEditor>()
|
||||
// 富文本引擎做成可切换的三套(对比 Word 粘贴保真度),存储统一为编辑器产出的 HTML。
|
||||
type EditorEngine = 'wangeditor' | 'ckeditor5' | 'tinymce'
|
||||
|
||||
interface EditorPaneHandle {
|
||||
getText: () => string
|
||||
insertText: (text: string) => void
|
||||
}
|
||||
|
||||
const engineComponents = {
|
||||
wangeditor: EditorWangPane,
|
||||
ckeditor5: EditorCkPane,
|
||||
tinymce: EditorTinyPane,
|
||||
}
|
||||
|
||||
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)',
|
||||
},
|
||||
]
|
||||
|
||||
const engine = ref<EditorEngine>('wangeditor')
|
||||
const engineNote = ref(engineOptions[0]?.note ?? '')
|
||||
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 paneRef = ref<EditorPaneHandle | null>(null)
|
||||
|
||||
// 占位符在签署时由系统按患者与就诊信息自动填充;签署交互管线同时会识别"勾选"表格。
|
||||
const placeholders = [
|
||||
@@ -102,13 +125,13 @@ function handleCampusChange() {
|
||||
}
|
||||
}
|
||||
|
||||
function handleCreated(editor: IDomEditor) {
|
||||
editorRef.value = editor
|
||||
function selectEngine(next: EditorEngine) {
|
||||
engine.value = next
|
||||
engineNote.value = engineOptions.find((option) => option.key === next)?.note ?? ''
|
||||
}
|
||||
|
||||
function insertPlaceholder(text: string) {
|
||||
// slate 文本节点:{ text };由编辑器在当前光标处插入。
|
||||
editorRef.value?.insertNode({ text })
|
||||
paneRef.value?.insertText(text)
|
||||
}
|
||||
|
||||
async function handleDocxChange(event: Event) {
|
||||
@@ -139,7 +162,7 @@ async function handleDocxChange(event: Event) {
|
||||
}
|
||||
|
||||
function save() {
|
||||
const text = editorRef.value?.getText().trim()
|
||||
const text = paneRef.value?.getText() ?? ''
|
||||
|
||||
if (!form.name.trim()) {
|
||||
importMessage.value = '请填写文书名称'
|
||||
@@ -154,10 +177,6 @@ function save() {
|
||||
form.contentHtml = valueHtml.value
|
||||
emit('save', { ...form, contentHtml: valueHtml.value })
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
editorRef.value?.destroy()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -242,6 +261,20 @@ onBeforeUnmount(() => {
|
||||
</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"
|
||||
@@ -265,18 +298,12 @@ onBeforeUnmount(() => {
|
||||
</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"
|
||||
<component
|
||||
:is="engineComponents[engine]"
|
||||
:key="engine"
|
||||
ref="paneRef"
|
||||
:model-value="valueHtml"
|
||||
@update:model-value="valueHtml = $event"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -478,24 +505,52 @@ onBeforeUnmount(() => {
|
||||
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: 380px;
|
||||
min-height: 420px;
|
||||
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 {
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
<script setup lang="ts">
|
||||
import { shallowRef } from 'vue'
|
||||
|
||||
import { Ckeditor } from '@ckeditor/ckeditor5-vue'
|
||||
import {
|
||||
Alignment,
|
||||
Bold,
|
||||
ClassicEditor,
|
||||
Essentials,
|
||||
FontColor,
|
||||
FontSize,
|
||||
GeneralHtmlSupport,
|
||||
Heading,
|
||||
Italic,
|
||||
PasteFromOffice,
|
||||
Paragraph,
|
||||
Table,
|
||||
TableToolbar,
|
||||
Underline,
|
||||
} from 'ckeditor5'
|
||||
import type { Editor } from 'ckeditor5'
|
||||
import 'ckeditor5/ckeditor5.css'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: string]
|
||||
}>()
|
||||
|
||||
const editorRef = shallowRef<Editor>()
|
||||
|
||||
// PasteFromOffice + GeneralHtmlSupport:CKEditor 的 Word 粘贴保真核心,
|
||||
// 开源 GPL 构建即包含(对应 config 中 licenseKey: 'GPL')。
|
||||
const config = {
|
||||
licenseKey: 'GPL',
|
||||
placeholder: '将 Word / WPS 中的文书内容直接粘贴到此处(PasteFromOffice 插件处理 Word 粘贴)。',
|
||||
plugins: [
|
||||
Essentials,
|
||||
Paragraph,
|
||||
Heading,
|
||||
Bold,
|
||||
Italic,
|
||||
Underline,
|
||||
Alignment,
|
||||
FontSize,
|
||||
FontColor,
|
||||
Table,
|
||||
TableToolbar,
|
||||
GeneralHtmlSupport,
|
||||
PasteFromOffice,
|
||||
],
|
||||
toolbar: [
|
||||
'heading',
|
||||
'|',
|
||||
'bold',
|
||||
'italic',
|
||||
'underline',
|
||||
'|',
|
||||
'fontSize',
|
||||
'fontColor',
|
||||
'|',
|
||||
'alignment:left',
|
||||
'alignment:center',
|
||||
'alignment:right',
|
||||
'alignment:justify',
|
||||
'|',
|
||||
'bulletedList',
|
||||
'numberedList',
|
||||
'|',
|
||||
'insertTable',
|
||||
'undo',
|
||||
'redo',
|
||||
],
|
||||
heading: {
|
||||
options: [
|
||||
{ model: 'paragraph' as const, title: '正文', class: 'ck-heading_paragraph' },
|
||||
{ model: 'heading1' as const, view: 'h1', title: '标题 1', class: 'ck-heading_heading1' },
|
||||
{ model: 'heading2' as const, view: 'h2', title: '标题 2', class: 'ck-heading_heading2' },
|
||||
{ model: 'heading3' as const, view: 'h3', title: '标题 3', class: 'ck-heading_heading3' },
|
||||
],
|
||||
},
|
||||
// 尽量保留粘贴内容中的内联样式与 class(Word 居中等视觉信息)。
|
||||
htmlSupport: {
|
||||
allow: [{ name: /[\s\S]+/, classes: /[\s\S]+/, styles: /[\s\S]+/, attributes: /[\s\S]+/ }],
|
||||
},
|
||||
table: {
|
||||
contentToolbar: ['tableColumn', 'tableRow', 'mergeTableCells'],
|
||||
},
|
||||
}
|
||||
|
||||
function onReady(editor: Editor) {
|
||||
editorRef.value = editor
|
||||
}
|
||||
|
||||
function getText(): string {
|
||||
const html = editorRef.value?.getData() ?? ''
|
||||
return html
|
||||
.replace(/<[^>]*>/g, ' ')
|
||||
.replace(/ /g, ' ')
|
||||
.trim()
|
||||
}
|
||||
|
||||
function insertText(text: string) {
|
||||
const editor = editorRef.value
|
||||
|
||||
if (!editor) {
|
||||
return
|
||||
}
|
||||
|
||||
editor.model.change((writer) => {
|
||||
const focus = editor.model.document.selection.focus
|
||||
|
||||
if (focus) {
|
||||
writer.insertText(text, focus)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
defineExpose({ getText, insertText })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="ck-pane">
|
||||
<Ckeditor
|
||||
class="pane-editor"
|
||||
:editor="ClassicEditor"
|
||||
:model-value="props.modelValue"
|
||||
:config="config"
|
||||
@update:model-value="emit('update:modelValue', String($event))"
|
||||
@ready="onReady"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.ck-pane {
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.ck-pane :deep(.ck-editor__editable_inline) {
|
||||
min-height: 380px;
|
||||
max-height: 460px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,82 @@
|
||||
<script setup lang="ts">
|
||||
import { shallowRef } from 'vue'
|
||||
|
||||
// 副作用导入:注册全局 window.tinymce(@tinymce/tinymce-vue 会检测全局实例,从而走本地包而非 CDN)。
|
||||
import 'tinymce/tinymce'
|
||||
import 'tinymce/themes/silver'
|
||||
import 'tinymce/icons/default/icons'
|
||||
import 'tinymce/models/dom'
|
||||
import 'tinymce/plugins/lists'
|
||||
import 'tinymce/plugins/table'
|
||||
import 'tinymce/skins/ui/oxide/skin.min.css'
|
||||
import TinyEditor from '@tinymce/tinymce-vue'
|
||||
|
||||
// TinyMCE 8(GPL 开源模式需声明 license_key: 'gpl')。
|
||||
// skin: false / content_css: false —— 皮肤样式由打包引入,iframe 内容样式用 content_style 内联。
|
||||
const editorRef = shallowRef<TinyMceApi | null>(null)
|
||||
|
||||
interface TinyMceApi {
|
||||
getContent: (options?: { format?: string }) => string
|
||||
setContent: (content: string) => void
|
||||
insertContent: (content: string) => void
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: string]
|
||||
}>()
|
||||
|
||||
const init = {
|
||||
license_key: 'gpl',
|
||||
menubar: false,
|
||||
placeholder: '将 Word / WPS 中的文书内容直接粘贴到此处(保留表格与基本格式)。',
|
||||
plugins: 'lists table paste',
|
||||
toolbar:
|
||||
'undo redo | bold italic underline | alignleft aligncenter alignright alignjustify | fontsizeselect forecolor backcolor | bullist numlist | table',
|
||||
skin: false,
|
||||
content_css: false,
|
||||
content_style: `
|
||||
body { font-family: 'SimSun', 'Songti SC', serif; font-size: 14px; line-height: 1.8; color: #1c2b33; }
|
||||
table { border-collapse: collapse; width: 100%; }
|
||||
th, td { border: 1px solid #52646e; padding: 5px 7px; }
|
||||
`,
|
||||
paste_data_images: true,
|
||||
height: 460,
|
||||
}
|
||||
|
||||
function onInit(_event: unknown, editor: TinyMceApi) {
|
||||
editorRef.value = editor
|
||||
}
|
||||
|
||||
function getText(): string {
|
||||
return editorRef.value?.getContent({ format: 'text' }).trim() ?? ''
|
||||
}
|
||||
|
||||
function insertText(text: string) {
|
||||
editorRef.value?.insertContent(text)
|
||||
}
|
||||
|
||||
defineExpose({ getText, insertText })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="tiny-pane">
|
||||
<TinyEditor
|
||||
:model-value="props.modelValue"
|
||||
:init="init"
|
||||
@update:model-value="emit('update:modelValue', $event)"
|
||||
@init="onInit"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.tiny-pane {
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,107 @@
|
||||
<script setup lang="ts">
|
||||
import '@wangeditor/editor/dist/css/style.css'
|
||||
|
||||
import { onBeforeUnmount, shallowRef } from 'vue'
|
||||
import { Editor, Toolbar } from '@wangeditor/editor-for-vue'
|
||||
import type { IDomEditor, IEditorConfig, IToolbarConfig } from '@wangeditor/editor'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: string]
|
||||
}>()
|
||||
|
||||
const editorRef = shallowRef<IDomEditor>()
|
||||
const editorMode = 'default'
|
||||
|
||||
// 剪贴板富 HTML 的轻量清理:去掉脚本/样式表/条件注释(WangEditor 默认粘贴会过滤掉
|
||||
// 大量内联样式导致"粘贴变形",这里接管粘贴以尽量保留 Word 原样式)。
|
||||
function cleanClipboardHtml(html: string): string {
|
||||
return html
|
||||
.replace(/<!--[\s\S]*?-->/g, '')
|
||||
.replace(/<(script|style|meta|link)\b[^>]*>[\s\S]*?<\/\1>/gi, '')
|
||||
.replace(/<(script|meta|link)\b[^>]*\/?>/gi, '')
|
||||
}
|
||||
|
||||
const toolbarConfig: Partial<IToolbarConfig> = {
|
||||
excludeKeys: ['group-video', 'fullScreen'],
|
||||
}
|
||||
|
||||
const editorConfig: Partial<IEditorConfig> = {
|
||||
placeholder: '将 Word / WPS 中的文书内容直接粘贴到此处(保留表格与基本格式)。',
|
||||
// 接管粘贴:优先使用剪贴板中的富 HTML 原样插入(保留居中、加粗、表格等内联样式);
|
||||
// 纯文本粘贴(无富 HTML)时返回 true 走默认处理。
|
||||
customPaste: (editor, event) => {
|
||||
const html = event.clipboardData?.getData('text/html')
|
||||
|
||||
if (html) {
|
||||
editor.dangerouslyInsertHtml(cleanClipboardHtml(html))
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
},
|
||||
}
|
||||
|
||||
function handleCreated(editor: IDomEditor) {
|
||||
editorRef.value = editor
|
||||
}
|
||||
|
||||
function getText(): string {
|
||||
return editorRef.value?.getText().trim() ?? ''
|
||||
}
|
||||
|
||||
// slate 文本节点:{ text };由编辑器在当前光标处插入。
|
||||
function insertText(text: string) {
|
||||
editorRef.value?.insertNode({ text })
|
||||
}
|
||||
|
||||
defineExpose({ getText, insertText })
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
editorRef.value?.destroy()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="wang-pane">
|
||||
<Toolbar
|
||||
class="pane-toolbar"
|
||||
:editor="editorRef"
|
||||
:default-config="toolbarConfig"
|
||||
:mode="editorMode"
|
||||
/>
|
||||
<Editor
|
||||
class="pane-content"
|
||||
:model-value="props.modelValue"
|
||||
:default-config="editorConfig"
|
||||
:mode="editorMode"
|
||||
@update:model-value="emit('update:modelValue', $event)"
|
||||
@on-created="handleCreated"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.wang-pane {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.pane-toolbar {
|
||||
flex-shrink: 0;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.pane-content {
|
||||
flex: 1;
|
||||
min-height: 340px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
</style>
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
|
||||
import NewSigningTaskDialog from '@/components/signing/NewSigningTaskDialog.vue'
|
||||
import type { SigningTemplate } from '@/api/workbench/types'
|
||||
@@ -50,7 +50,7 @@ const departments: DepartmentRecord[] = ['医学影像科', '医学检验科', '
|
||||
const filters = reactive<DocumentFilterForm>({
|
||||
keyword: '',
|
||||
department: null,
|
||||
status: 'enabled',
|
||||
status: 'all',
|
||||
})
|
||||
|
||||
const allTemplates = computed<DocumentTemplate[]>(() =>
|
||||
@@ -76,19 +76,21 @@ const departmentTabs = computed<DepartmentTab[]>(() => [
|
||||
})),
|
||||
])
|
||||
|
||||
const statusTabs = computed<StatusTab[]>(() =>
|
||||
documentStatusOptions.map((option) => ({
|
||||
const statusTabs = computed<StatusTab[]>(() => [
|
||||
{ key: 'all', label: '全部', count: allTemplates.value.length },
|
||||
...documentStatusOptions.map((option) => ({
|
||||
...option,
|
||||
count: allTemplates.value.filter((template) => template.status === option.key).length,
|
||||
})),
|
||||
)
|
||||
])
|
||||
|
||||
const filteredTemplates = computed(() => {
|
||||
const keyword = filters.keyword.trim().toLowerCase()
|
||||
|
||||
return allTemplates.value.filter((template) => {
|
||||
const matchesDepartment = !filters.department || template.departmentKey === filters.department
|
||||
const matchesStatus = template.status === filters.status
|
||||
const matchesStatus =
|
||||
filters.status === 'all' || template.status === filters.status
|
||||
const matchesKeyword =
|
||||
!keyword ||
|
||||
[template.name, template.code, template.department].join(' ').toLowerCase().includes(keyword)
|
||||
@@ -169,7 +171,8 @@ function addLocalTemplate(form: TemplateEditorForm) {
|
||||
name: form.name,
|
||||
code: form.templateCode,
|
||||
version: form.versionNo || 'v1',
|
||||
status: 'draft',
|
||||
// 原型闭环:新文书直接启用(工作流 UI 待签署端接入后恢复),保证创建后即可预览/发起。
|
||||
status: 'enabled',
|
||||
description: form.description,
|
||||
versionId: `local-version-${timestamp}`,
|
||||
departmentId: form.departmentId || null,
|
||||
@@ -200,6 +203,35 @@ function saveTemplate(form: TemplateEditorForm) {
|
||||
ElMessage.success(form.id ? '文书已更新' : '文书已创建')
|
||||
}
|
||||
|
||||
async function removeTemplate(template: DocumentTemplate) {
|
||||
const confirmed = await ElMessageBox.confirm(
|
||||
`确定删除文书「${template.name}」?删除后不可恢复。`,
|
||||
'删除文书',
|
||||
{ type: 'warning', confirmButtonText: '删除', cancelButtonText: '取消' },
|
||||
)
|
||||
.then(() => true)
|
||||
.catch(() => false)
|
||||
|
||||
if (!confirmed) {
|
||||
return
|
||||
}
|
||||
|
||||
// 移除文书后,顺带清理空分类与空科室分组,避免留下空壳节点。
|
||||
library.value = library.value
|
||||
.map((department) => ({
|
||||
...department,
|
||||
categories: department.categories
|
||||
.map((category) => ({
|
||||
...category,
|
||||
documents: category.documents.filter((document) => document.id !== template.id),
|
||||
}))
|
||||
.filter((category) => category.documents.length > 0),
|
||||
}))
|
||||
.filter((department) => department.categories.length > 0)
|
||||
|
||||
ElMessage.success('文书已删除')
|
||||
}
|
||||
|
||||
// 版本工作流(提交审核/发布等)随原模板编辑器移除,待签署端接入后按后端工作流接口恢复。
|
||||
|
||||
function toSigningTemplate(template: DocumentTemplate): SigningTemplate {
|
||||
@@ -265,20 +297,26 @@ onMounted(loadLibrary)
|
||||
@preview="handlePreview"
|
||||
@edit="showEditMessage"
|
||||
@initiate="openSigningDialog"
|
||||
@remove="removeTemplate"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-else class="empty-state">
|
||||
<div class="empty-icon" aria-hidden="true">⌕</div>
|
||||
<p>没有符合条件的模板</p>
|
||||
<button
|
||||
v-if="filters.keyword || filters.department"
|
||||
type="button"
|
||||
class="reset-button"
|
||||
@click="clearFilters"
|
||||
>
|
||||
清除筛选
|
||||
</button>
|
||||
<template v-if="allTemplates.length === 0">
|
||||
<p>文书库为空,点击右上角「新增文档」用 Word 创建第一份文书</p>
|
||||
</template>
|
||||
<template v-else>
|
||||
<p>没有符合条件的文书</p>
|
||||
<button
|
||||
v-if="filters.keyword || filters.department"
|
||||
type="button"
|
||||
class="reset-button"
|
||||
@click="clearFilters"
|
||||
>
|
||||
清除筛选
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<DocumentPreviewDialog
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
import type { DocumentDepartment, DocumentStatus, LibraryDocument } from './types'
|
||||
|
||||
import { presetDocumentContentHtml } from './preset-contents'
|
||||
import type { DocumentDepartment, DocumentStatus } from './types'
|
||||
|
||||
const documentStatusLabels: Record<DocumentStatus, string> = {
|
||||
enabled: '启用中',
|
||||
@@ -16,90 +14,5 @@ export const documentStatusOptions = Object.entries(documentStatusLabels).map(([
|
||||
|
||||
export { documentStatusLabels }
|
||||
|
||||
function createDocument(
|
||||
id: string,
|
||||
name: string,
|
||||
code: string,
|
||||
version: string,
|
||||
description: string,
|
||||
status: DocumentStatus = 'enabled',
|
||||
): LibraryDocument {
|
||||
return {
|
||||
id,
|
||||
name,
|
||||
code,
|
||||
version,
|
||||
description,
|
||||
status,
|
||||
contentHtml: presetDocumentContentHtml[id],
|
||||
}
|
||||
}
|
||||
|
||||
// 预置文书为 2025 年在各科室采集的真实知情同意书原件(HTML 已固化在 preset-contents.ts),
|
||||
// 也可在 Word 编辑器中继续维护;科室自维护的新文书通过 Word 编辑器录入。
|
||||
export const documentLibrary: DocumentDepartment[] = [
|
||||
{
|
||||
key: 'medical-imaging',
|
||||
name: '医学影像科',
|
||||
campus: 'all',
|
||||
categories: [
|
||||
{
|
||||
name: '磁共振检查',
|
||||
documents: [
|
||||
createDocument(
|
||||
'doc-img-mri-001',
|
||||
'MRI 检查预约单暨知情同意书',
|
||||
'TPL-IMG-MRI-001',
|
||||
'v1.0.0',
|
||||
'含检查预约登记、病人检查准备及注意事项、婴幼儿检查特殊要求,背面为 MRI 安全筛查表(起搏器、金属植入物、孕产史等逐项勾选),需患者或家属签名确认。',
|
||||
),
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'medical-laboratory',
|
||||
name: '医学检验科',
|
||||
campus: 'all',
|
||||
categories: [
|
||||
{
|
||||
name: '基因检测告知',
|
||||
documents: [
|
||||
createDocument(
|
||||
'doc-lab-infection-gene-001',
|
||||
'感染基因高通量测序自费项目知情同意书',
|
||||
'TPL-LAB-IGT-001',
|
||||
'v1.0.0',
|
||||
'感染基因 mNGS 检测原理、局限性与潜在风险、受检者知情确认,附 3 项自费检测项目勾选表,自费项目需患者签字确认。',
|
||||
),
|
||||
createDocument(
|
||||
'doc-lab-coop-gene-001',
|
||||
'对外合作或自费基因检测项目知情同意书',
|
||||
'TPL-LAB-CGT-001',
|
||||
'v2025.06',
|
||||
'2025.6.24 版,覆盖医学检验科、生殖医学中心、宫内儿科疾病诊治中心、临床遗传中心、病理科共 60 项对外合作或自费检测项目,含收费金额、收费类别与执行科室,患方勾选后签署。',
|
||||
),
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'clinical-genetics',
|
||||
name: '临床遗传中心',
|
||||
campus: 'all',
|
||||
categories: [
|
||||
{
|
||||
name: '肿瘤基因检测',
|
||||
documents: [
|
||||
createDocument(
|
||||
'doc-gen-onco-gene-001',
|
||||
'肿瘤基因高通量测序自费项目知情同意书',
|
||||
'TPL-GEN-OGT-001',
|
||||
'v1.0.0',
|
||||
'肿瘤基因 NGS 分子检测原理、局限性与潜在风险、受检者知情确认,附 BRCA1/2 及 NGS 小/中/大 panel 共 4 项检测项目勾选表。',
|
||||
),
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
// Word 自维护方案:文书库初始为空,所有文书由科室通过 Word 编辑器(粘贴 / 导入 .docx)创建维护。
|
||||
export const documentLibrary: DocumentDepartment[] = []
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -1,7 +1,5 @@
|
||||
export type DocumentStatus = 'enabled' | 'draft' | 'archived' | 'review'
|
||||
|
||||
export type DocumentFilterStatus = 'all' | DocumentStatus
|
||||
|
||||
export type DocumentCampus = 'all' | '本部院区' | '东院区' | '西院区'
|
||||
|
||||
export interface LibraryDocument {
|
||||
@@ -49,10 +47,18 @@ export interface TemplateEditorForm {
|
||||
contentHtml: string
|
||||
}
|
||||
|
||||
export type DocumentFilterStatus = 'all' | DocumentStatus
|
||||
|
||||
export interface StatusTab {
|
||||
key: DocumentFilterStatus
|
||||
label: string
|
||||
count: number
|
||||
}
|
||||
|
||||
export interface DocumentFilterForm {
|
||||
keyword: string
|
||||
department: string | null
|
||||
status: DocumentStatus
|
||||
status: DocumentFilterStatus
|
||||
}
|
||||
|
||||
export interface DepartmentTab {
|
||||
@@ -60,9 +66,3 @@ export interface DepartmentTab {
|
||||
label: string
|
||||
count: number
|
||||
}
|
||||
|
||||
export interface StatusTab {
|
||||
key: DocumentStatus
|
||||
label: string
|
||||
count: number
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user