feat(签署): 支持文书填写项与签名回显
This commit is contained in:
@@ -1,7 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { computed, nextTick, ref, watch } from 'vue'
|
||||
|
||||
import { buildSigningDocumentHtml } from '@/utils/signing-document'
|
||||
import {
|
||||
buildSigningDocumentHtml,
|
||||
describeFieldProgress,
|
||||
readAnswersFromRoot,
|
||||
type SigningFieldProgress,
|
||||
} from '@/utils/signing-document'
|
||||
|
||||
import { documentStatusLabels, signingPreviewPatient } from '../mock'
|
||||
import type { DocumentTemplate } from '../types'
|
||||
@@ -22,14 +27,30 @@ const emit = defineEmits<{
|
||||
const activeTab = ref<'content' | 'signing'>('content')
|
||||
/** 签署预览里科室试勾选的结果,仅用于预览反馈,不作为签署证据 */
|
||||
const signingSelection = ref<string[]>([])
|
||||
/** 签署预览里填写项的完成情况,同样只用于预览反馈 */
|
||||
const signingProgress = ref<SigningFieldProgress>({
|
||||
total: 0,
|
||||
filled: 0,
|
||||
missingRequired: [],
|
||||
abnormal: [],
|
||||
})
|
||||
const signingBodyRef = ref<HTMLElement | null>(null)
|
||||
|
||||
// 切到签署预览时才挂载文书 DOM,挂载后再统计填写项,否则进页面会显示「0 / 0 项」
|
||||
watch(activeTab, async (tab) => {
|
||||
if (tab === 'signing') {
|
||||
await nextTick()
|
||||
syncPreviewFields()
|
||||
}
|
||||
})
|
||||
|
||||
watch(
|
||||
() => props.visible,
|
||||
(visible) => {
|
||||
if (visible) {
|
||||
activeTab.value = 'content'
|
||||
signingSelection.value = []
|
||||
signingProgress.value = { total: 0, filled: 0, missingRequired: [], abnormal: [] }
|
||||
}
|
||||
},
|
||||
)
|
||||
@@ -98,20 +119,19 @@ const signingPreviewHtml = computed(() =>
|
||||
buildSigningDocumentHtml(props.template?.contentHtml ?? '', signingPreviewPatient, true),
|
||||
)
|
||||
|
||||
// 勾选汇总只在预览内展示(切页签、重开弹窗即重置),不作为签署证据。
|
||||
function handleSigningSelectionChange(event: Event) {
|
||||
const target = event.target as HTMLInputElement
|
||||
// 填写项与勾选汇总只在预览内展示(切页签、重开弹窗即重置),不作为签署证据。
|
||||
function syncPreviewFields() {
|
||||
const body = signingBodyRef.value
|
||||
|
||||
if (!target.classList.contains('sign-doc-checkbox')) {
|
||||
if (!body) {
|
||||
return
|
||||
}
|
||||
|
||||
const body = signingBodyRef.value
|
||||
const checked = body
|
||||
? Array.from(body.querySelectorAll<HTMLInputElement>('input.sign-doc-checkbox:checked'))
|
||||
: []
|
||||
|
||||
signingSelection.value = checked.map((input) => input.getAttribute('data-sign-item') ?? '')
|
||||
const answers = readAnswersFromRoot(body)
|
||||
signingProgress.value = describeFieldProgress(body, answers)
|
||||
signingSelection.value = Array.from(
|
||||
body.querySelectorAll<HTMLInputElement>('input.sign-doc-checkbox:checked'),
|
||||
).map((input) => input.getAttribute('data-sign-item') ?? '')
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -179,14 +199,22 @@ function handleSigningSelectionChange(event: Event) {
|
||||
<div
|
||||
ref="signingBodyRef"
|
||||
class="signing-preview"
|
||||
@change="handleSigningSelectionChange"
|
||||
@change="syncPreviewFields"
|
||||
@input="syncPreviewFields"
|
||||
v-html="signingPreviewHtml"
|
||||
/>
|
||||
<!-- eslint-enable vue/no-v-html -->
|
||||
<p class="signing-selection">
|
||||
已勾选 <strong>{{ signingSelection.length }}</strong> 项{{
|
||||
signingSelection.length ? `:${signingSelection.join('、')}` : ''
|
||||
}}
|
||||
填写项 <strong>{{ signingProgress.filled }}</strong> /
|
||||
{{ signingProgress.total }} 项已填<template v-if="signingSelection.length">
|
||||
・已勾选 <strong>{{ signingSelection.length }}</strong> 项:{{
|
||||
signingSelection.join('、')
|
||||
}}</template
|
||||
><template v-if="signingProgress.abnormal.length">
|
||||
・异常项:<strong class="is-abnormal">{{
|
||||
signingProgress.abnormal.join('、')
|
||||
}}</strong></template
|
||||
>
|
||||
</p>
|
||||
</template>
|
||||
<iframe
|
||||
@@ -473,6 +501,101 @@ function handleSigningSelectionChange(event: Event) {
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
/* 填写项:签署预览里按签署时的控件外观渲染,样式与签署端保持一致 */
|
||||
.signing-preview :deep(.sign-field) {
|
||||
display: inline-flex;
|
||||
gap: 4px;
|
||||
align-items: center;
|
||||
padding: 2px 7px;
|
||||
margin: 0 2px;
|
||||
vertical-align: middle;
|
||||
background: #f7fbfd;
|
||||
border: 1px solid #d4e3e8;
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
.signing-preview :deep(.sign-field__label) {
|
||||
flex-shrink: 0;
|
||||
color: #587786;
|
||||
font-size: 11.5px;
|
||||
}
|
||||
|
||||
.signing-preview :deep(.sign-field__required) {
|
||||
margin-left: 2px;
|
||||
color: var(--err);
|
||||
}
|
||||
|
||||
.signing-preview :deep(.sign-field__control) {
|
||||
display: inline-flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.signing-preview :deep(.sign-field__input) {
|
||||
width: 9em;
|
||||
min-width: 0;
|
||||
padding: 1px 5px;
|
||||
color: #1c2b33;
|
||||
font: inherit;
|
||||
font-size: 12.5px;
|
||||
background: #fff;
|
||||
border: 1px solid #cfe0e6;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.signing-preview :deep(.sign-field__input:focus) {
|
||||
border-color: var(--brand);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.signing-preview :deep(.sign-field__choices) {
|
||||
display: inline-flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.signing-preview :deep(.sign-field__radio),
|
||||
.signing-preview :deep(.sign-field__checkbox) {
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
accent-color: var(--brand);
|
||||
}
|
||||
|
||||
.signing-preview :deep(.sign-field__signature-empty) {
|
||||
padding: 12px 18px;
|
||||
color: #a96900;
|
||||
font-size: 11px;
|
||||
background: #fff9ec;
|
||||
border: 1px dashed #f0a21a;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.signing-preview :deep(.sign-field--signature) {
|
||||
padding: 3px 8px;
|
||||
}
|
||||
|
||||
.signing-preview :deep(.sign-field--signature .sign-field__control) {
|
||||
min-width: 150px;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.signing-preview :deep(.sign-field--abnormal) {
|
||||
background: #fdf0f0;
|
||||
border-color: #efb9bb;
|
||||
}
|
||||
|
||||
.signing-preview :deep(.sign-field--abnormal .sign-field__label) {
|
||||
color: #a42e34;
|
||||
}
|
||||
|
||||
.signing-preview :deep(.sign-field__badge) {
|
||||
padding: 1px 6px;
|
||||
color: #fff;
|
||||
font-size: 10px;
|
||||
background: var(--err);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.signing-selection {
|
||||
padding: 8px 12px;
|
||||
margin: 10px 0 0;
|
||||
@@ -487,6 +610,10 @@ function handleSigningSelectionChange(event: Event) {
|
||||
color: var(--brand);
|
||||
}
|
||||
|
||||
.signing-selection .is-abnormal {
|
||||
color: var(--err);
|
||||
}
|
||||
|
||||
.preview-paper {
|
||||
min-height: 420px;
|
||||
padding: 26px 34px;
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
<script setup lang="ts">
|
||||
import { reactive, ref, watch } from 'vue'
|
||||
import { computed, reactive, ref, watch } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
|
||||
import type { CampusRecord, DepartmentRecord } from '@/api/management/types'
|
||||
import {
|
||||
buildFieldToken,
|
||||
collectFieldDefs,
|
||||
findDuplicateFields,
|
||||
SIGNING_FIELD_PRESETS,
|
||||
} from '@/utils/signing-fields'
|
||||
|
||||
import EditorWangPane from './editors/EditorWangPane.vue'
|
||||
import type { DocumentTemplate, TemplateEditorForm } from '../types'
|
||||
@@ -35,16 +41,9 @@ const importing = ref(false)
|
||||
const importMessage = ref('')
|
||||
const paneRef = ref<EditorPaneHandle | null>(null)
|
||||
|
||||
// 占位符在签署时由系统按患者与就诊信息自动填充;签署交互管线同时会识别"勾选"表格。
|
||||
const placeholders = [
|
||||
'{患者姓名}',
|
||||
'{性别}',
|
||||
'{年龄}',
|
||||
'{就诊号}',
|
||||
'{签署日期}',
|
||||
'{患者签名}',
|
||||
'{与患者关系}',
|
||||
]
|
||||
// 占位符在签署时按字段键取 HIS 与患者就诊信息自动填充,其中签名是独立控件类型:
|
||||
// 签字后签名图作为该字段取值落回文书原本的签名位,不需要再替换文字。
|
||||
// 目录见 utils/signing-fields 的 SIGNING_FIELD_PRESETS,与签署端渲染共用同一份。
|
||||
|
||||
const form = reactive<TemplateEditorForm>({
|
||||
templateCode: '',
|
||||
@@ -108,6 +107,44 @@ function insertContent(text: string) {
|
||||
paneRef.value?.insertText(text)
|
||||
}
|
||||
|
||||
function insertField(field: (typeof SIGNING_FIELD_PRESETS)[number]) {
|
||||
insertContent(buildFieldToken(field))
|
||||
}
|
||||
|
||||
/** 已插入的填写项:让科室在保存前确认签名位、日期位有没有漏 */
|
||||
const detectedFields = computed(() => collectFieldDefs(valueHtml.value))
|
||||
|
||||
const placeholderHints = computed(() => {
|
||||
const hints: string[] = []
|
||||
const ids = new Set(detectedFields.value.map((field) => field.id))
|
||||
|
||||
if (!ids.has('signature')) {
|
||||
hints.push('文书中没有签名占位符,签署后签名无处落位,建议插入「患者签名」。')
|
||||
}
|
||||
|
||||
if (!ids.has('signedDate')) {
|
||||
hints.push('文书中没有签署日期占位符,建议插入「签署日期」以便留痕。')
|
||||
}
|
||||
|
||||
const duplicates = findDuplicateFields(valueHtml.value)
|
||||
|
||||
if (duplicates.length) {
|
||||
hints.push(
|
||||
`以下占位符出现多次:${duplicates.map((field) => field.label).join('、')}(同一字段共用一份取值)。`,
|
||||
)
|
||||
}
|
||||
|
||||
return hints
|
||||
})
|
||||
|
||||
const kindLabels: Record<string, string> = {
|
||||
text: '文本',
|
||||
date: '日期',
|
||||
choice: '单选',
|
||||
checkbox: '勾选',
|
||||
signature: '签名',
|
||||
}
|
||||
|
||||
async function handleDocxChange(event: Event) {
|
||||
const input = event.target as HTMLInputElement
|
||||
const file = input.files?.[0]
|
||||
@@ -163,12 +200,7 @@ function save() {
|
||||
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-if="visible"
|
||||
class="dialog-mask"
|
||||
role="presentation"
|
||||
@click.self="emit('close')"
|
||||
>
|
||||
<div v-if="visible" class="dialog-mask" role="presentation" @click.self="emit('close')">
|
||||
<section
|
||||
class="word-editor"
|
||||
role="dialog"
|
||||
@@ -206,7 +238,11 @@ function save() {
|
||||
<span>所属科室</span>
|
||||
<select v-model="form.departmentId">
|
||||
<option value="">全院通用</option>
|
||||
<option v-for="department in availableDepartments" :key="department.id" :value="department.id">
|
||||
<option
|
||||
v-for="department in availableDepartments"
|
||||
:key="department.id"
|
||||
:value="department.id"
|
||||
>
|
||||
{{ department.name }}
|
||||
</option>
|
||||
</select>
|
||||
@@ -230,13 +266,15 @@ function save() {
|
||||
<span>插入签署占位符</span>
|
||||
<div class="placeholder-row">
|
||||
<button
|
||||
v-for="item in placeholders"
|
||||
:key="item"
|
||||
v-for="field in SIGNING_FIELD_PRESETS"
|
||||
:key="field.id"
|
||||
type="button"
|
||||
class="placeholder-chip"
|
||||
@click="insertContent(item)"
|
||||
:class="{ 'is-signature': field.kind === 'signature' }"
|
||||
:title="`${kindLabels[field.kind]}字段;签署时按患者与就诊信息自动填充${field.required ? ',必填' : ''}`"
|
||||
@click="insertField(field)"
|
||||
>
|
||||
{{ item }}
|
||||
{{ field.label }}{{ field.required ? ' *' : '' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -257,6 +295,26 @@ function save() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="placeholder-check">
|
||||
<div class="placeholder-check__head">
|
||||
<strong>占位符检查</strong>
|
||||
<span>{{ detectedFields.length }} 个填写项</span>
|
||||
</div>
|
||||
<div v-if="detectedFields.length" class="placeholder-check__list">
|
||||
<span v-for="field in detectedFields" :key="field.id" class="placeholder-check__item">
|
||||
{{ field.label }}
|
||||
<em>{{ kindLabels[field.kind] }}</em>
|
||||
<i v-if="field.required">必填</i>
|
||||
</span>
|
||||
</div>
|
||||
<p v-else class="placeholder-check__empty">
|
||||
文书中还没有任何填写项,签署时不会采集患者填写内容。
|
||||
</p>
|
||||
<ul v-if="placeholderHints.length" class="placeholder-check__hints">
|
||||
<li v-for="hint in placeholderHints" :key="hint">{{ hint }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="editor-shell">
|
||||
<div class="docx-bar">
|
||||
<button
|
||||
@@ -277,7 +335,8 @@ function save() {
|
||||
/>
|
||||
<span v-if="importMessage" class="docx-message">{{ importMessage }}</span>
|
||||
<span v-else class="docx-hint">
|
||||
建议从 Word/WPS 中全选复制文书后直接粘贴到下方编辑区;需要患者勾选的内容可点上方「插入勾选框」按钮插入(光标停在表格单元格里就插到该单元格),或用表头含「勾选」列的表格承载。
|
||||
建议从 Word/WPS
|
||||
中全选复制文书后直接粘贴到下方编辑区;需要患者勾选的内容可点上方「插入勾选框」按钮插入(光标停在表格单元格里就插到该单元格),或用表头含「勾选」列的表格承载。
|
||||
</span>
|
||||
</div>
|
||||
<div class="editor-area">
|
||||
@@ -435,6 +494,77 @@ function save() {
|
||||
filter: brightness(0.95);
|
||||
}
|
||||
|
||||
/* 签名类占位符用不同颜色区分:它决定签名落位,是文书里最不能漏的一项 */
|
||||
.placeholder-chip.is-signature {
|
||||
color: #a96900;
|
||||
background: #fff4e2;
|
||||
}
|
||||
|
||||
.placeholder-check {
|
||||
padding: 10px 18px 0;
|
||||
}
|
||||
|
||||
.placeholder-check__head {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
margin-bottom: 7px;
|
||||
}
|
||||
|
||||
.placeholder-check__head strong {
|
||||
color: var(--ink);
|
||||
font-size: 11.5px;
|
||||
}
|
||||
|
||||
.placeholder-check__head span {
|
||||
color: var(--mut);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.placeholder-check__list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.placeholder-check__item {
|
||||
display: inline-flex;
|
||||
gap: 5px;
|
||||
align-items: center;
|
||||
padding: 2px 8px;
|
||||
color: var(--ink);
|
||||
font-size: 11.5px;
|
||||
background: #f4f8fa;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.placeholder-check__item em {
|
||||
color: var(--brand);
|
||||
font-size: 10.5px;
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
.placeholder-check__item i {
|
||||
color: var(--err);
|
||||
font-size: 10.5px;
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
.placeholder-check__empty {
|
||||
margin: 0;
|
||||
color: var(--mut);
|
||||
font-size: 11.5px;
|
||||
}
|
||||
|
||||
.placeholder-check__hints {
|
||||
padding-left: 16px;
|
||||
margin: 8px 0 0;
|
||||
color: var(--warn);
|
||||
font-size: 11.5px;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.editor-shell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
Reference in New Issue
Block a user