feat(signing): 签署任务固化文书 HTML 并交互化渲染
This commit is contained in:
Generated
-23
@@ -9,7 +9,6 @@
|
|||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ckeditor/ckeditor5-vue": "^8.2.0",
|
"@ckeditor/ckeditor5-vue": "^8.2.0",
|
||||||
"@tinymce/tinymce-vue": "^6.3.0",
|
|
||||||
"@tiptap/extension-color": "^2.27.3",
|
"@tiptap/extension-color": "^2.27.3",
|
||||||
"@tiptap/extension-font-family": "^2.27.3",
|
"@tiptap/extension-font-family": "^2.27.3",
|
||||||
"@tiptap/extension-table": "^2.27.3",
|
"@tiptap/extension-table": "^2.27.3",
|
||||||
@@ -29,7 +28,6 @@
|
|||||||
"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"
|
||||||
},
|
},
|
||||||
@@ -1605,21 +1603,6 @@
|
|||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@tinymce/tinymce-vue": {
|
|
||||||
"version": "6.3.0",
|
|
||||||
"resolved": "https://registry.npmmirror.com/@tinymce/tinymce-vue/-/tinymce-vue-6.3.0.tgz",
|
|
||||||
"integrity": "sha512-DSP8Jhd3XqCCliTnusfbmz3D8GqQ4iRzkc4aadYHDcJPVjkaqopJ61McOdH82CSy599vGLkPjGzqJYWJkRMiUA==",
|
|
||||||
"license": "MIT",
|
|
||||||
"peerDependencies": {
|
|
||||||
"tinymce": "^8.0.0 || ^7.0.0 || ^6.0.0 || ^5.5.1",
|
|
||||||
"vue": "^3.0.0"
|
|
||||||
},
|
|
||||||
"peerDependenciesMeta": {
|
|
||||||
"tinymce": {
|
|
||||||
"optional": true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@tiptap/core": {
|
"node_modules/@tiptap/core": {
|
||||||
"version": "2.27.3",
|
"version": "2.27.3",
|
||||||
"resolved": "https://registry.npmmirror.com/@tiptap/core/-/core-2.27.3.tgz",
|
"resolved": "https://registry.npmmirror.com/@tiptap/core/-/core-2.27.3.tgz",
|
||||||
@@ -7078,12 +7061,6 @@
|
|||||||
"url": "https://github.com/sponsors/SuperchupuDev"
|
"url": "https://github.com/sponsors/SuperchupuDev"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/tinymce": {
|
|
||||||
"version": "8.9.1",
|
|
||||||
"resolved": "https://registry.npmmirror.com/tinymce/-/tinymce-8.9.1.tgz",
|
|
||||||
"integrity": "sha512-5KrFiuKri2sPQW3ERWodsx3R0RZiw0cJJDr61qaMQiXO1ir3ZYaf6gVVaao+T29spZ9L9waaeE/co8Ov2Rn3vQ==",
|
|
||||||
"license": "SEE LICENSE IN license.md"
|
|
||||||
},
|
|
||||||
"node_modules/tippy.js": {
|
"node_modules/tippy.js": {
|
||||||
"version": "6.3.7",
|
"version": "6.3.7",
|
||||||
"resolved": "https://registry.npmmirror.com/tippy.js/-/tippy.js-6.3.7.tgz",
|
"resolved": "https://registry.npmmirror.com/tippy.js/-/tippy.js-6.3.7.tgz",
|
||||||
|
|||||||
@@ -14,7 +14,6 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ckeditor/ckeditor5-vue": "^8.2.0",
|
"@ckeditor/ckeditor5-vue": "^8.2.0",
|
||||||
"@tinymce/tinymce-vue": "^6.3.0",
|
|
||||||
"@tiptap/extension-color": "^2.27.3",
|
"@tiptap/extension-color": "^2.27.3",
|
||||||
"@tiptap/extension-font-family": "^2.27.3",
|
"@tiptap/extension-font-family": "^2.27.3",
|
||||||
"@tiptap/extension-table": "^2.27.3",
|
"@tiptap/extension-table": "^2.27.3",
|
||||||
@@ -34,7 +33,6 @@
|
|||||||
"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"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -946,6 +946,7 @@ export async function createSigningTask(input: CreateSigningTaskInput): Promise<
|
|||||||
updatedAt: formatMockDate(),
|
updatedAt: formatMockDate(),
|
||||||
rowVersion: 1,
|
rowVersion: 1,
|
||||||
backendStatus: 'WAITING_SIGN',
|
backendStatus: 'WAITING_SIGN',
|
||||||
|
documentContentHtml: input.documentContentHtml,
|
||||||
}
|
}
|
||||||
|
|
||||||
mockRecords.unshift(task)
|
mockRecords.unshift(task)
|
||||||
|
|||||||
@@ -60,6 +60,8 @@ export interface SigningTemplate {
|
|||||||
category: string
|
category: string
|
||||||
description: string
|
description: string
|
||||||
supportedMethods: SigningMethod[]
|
supportedMethods: SigningMethod[]
|
||||||
|
/** 原型:文书定稿 HTML 随模板携带,发起签署时进入任务快照。 */
|
||||||
|
contentHtml?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface WorkbenchSummary {
|
export interface WorkbenchSummary {
|
||||||
@@ -186,6 +188,8 @@ export interface SigningTaskRecord extends WorkbenchTask {
|
|||||||
signatureDataUrl?: string
|
signatureDataUrl?: string
|
||||||
signedAt?: string
|
signedAt?: string
|
||||||
voidReason?: string
|
voidReason?: string
|
||||||
|
/** 原型:任务创建时固化的文书 HTML(对应真实实现的模板版本快照 + contentSha256)。 */
|
||||||
|
documentContentHtml?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export type SigningTaskListResponse = PageResult<SigningTaskRecord>
|
export type SigningTaskListResponse = PageResult<SigningTaskRecord>
|
||||||
@@ -202,6 +206,8 @@ export interface CreateSigningTaskInput {
|
|||||||
visitType: VisitType
|
visitType: VisitType
|
||||||
visitNo: string
|
visitNo: string
|
||||||
department: string
|
department: string
|
||||||
|
/** 页面快照字段:仅 Mock 模式进入任务记录;真实接口不提交(后端按 templateVersionId 取版本快照)。 */
|
||||||
|
documentContentHtml?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
/** MEDISIGN 创建签署任务请求 DTO。不要向后端提交页面快照字段。 */
|
/** MEDISIGN 创建签署任务请求 DTO。不要向后端提交页面快照字段。 */
|
||||||
|
|||||||
@@ -271,6 +271,7 @@ export function useSigningTaskForm(options: UseSigningTaskFormOptions) {
|
|||||||
visitType: selectedVisit.value.type,
|
visitType: selectedVisit.value.type,
|
||||||
visitNo: selectedVisit.value.visitNo,
|
visitNo: selectedVisit.value.visitNo,
|
||||||
department: selectedVisit.value.department,
|
department: selectedVisit.value.department,
|
||||||
|
documentContentHtml: selectedTemplate.value.contentHtml,
|
||||||
})
|
})
|
||||||
|
|
||||||
if (!isSigningMockEnabled && method.value === 'sms') {
|
if (!isSigningMockEnabled && method.value === 'sms') {
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import type { CampusRecord, DepartmentRecord } from '@/api/management/types'
|
|||||||
|
|
||||||
import EditorCkPane from './editors/EditorCkPane.vue'
|
import EditorCkPane from './editors/EditorCkPane.vue'
|
||||||
import EditorTipTapPane from './editors/EditorTipTapPane.vue'
|
import EditorTipTapPane from './editors/EditorTipTapPane.vue'
|
||||||
import EditorTinyPane from './editors/EditorTinyPane.vue'
|
|
||||||
import EditorWangPane from './editors/EditorWangPane.vue'
|
import EditorWangPane from './editors/EditorWangPane.vue'
|
||||||
import type { DocumentTemplate, TemplateEditorForm } from '../types'
|
import type { DocumentTemplate, TemplateEditorForm } from '../types'
|
||||||
|
|
||||||
@@ -26,7 +25,7 @@ const emit = defineEmits<{
|
|||||||
// Word 自维护方案:科室把文书从 Word/WPS 直接粘贴进来(保留表格与基本格式),
|
// Word 自维护方案:科室把文书从 Word/WPS 直接粘贴进来(保留表格与基本格式),
|
||||||
// 或上传 .docx 由 mammoth 转换;签署时由交互管线识别"勾选"表并转为签署域。
|
// 或上传 .docx 由 mammoth 转换;签署时由交互管线识别"勾选"表并转为签署域。
|
||||||
// 富文本引擎做成可切换的三套(对比 Word 粘贴保真度),存储统一为编辑器产出的 HTML。
|
// 富文本引擎做成可切换的三套(对比 Word 粘贴保真度),存储统一为编辑器产出的 HTML。
|
||||||
type EditorEngine = 'wangeditor' | 'ckeditor5' | 'tinymce' | 'tiptap'
|
type EditorEngine = 'wangeditor' | 'ckeditor5' | 'tiptap'
|
||||||
|
|
||||||
interface EditorPaneHandle {
|
interface EditorPaneHandle {
|
||||||
getText: () => string
|
getText: () => string
|
||||||
@@ -36,7 +35,6 @@ interface EditorPaneHandle {
|
|||||||
const engineComponents = {
|
const engineComponents = {
|
||||||
wangeditor: EditorWangPane,
|
wangeditor: EditorWangPane,
|
||||||
ckeditor5: EditorCkPane,
|
ckeditor5: EditorCkPane,
|
||||||
tinymce: EditorTinyPane,
|
|
||||||
tiptap: EditorTipTapPane,
|
tiptap: EditorTipTapPane,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -51,11 +49,6 @@ const engineOptions: Array<{ key: EditorEngine; label: string; note: string }> =
|
|||||||
label: 'CKEditor 5',
|
label: 'CKEditor 5',
|
||||||
note: 'PasteFromOffice 插件,Word 粘贴保真最强(GPL2)',
|
note: 'PasteFromOffice 插件,Word 粘贴保真最强(GPL2)',
|
||||||
},
|
},
|
||||||
{
|
|
||||||
key: 'tinymce',
|
|
||||||
label: 'TinyMCE',
|
|
||||||
note: '均衡之选(MIT)',
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
key: 'tiptap',
|
key: 'tiptap',
|
||||||
label: 'TipTap',
|
label: 'TipTap',
|
||||||
|
|||||||
@@ -1,82 +0,0 @@
|
|||||||
<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 中的文书内容直接粘贴到此处(保留表格与基本格式)。',
|
|
||||||
// TinyMCE 8:paste 已并入核心,不能再声明(声明会导致插件加载失败)。
|
|
||||||
plugins: 'lists table',
|
|
||||||
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,
|
|
||||||
// tinymce-vue 不派发 init 事件,编辑器实例只能从这里拿。
|
|
||||||
init_instance_callback: (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)"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.tiny-pane {
|
|
||||||
overflow: hidden;
|
|
||||||
border: 1px solid var(--line);
|
|
||||||
border-radius: 8px;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -247,6 +247,7 @@ function toSigningTemplate(template: DocumentTemplate): SigningTemplate {
|
|||||||
category: template.category,
|
category: template.category,
|
||||||
description: template.description,
|
description: template.description,
|
||||||
supportedMethods: ['pad', 'sms'],
|
supportedMethods: ['pad', 'sms'],
|
||||||
|
contentHtml: template.contentHtml,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,310 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, ref } from 'vue'
|
||||||
|
|
||||||
|
import type { SigningTaskRecord } from '@/api/workbench/types'
|
||||||
|
|
||||||
|
// 签署文书交互化渲染(原型管线):
|
||||||
|
// ① 占位符填充({患者姓名}等 → 任务患者快照数据)
|
||||||
|
// ② 勾选表识别:表头含"勾选"列的表格,逐行转为可交互复选框
|
||||||
|
// ③ 签名域:任务已有签名图(signatureDataUrl)时替换 {患者签名}
|
||||||
|
// 真实实现中填充在后端完成,勾选结果随签署证据提交;本组件用于前端闭环验证。
|
||||||
|
const props = withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
task: SigningTaskRecord
|
||||||
|
interactive?: boolean
|
||||||
|
}>(),
|
||||||
|
{ interactive: false },
|
||||||
|
)
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'selection-change': [items: string[]]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const containerRef = ref<HTMLElement | null>(null)
|
||||||
|
const selectedItems = ref<string[]>([])
|
||||||
|
|
||||||
|
function replaceTextPlaceholder(root: Element, placeholder: string, replacement: string) {
|
||||||
|
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT)
|
||||||
|
const targets: Text[] = []
|
||||||
|
let node = walker.nextNode()
|
||||||
|
|
||||||
|
while (node) {
|
||||||
|
if ((node.textContent ?? '').includes(placeholder)) {
|
||||||
|
targets.push(node as Text)
|
||||||
|
}
|
||||||
|
node = walker.nextNode()
|
||||||
|
}
|
||||||
|
|
||||||
|
targets.forEach((textNode) => {
|
||||||
|
textNode.textContent = textNode.textContent?.split(placeholder).join(replacement) ?? ''
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function replaceSignaturePlaceholder(root: Element, dataUrl: string) {
|
||||||
|
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT)
|
||||||
|
const targets: Text[] = []
|
||||||
|
let node = walker.nextNode()
|
||||||
|
|
||||||
|
while (node) {
|
||||||
|
if ((node.textContent ?? '').includes('{患者签名}')) {
|
||||||
|
targets.push(node as Text)
|
||||||
|
}
|
||||||
|
node = walker.nextNode()
|
||||||
|
}
|
||||||
|
|
||||||
|
targets.forEach((textNode) => {
|
||||||
|
const parts = (textNode.textContent ?? '').split('{患者签名}')
|
||||||
|
const fragment = document.createDocumentFragment()
|
||||||
|
|
||||||
|
if (parts[0]) {
|
||||||
|
fragment.appendChild(document.createTextNode(parts[0]))
|
||||||
|
}
|
||||||
|
|
||||||
|
const image = document.createElement('img')
|
||||||
|
image.src = dataUrl
|
||||||
|
image.alt = '患者签名'
|
||||||
|
image.className = 'sign-doc-signature'
|
||||||
|
fragment.appendChild(image)
|
||||||
|
|
||||||
|
if (parts[1]) {
|
||||||
|
fragment.appendChild(document.createTextNode(parts[1]))
|
||||||
|
}
|
||||||
|
|
||||||
|
textNode.replaceWith(fragment)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function fillPatientPlaceholders(root: Element) {
|
||||||
|
const task = props.task
|
||||||
|
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT)
|
||||||
|
const targets: Text[] = []
|
||||||
|
let node = walker.nextNode()
|
||||||
|
|
||||||
|
while (node) {
|
||||||
|
if ((node.textContent ?? '').includes('{')) {
|
||||||
|
targets.push(node as Text)
|
||||||
|
}
|
||||||
|
node = walker.nextNode()
|
||||||
|
}
|
||||||
|
|
||||||
|
targets.forEach((textNode) => {
|
||||||
|
textNode.textContent = (textNode.textContent ?? '')
|
||||||
|
.replace(/{患者姓名}/g, task.patientName)
|
||||||
|
.replace(/{性别}/g, task.sex)
|
||||||
|
.replace(/{年龄}/g, `${task.age} 岁`)
|
||||||
|
.replace(/{就诊号}/g, task.visitNo)
|
||||||
|
.replace(/{联系电话}/g, '—')
|
||||||
|
.replace(/{预约日期}/g, task.visitDate)
|
||||||
|
.replace(/{与患者关系}/g, '本人')
|
||||||
|
.replace(/{签署日期}/g, task.signedAt || task.visitDate || ' 年 月 日')
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function convertCheckboxes(root: Element) {
|
||||||
|
root.querySelectorAll('table').forEach((table) => {
|
||||||
|
const headerCells = Array.from(table.querySelectorAll('thead th'))
|
||||||
|
const checkIndex = headerCells.findIndex((cell) => cell.textContent?.includes('勾选'))
|
||||||
|
|
||||||
|
if (checkIndex === -1) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const nameIndex = headerCells.findIndex((cell) => cell.textContent?.includes('项目'))
|
||||||
|
table.classList.add('sign-doc-table')
|
||||||
|
|
||||||
|
table.querySelectorAll('tbody tr').forEach((row) => {
|
||||||
|
const cells = Array.from(row.querySelectorAll('td'))
|
||||||
|
const checkCell = cells[checkIndex]
|
||||||
|
|
||||||
|
if (!checkCell) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const nameCell = cells[nameIndex > -1 ? nameIndex : 1]
|
||||||
|
const itemName = (nameCell?.textContent ?? '').trim()
|
||||||
|
|
||||||
|
const checkbox = document.createElement('input')
|
||||||
|
checkbox.type = 'checkbox'
|
||||||
|
checkbox.className = 'sign-doc-checkbox'
|
||||||
|
checkbox.setAttribute('data-sign-item', itemName)
|
||||||
|
checkbox.disabled = !props.interactive
|
||||||
|
|
||||||
|
checkCell.textContent = ''
|
||||||
|
checkCell.appendChild(checkbox)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const processedHtml = computed(() => {
|
||||||
|
const source = props.task.documentContentHtml
|
||||||
|
|
||||||
|
if (!source) {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
const doc = new DOMParser().parseFromString(`<div id="sign-doc-root">${source}</div>`, 'text/html')
|
||||||
|
const root = doc.getElementById('sign-doc-root')
|
||||||
|
|
||||||
|
if (!root) {
|
||||||
|
return source
|
||||||
|
}
|
||||||
|
|
||||||
|
fillPatientPlaceholders(root)
|
||||||
|
|
||||||
|
if (props.task.signatureDataUrl) {
|
||||||
|
replaceSignaturePlaceholder(root, props.task.signatureDataUrl)
|
||||||
|
} else {
|
||||||
|
replaceTextPlaceholder(root, '{患者签名}', '(待患者签署)')
|
||||||
|
}
|
||||||
|
|
||||||
|
convertCheckboxes(root)
|
||||||
|
|
||||||
|
return root.innerHTML
|
||||||
|
})
|
||||||
|
|
||||||
|
function handleSelectionChange(event: Event) {
|
||||||
|
const target = event.target as HTMLInputElement
|
||||||
|
|
||||||
|
if (!target.classList.contains('sign-doc-checkbox')) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const container = containerRef.value
|
||||||
|
const checked = container
|
||||||
|
? Array.from(container.querySelectorAll<HTMLInputElement>('input.sign-doc-checkbox:checked'))
|
||||||
|
: []
|
||||||
|
|
||||||
|
selectedItems.value = checked.map((item) => item.getAttribute('data-sign-item') ?? '')
|
||||||
|
emit('selection-change', [...selectedItems.value])
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<article class="sign-doc">
|
||||||
|
<div class="sign-doc-banner">
|
||||||
|
<span>已按任务固化的文书版本渲染(占位符已填充、"勾选"表已交互化)</span>
|
||||||
|
<span class="sign-doc-state">
|
||||||
|
{{ task.signatureDataUrl ? '已采集签名' : task.status === 'signed' ? '已签署' : '待患者签署' }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- eslint-disable vue/no-v-html -- 内容为本系统定稿的文书 HTML(编辑器产出、仅医务人员可改),非任意用户输入 -->
|
||||||
|
<div
|
||||||
|
ref="containerRef"
|
||||||
|
class="sign-doc-body"
|
||||||
|
@change="handleSelectionChange"
|
||||||
|
v-html="processedHtml"
|
||||||
|
/>
|
||||||
|
<!-- eslint-enable vue/no-v-html -->
|
||||||
|
|
||||||
|
<footer v-if="interactive" class="sign-doc-footer">
|
||||||
|
<span>
|
||||||
|
已勾选 <strong>{{ selectedItems.length }}</strong> 项{{ selectedItems.length ? `:${selectedItems.join('、')}` : '' }}
|
||||||
|
</span>
|
||||||
|
<span>完成勾选后通过「手写板签署 / 线上签署」采集签名,勾选结果将随签署证据一并提交。</span>
|
||||||
|
</footer>
|
||||||
|
</article>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.sign-doc {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 480px;
|
||||||
|
background: #fff;
|
||||||
|
border-radius: var(--r);
|
||||||
|
box-shadow: var(--sh);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sign-doc-banner {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 9px 16px;
|
||||||
|
color: var(--brand);
|
||||||
|
font-size: 11.5px;
|
||||||
|
background: var(--brand-l);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sign-doc-state {
|
||||||
|
padding: 2px 9px;
|
||||||
|
color: #fff;
|
||||||
|
font-weight: 700;
|
||||||
|
background: var(--brand);
|
||||||
|
border-radius: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sign-doc-body {
|
||||||
|
flex: 1;
|
||||||
|
padding: 26px 32px 30px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sign-doc-footer {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 10px 16px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--mut);
|
||||||
|
background: #f8fbfc;
|
||||||
|
border-top: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sign-doc-footer strong {
|
||||||
|
color: var(--brand);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 注入的文书 HTML(v-html 内容不携带 scoped 属性,统一用 :deep 覆盖基础文书样式) */
|
||||||
|
.sign-doc-body :deep(p) {
|
||||||
|
margin: 0 0 6px;
|
||||||
|
font-size: 13.5px;
|
||||||
|
line-height: 1.85;
|
||||||
|
color: #1c2b33;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sign-doc-body :deep(table) {
|
||||||
|
width: 100%;
|
||||||
|
margin: 8px 0 12px;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: 12.5px;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sign-doc-body :deep(th),
|
||||||
|
.sign-doc-body :deep(td) {
|
||||||
|
padding: 5px 7px;
|
||||||
|
border: 1px solid #52646e;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sign-doc-body :deep(th) {
|
||||||
|
font-weight: 700;
|
||||||
|
background: #f2f6f8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sign-doc-body :deep(img) {
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sign-doc-body :deep(.sign-doc-checkbox) {
|
||||||
|
width: 15px;
|
||||||
|
height: 15px;
|
||||||
|
accent-color: var(--brand);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sign-doc-body :deep(.sign-doc-checkbox:disabled) {
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.55;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sign-doc-body :deep(.sign-doc-signature) {
|
||||||
|
max-width: 150px;
|
||||||
|
max-height: 52px;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { SigningArtifact, SigningTaskEvent, SigningTaskRecord } from '@/api/workbench/types'
|
import type { SigningArtifact, SigningTaskEvent, SigningTaskRecord } from '@/api/workbench/types'
|
||||||
|
|
||||||
|
import SigningDocumentInteractive from './SigningDocumentInteractive.vue'
|
||||||
import SigningDocumentPreview from './SigningDocumentPreview.vue'
|
import SigningDocumentPreview from './SigningDocumentPreview.vue'
|
||||||
import SigningArtifactList from './SigningArtifactList.vue'
|
import SigningArtifactList from './SigningArtifactList.vue'
|
||||||
import TaskAuditTimeline from './TaskAuditTimeline.vue'
|
import TaskAuditTimeline from './TaskAuditTimeline.vue'
|
||||||
@@ -138,7 +139,13 @@ const emit = defineEmits<{
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<SigningDocumentPreview :task="task" />
|
<!-- 任务创建时固化了文书 HTML 的走交互化渲染;老任务回退到静态纸样预览 -->
|
||||||
|
<SigningDocumentInteractive
|
||||||
|
v-if="task.documentContentHtml"
|
||||||
|
:task="task"
|
||||||
|
:interactive="allowLocalSigning && task.status !== 'signed' && task.status !== 'void'"
|
||||||
|
/>
|
||||||
|
<SigningDocumentPreview v-else :task="task" />
|
||||||
<TaskAuditTimeline :events="auditEvents" :loading="auditLoading" :error="auditError" />
|
<TaskAuditTimeline :events="auditEvents" :loading="auditLoading" :error="auditError" />
|
||||||
<SigningArtifactList
|
<SigningArtifactList
|
||||||
:artifacts="artifacts"
|
:artifacts="artifacts"
|
||||||
|
|||||||
Reference in New Issue
Block a user