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"
|
"format:check": "prettier . --check"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@ckeditor/ckeditor5-vue": "^8.2.0",
|
||||||
|
"@tinymce/tinymce-vue": "^6.3.0",
|
||||||
"@wangeditor/editor": "^5.1.23",
|
"@wangeditor/editor": "^5.1.23",
|
||||||
"@wangeditor/editor-for-vue": "^5.1.12",
|
"@wangeditor/editor-for-vue": "^5.1.12",
|
||||||
"axios": "^1.20.0",
|
"axios": "^1.20.0",
|
||||||
|
"ckeditor5": "^48.5.0",
|
||||||
"element-plus": "^2.14.5",
|
"element-plus": "^2.14.5",
|
||||||
"mammoth": "^1.12.2",
|
"mammoth": "^1.12.2",
|
||||||
"pinia": "^4.0.3",
|
"pinia": "^4.0.3",
|
||||||
|
"tinymce": "^8.9.1",
|
||||||
"vue": "^3.5.41",
|
"vue": "^3.5.41",
|
||||||
"vue-router": "^5.2.0"
|
"vue-router": "^5.2.0"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,16 +1,16 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { DepartmentTab, DocumentStatus, StatusTab } from '../types'
|
import type { DepartmentTab, DocumentFilterStatus, StatusTab } from '../types'
|
||||||
|
|
||||||
defineProps<{
|
defineProps<{
|
||||||
department: string | null
|
department: string | null
|
||||||
status: DocumentStatus
|
status: DocumentFilterStatus
|
||||||
departments: DepartmentTab[]
|
departments: DepartmentTab[]
|
||||||
statuses: StatusTab[]
|
statuses: StatusTab[]
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
'update:department': [value: string | null]
|
'update:department': [value: string | null]
|
||||||
'update:status': [value: DocumentStatus]
|
'update:status': [value: DocumentFilterStatus]
|
||||||
}>()
|
}>()
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -23,6 +23,47 @@ const statusLabel = computed(() => {
|
|||||||
|
|
||||||
return documentStatusLabels[props.template.status]
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -58,7 +99,7 @@ const statusLabel = computed(() => {
|
|||||||
<iframe
|
<iframe
|
||||||
v-else-if="template.contentHtml"
|
v-else-if="template.contentHtml"
|
||||||
class="preview-frame"
|
class="preview-frame"
|
||||||
:srcdoc="template.contentHtml"
|
:srcdoc="previewSrcDoc"
|
||||||
sandbox=""
|
sandbox=""
|
||||||
title="文档模板内容预览"
|
title="文档模板内容预览"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ const emit = defineEmits<{
|
|||||||
preview: [template: DocumentTemplate]
|
preview: [template: DocumentTemplate]
|
||||||
edit: [template: DocumentTemplate]
|
edit: [template: DocumentTemplate]
|
||||||
initiate: [template: DocumentTemplate]
|
initiate: [template: DocumentTemplate]
|
||||||
|
remove: [template: DocumentTemplate]
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const statusLabel = computed(() => documentStatusLabels[props.template.status])
|
const statusLabel = computed(() => documentStatusLabels[props.template.status])
|
||||||
@@ -49,6 +50,14 @@ const statusLabel = computed(() => documentStatusLabels[props.template.status])
|
|||||||
>
|
>
|
||||||
发起
|
发起
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="card-button danger"
|
||||||
|
title="删除此文书"
|
||||||
|
@click="emit('remove', template)"
|
||||||
|
>
|
||||||
|
删除
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
@@ -224,6 +233,15 @@ const statusLabel = computed(() => documentStatusLabels[props.template.status])
|
|||||||
background: #f1f5f6;
|
background: #f1f5f6;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.card-button.danger {
|
||||||
|
color: #b3442f;
|
||||||
|
border-color: #cf7c68;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-button.danger:hover {
|
||||||
|
background: #fbeee9;
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 680px) {
|
@media (max-width: 680px) {
|
||||||
.template-footer {
|
.template-footer {
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
|
|||||||
@@ -1,12 +1,11 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import '@wangeditor/editor/dist/css/style.css'
|
import { reactive, ref, watch } from 'vue'
|
||||||
|
|
||||||
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 { 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'
|
import type { DocumentTemplate, TemplateEditorForm } from '../types'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
@@ -24,21 +23,45 @@ const emit = defineEmits<{
|
|||||||
|
|
||||||
// Word 自维护方案:科室把文书从 Word/WPS 直接粘贴进来(保留表格与基本格式),
|
// Word 自维护方案:科室把文书从 Word/WPS 直接粘贴进来(保留表格与基本格式),
|
||||||
// 或上传 .docx 由 mammoth 转换;签署时由交互管线识别"勾选"表并转为签署域。
|
// 或上传 .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 valueHtml = ref('<p><br></p>')
|
||||||
const editorMode = 'default'
|
|
||||||
const docxInput = ref<HTMLInputElement | null>(null)
|
const docxInput = ref<HTMLInputElement | null>(null)
|
||||||
const importing = ref(false)
|
const importing = ref(false)
|
||||||
const importMessage = ref('')
|
const importMessage = ref('')
|
||||||
|
const paneRef = ref<EditorPaneHandle | null>(null)
|
||||||
const toolbarConfig: Partial<IToolbarConfig> = {
|
|
||||||
excludeKeys: ['group-video', 'fullScreen'],
|
|
||||||
}
|
|
||||||
|
|
||||||
const editorConfig: Partial<IEditorConfig> = {
|
|
||||||
placeholder:
|
|
||||||
'将 Word / WPS 中的文书内容直接粘贴到此处(保留表格与基本格式),或点击上方「导入 .docx」自动转换。',
|
|
||||||
}
|
|
||||||
|
|
||||||
// 占位符在签署时由系统按患者与就诊信息自动填充;签署交互管线同时会识别"勾选"表格。
|
// 占位符在签署时由系统按患者与就诊信息自动填充;签署交互管线同时会识别"勾选"表格。
|
||||||
const placeholders = [
|
const placeholders = [
|
||||||
@@ -102,13 +125,13 @@ function handleCampusChange() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleCreated(editor: IDomEditor) {
|
function selectEngine(next: EditorEngine) {
|
||||||
editorRef.value = editor
|
engine.value = next
|
||||||
|
engineNote.value = engineOptions.find((option) => option.key === next)?.note ?? ''
|
||||||
}
|
}
|
||||||
|
|
||||||
function insertPlaceholder(text: string) {
|
function insertPlaceholder(text: string) {
|
||||||
// slate 文本节点:{ text };由编辑器在当前光标处插入。
|
paneRef.value?.insertText(text)
|
||||||
editorRef.value?.insertNode({ text })
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleDocxChange(event: Event) {
|
async function handleDocxChange(event: Event) {
|
||||||
@@ -139,7 +162,7 @@ async function handleDocxChange(event: Event) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function save() {
|
function save() {
|
||||||
const text = editorRef.value?.getText().trim()
|
const text = paneRef.value?.getText() ?? ''
|
||||||
|
|
||||||
if (!form.name.trim()) {
|
if (!form.name.trim()) {
|
||||||
importMessage.value = '请填写文书名称'
|
importMessage.value = '请填写文书名称'
|
||||||
@@ -154,10 +177,6 @@ function save() {
|
|||||||
form.contentHtml = valueHtml.value
|
form.contentHtml = valueHtml.value
|
||||||
emit('save', { ...form, contentHtml: valueHtml.value })
|
emit('save', { ...form, contentHtml: valueHtml.value })
|
||||||
}
|
}
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
|
||||||
editorRef.value?.destroy()
|
|
||||||
})
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -242,6 +261,20 @@ onBeforeUnmount(() => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="editor-shell">
|
<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">
|
<div class="docx-bar">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -265,18 +298,12 @@ onBeforeUnmount(() => {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="editor-area">
|
<div class="editor-area">
|
||||||
<Toolbar
|
<component
|
||||||
class="editor-toolbar"
|
:is="engineComponents[engine]"
|
||||||
:editor="editorRef"
|
:key="engine"
|
||||||
:default-config="toolbarConfig"
|
ref="paneRef"
|
||||||
:mode="editorMode"
|
:model-value="valueHtml"
|
||||||
/>
|
@update:model-value="valueHtml = $event"
|
||||||
<Editor
|
|
||||||
v-model="valueHtml"
|
|
||||||
class="editor-content"
|
|
||||||
:default-config="editorConfig"
|
|
||||||
:mode="editorMode"
|
|
||||||
@on-created="handleCreated"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -478,24 +505,52 @@ onBeforeUnmount(() => {
|
|||||||
font-size: 12px;
|
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 {
|
.editor-area {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-height: 380px;
|
min-height: 420px;
|
||||||
overflow: hidden;
|
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 {
|
.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">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted, reactive, ref } from 'vue'
|
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 NewSigningTaskDialog from '@/components/signing/NewSigningTaskDialog.vue'
|
||||||
import type { SigningTemplate } from '@/api/workbench/types'
|
import type { SigningTemplate } from '@/api/workbench/types'
|
||||||
@@ -50,7 +50,7 @@ const departments: DepartmentRecord[] = ['医学影像科', '医学检验科', '
|
|||||||
const filters = reactive<DocumentFilterForm>({
|
const filters = reactive<DocumentFilterForm>({
|
||||||
keyword: '',
|
keyword: '',
|
||||||
department: null,
|
department: null,
|
||||||
status: 'enabled',
|
status: 'all',
|
||||||
})
|
})
|
||||||
|
|
||||||
const allTemplates = computed<DocumentTemplate[]>(() =>
|
const allTemplates = computed<DocumentTemplate[]>(() =>
|
||||||
@@ -76,19 +76,21 @@ const departmentTabs = computed<DepartmentTab[]>(() => [
|
|||||||
})),
|
})),
|
||||||
])
|
])
|
||||||
|
|
||||||
const statusTabs = computed<StatusTab[]>(() =>
|
const statusTabs = computed<StatusTab[]>(() => [
|
||||||
documentStatusOptions.map((option) => ({
|
{ key: 'all', label: '全部', count: allTemplates.value.length },
|
||||||
|
...documentStatusOptions.map((option) => ({
|
||||||
...option,
|
...option,
|
||||||
count: allTemplates.value.filter((template) => template.status === option.key).length,
|
count: allTemplates.value.filter((template) => template.status === option.key).length,
|
||||||
})),
|
})),
|
||||||
)
|
])
|
||||||
|
|
||||||
const filteredTemplates = computed(() => {
|
const filteredTemplates = computed(() => {
|
||||||
const keyword = filters.keyword.trim().toLowerCase()
|
const keyword = filters.keyword.trim().toLowerCase()
|
||||||
|
|
||||||
return allTemplates.value.filter((template) => {
|
return allTemplates.value.filter((template) => {
|
||||||
const matchesDepartment = !filters.department || template.departmentKey === filters.department
|
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 =
|
const matchesKeyword =
|
||||||
!keyword ||
|
!keyword ||
|
||||||
[template.name, template.code, template.department].join(' ').toLowerCase().includes(keyword)
|
[template.name, template.code, template.department].join(' ').toLowerCase().includes(keyword)
|
||||||
@@ -169,7 +171,8 @@ function addLocalTemplate(form: TemplateEditorForm) {
|
|||||||
name: form.name,
|
name: form.name,
|
||||||
code: form.templateCode,
|
code: form.templateCode,
|
||||||
version: form.versionNo || 'v1',
|
version: form.versionNo || 'v1',
|
||||||
status: 'draft',
|
// 原型闭环:新文书直接启用(工作流 UI 待签署端接入后恢复),保证创建后即可预览/发起。
|
||||||
|
status: 'enabled',
|
||||||
description: form.description,
|
description: form.description,
|
||||||
versionId: `local-version-${timestamp}`,
|
versionId: `local-version-${timestamp}`,
|
||||||
departmentId: form.departmentId || null,
|
departmentId: form.departmentId || null,
|
||||||
@@ -200,6 +203,35 @@ function saveTemplate(form: TemplateEditorForm) {
|
|||||||
ElMessage.success(form.id ? '文书已更新' : '文书已创建')
|
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 {
|
function toSigningTemplate(template: DocumentTemplate): SigningTemplate {
|
||||||
@@ -265,20 +297,26 @@ onMounted(loadLibrary)
|
|||||||
@preview="handlePreview"
|
@preview="handlePreview"
|
||||||
@edit="showEditMessage"
|
@edit="showEditMessage"
|
||||||
@initiate="openSigningDialog"
|
@initiate="openSigningDialog"
|
||||||
|
@remove="removeTemplate"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else class="empty-state">
|
<div v-else class="empty-state">
|
||||||
<div class="empty-icon" aria-hidden="true">⌕</div>
|
<div class="empty-icon" aria-hidden="true">⌕</div>
|
||||||
<p>没有符合条件的模板</p>
|
<template v-if="allTemplates.length === 0">
|
||||||
<button
|
<p>文书库为空,点击右上角「新增文档」用 Word 创建第一份文书</p>
|
||||||
v-if="filters.keyword || filters.department"
|
</template>
|
||||||
type="button"
|
<template v-else>
|
||||||
class="reset-button"
|
<p>没有符合条件的文书</p>
|
||||||
@click="clearFilters"
|
<button
|
||||||
>
|
v-if="filters.keyword || filters.department"
|
||||||
清除筛选
|
type="button"
|
||||||
</button>
|
class="reset-button"
|
||||||
|
@click="clearFilters"
|
||||||
|
>
|
||||||
|
清除筛选
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<DocumentPreviewDialog
|
<DocumentPreviewDialog
|
||||||
|
|||||||
@@ -1,6 +1,4 @@
|
|||||||
import type { DocumentDepartment, DocumentStatus, LibraryDocument } from './types'
|
import type { DocumentDepartment, DocumentStatus } from './types'
|
||||||
|
|
||||||
import { presetDocumentContentHtml } from './preset-contents'
|
|
||||||
|
|
||||||
const documentStatusLabels: Record<DocumentStatus, string> = {
|
const documentStatusLabels: Record<DocumentStatus, string> = {
|
||||||
enabled: '启用中',
|
enabled: '启用中',
|
||||||
@@ -16,90 +14,5 @@ export const documentStatusOptions = Object.entries(documentStatusLabels).map(([
|
|||||||
|
|
||||||
export { documentStatusLabels }
|
export { documentStatusLabels }
|
||||||
|
|
||||||
function createDocument(
|
// Word 自维护方案:文书库初始为空,所有文书由科室通过 Word 编辑器(粘贴 / 导入 .docx)创建维护。
|
||||||
id: string,
|
export const documentLibrary: DocumentDepartment[] = []
|
||||||
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 项检测项目勾选表。',
|
|
||||||
),
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -1,7 +1,5 @@
|
|||||||
export type DocumentStatus = 'enabled' | 'draft' | 'archived' | 'review'
|
export type DocumentStatus = 'enabled' | 'draft' | 'archived' | 'review'
|
||||||
|
|
||||||
export type DocumentFilterStatus = 'all' | DocumentStatus
|
|
||||||
|
|
||||||
export type DocumentCampus = 'all' | '本部院区' | '东院区' | '西院区'
|
export type DocumentCampus = 'all' | '本部院区' | '东院区' | '西院区'
|
||||||
|
|
||||||
export interface LibraryDocument {
|
export interface LibraryDocument {
|
||||||
@@ -49,10 +47,18 @@ export interface TemplateEditorForm {
|
|||||||
contentHtml: string
|
contentHtml: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type DocumentFilterStatus = 'all' | DocumentStatus
|
||||||
|
|
||||||
|
export interface StatusTab {
|
||||||
|
key: DocumentFilterStatus
|
||||||
|
label: string
|
||||||
|
count: number
|
||||||
|
}
|
||||||
|
|
||||||
export interface DocumentFilterForm {
|
export interface DocumentFilterForm {
|
||||||
keyword: string
|
keyword: string
|
||||||
department: string | null
|
department: string | null
|
||||||
status: DocumentStatus
|
status: DocumentFilterStatus
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DepartmentTab {
|
export interface DepartmentTab {
|
||||||
@@ -60,9 +66,3 @@ export interface DepartmentTab {
|
|||||||
label: string
|
label: string
|
||||||
count: number
|
count: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface StatusTab {
|
|
||||||
key: DocumentStatus
|
|
||||||
label: string
|
|
||||||
count: number
|
|
||||||
}
|
|
||||||
|
|||||||
Reference in New Issue
Block a user