feat(sign): 支持多位置签名并优化坐标识别与模板版本校验
This commit is contained in:
+126
-37
@@ -1,59 +1,114 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { computed, onBeforeUnmount, ref, watch } from 'vue'
|
||||
import { toUserMessage } from '@/utils/api-response'
|
||||
import { getSignaturePreview } from '@/api/management/signature-preview'
|
||||
import type { SignaturePosition, SignaturePreview } from '@/api/management/signature-preview'
|
||||
|
||||
const props = defineProps<{
|
||||
fieldId: string
|
||||
templateId: string
|
||||
contentHtml: string
|
||||
position: SignaturePosition
|
||||
positionEmpty: boolean
|
||||
}>()
|
||||
const emit = defineEmits<{ change: [value: SignaturePosition] }>()
|
||||
const result = ref<SignaturePreview | null>(null)
|
||||
const busy = ref(false)
|
||||
const previewVisible = ref(true)
|
||||
const message = ref('')
|
||||
const renderedHtml = ref('')
|
||||
const generation = ref(0)
|
||||
const stale = computed(() => renderedHtml.value !== props.contentHtml)
|
||||
watch(
|
||||
() => props.templateId,
|
||||
() => {
|
||||
generation.value++
|
||||
result.value = null
|
||||
renderedHtml.value = ''
|
||||
},
|
||||
)
|
||||
async function render(detect: boolean) {
|
||||
const html = props.contentHtml
|
||||
// Position metadata does not affect PDF layout. Persisting detected/dragged coordinates
|
||||
// must not invalidate the preview or start another detection request.
|
||||
const previewHtml = computed(() => {
|
||||
const document = new DOMParser().parseFromString(props.contentHtml, 'text/html')
|
||||
for (const node of document.querySelectorAll('[data-field]')) {
|
||||
for (const attribute of Array.from(node.attributes)) {
|
||||
if (attribute.name.startsWith('data-pdf-')) node.removeAttribute(attribute.name)
|
||||
}
|
||||
}
|
||||
return document.documentElement.outerHTML
|
||||
})
|
||||
const stale = computed(() => renderedHtml.value !== previewHtml.value)
|
||||
const failed = ref(false)
|
||||
const detecting = ref(false)
|
||||
let timer: ReturnType<typeof setTimeout> | undefined
|
||||
|
||||
async function render() {
|
||||
clearTimeout(timer)
|
||||
const html = previewHtml.value
|
||||
const id = props.templateId
|
||||
const fieldId = props.fieldId
|
||||
const detect = props.positionEmpty
|
||||
const originalPosition = JSON.stringify(props.position)
|
||||
const current = ++generation.value
|
||||
busy.value = true
|
||||
detecting.value = detect
|
||||
failed.value = false
|
||||
message.value = ''
|
||||
try {
|
||||
const response = await getSignaturePreview(id, html)
|
||||
if (current !== generation.value || html !== props.contentHtml || id !== props.templateId)
|
||||
if (
|
||||
current !== generation.value ||
|
||||
html !== previewHtml.value ||
|
||||
id !== props.templateId ||
|
||||
fieldId !== props.fieldId
|
||||
)
|
||||
return
|
||||
result.value = response
|
||||
renderedHtml.value = html
|
||||
if (detect) {
|
||||
if (response.candidates.length === 1) {
|
||||
emit('change', response.candidates[0]!)
|
||||
message.value = '已识别患者签名占位符,请检查阴影位置并微调。'
|
||||
// A user may enter coordinates while the request is running. Never overwrite those edits.
|
||||
if (detect && props.positionEmpty && originalPosition === JSON.stringify(props.position)) {
|
||||
const candidates = response.candidates.filter((field) => field.id === fieldId)
|
||||
if (candidates.length === 1) {
|
||||
emit('change', candidates[0]!)
|
||||
message.value = '已自动填入签名位置,可拖动阴影或修改上方数值。'
|
||||
} else {
|
||||
message.value = response.candidates.length
|
||||
? '发现多个患者签名占位符,请保留唯一占位符后重新识别。'
|
||||
: '未找到可定位的患者签名占位符,请先在正文中插入。'
|
||||
failed.value = true
|
||||
message.value = candidates.length
|
||||
? '此签名位置的标识重复,请删除重复占位符后重试。'
|
||||
: '未找到此签名位置,请确认正文中已插入签名占位符后重试。'
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
message.value = 'PDF 预览失败,请检查正文格式后重试。'
|
||||
} catch (error) {
|
||||
if (current !== generation.value) return
|
||||
failed.value = true
|
||||
message.value = toUserMessage(
|
||||
error,
|
||||
detect ? '自动识别失败,请重试或手动填写位置。' : '预览加载失败,请重试。',
|
||||
)
|
||||
} finally {
|
||||
if (current === generation.value) busy.value = false
|
||||
}
|
||||
}
|
||||
onMounted(() => {
|
||||
if (props.position.page > 0 && props.position.width > 0 && props.position.height > 0)
|
||||
void render(false)
|
||||
|
||||
watch(
|
||||
() => [props.templateId, props.fieldId, previewHtml.value, props.positionEmpty] as const,
|
||||
(value, previous) => {
|
||||
const selectionChanged = !previous || value[0] !== previous[0] || value[1] !== previous[1]
|
||||
const contentChanged = !previous || value[2] !== previous[2]
|
||||
if (!selectionChanged && !contentChanged && !value[3]) return
|
||||
clearTimeout(timer)
|
||||
generation.value++
|
||||
busy.value = false
|
||||
failed.value = false
|
||||
message.value = ''
|
||||
if (selectionChanged) {
|
||||
result.value = null
|
||||
renderedHtml.value = ''
|
||||
}
|
||||
if (selectionChanged || props.positionEmpty) {
|
||||
busy.value = true
|
||||
detecting.value = props.positionEmpty
|
||||
// Coalesce editor changes, field selection and draft synchronisation into one request.
|
||||
timer = setTimeout(() => void render(), 200)
|
||||
}
|
||||
},
|
||||
{ immediate: true, flush: 'post' },
|
||||
)
|
||||
onBeforeUnmount(() => {
|
||||
clearTimeout(timer)
|
||||
generation.value++
|
||||
})
|
||||
function overlay(page: SignaturePreview['pages'][number]) {
|
||||
const p = props.position
|
||||
@@ -118,17 +173,34 @@ function move(event: PointerEvent, page: SignaturePreview['pages'][number]) {
|
||||
</script>
|
||||
<template>
|
||||
<section class="signature-preview">
|
||||
<button type="button" :disabled="busy" @click="render(true)">
|
||||
{{ busy ? '生成中…' : '自动识别患者签名' }}
|
||||
</button>
|
||||
<button type="button" :disabled="busy" @click="render(false)">生成 / 刷新 PDF 预览</button>
|
||||
<p>使用示例患者数据生成 PDF;修改坐标实时更新阴影,可拖动阴影调整位置,宽高在上方输入。</p>
|
||||
<p v-if="message" role="status">{{ message }}</p>
|
||||
<p v-if="result && stale" class="warning">正文已变化,请刷新 PDF 预览并重新确认位置。</p>
|
||||
<p v-if="result && !valid" class="warning">
|
||||
签名区域无效、页码不存在或超出页面,请调整后再保存。
|
||||
<div class="preview-toolbar">
|
||||
<div>
|
||||
<strong>签名位置预览</strong>
|
||||
<p v-if="busy">{{ detecting ? '正在自动识别签名位置…' : '正在加载预览…' }}</p>
|
||||
<p v-else-if="positionEmpty">尚未配置位置,将根据正文中的签名占位符自动填写。</p>
|
||||
<p v-else>已保留当前坐标,可拖动阴影或修改上方数值。</p>
|
||||
</div>
|
||||
<div class="preview-actions">
|
||||
<button
|
||||
type="button"
|
||||
:aria-expanded="previewVisible"
|
||||
@click="previewVisible = !previewVisible"
|
||||
>
|
||||
{{ previewVisible ? '隐藏预览' : '显示预览' }}
|
||||
</button>
|
||||
<button v-if="!busy" type="button" @click="render">
|
||||
{{ positionEmpty ? '重试自动识别' : failed ? '重试加载预览' : '刷新预览' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<p v-if="message" :class="{ warning: failed }" :role="failed ? 'alert' : 'status'">
|
||||
{{ message }}
|
||||
</p>
|
||||
<div v-if="result && !stale" class="pages">
|
||||
<p v-if="result && stale" class="warning">正文已变化,请刷新预览并手动检查位置。</p>
|
||||
<p v-if="result && !stale && !positionEmpty && !valid" class="warning">
|
||||
签名区域无效、页码不存在或超出页面,请调整上方数值。
|
||||
</p>
|
||||
<div v-if="result && !stale" v-show="previewVisible" class="pages">
|
||||
<div v-for="page in result.pages" :key="page.page">
|
||||
<p>
|
||||
第 {{ page.page }} 页({{ page.width.toFixed(1) }} × {{ page.height.toFixed(1) }} pt)
|
||||
@@ -141,7 +213,7 @@ function move(event: PointerEvent, page: SignaturePreview['pages'][number]) {
|
||||
:style="overlay(page)"
|
||||
@pointerdown.prevent="move($event, page)"
|
||||
>
|
||||
患者签名
|
||||
签名
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -153,10 +225,27 @@ function move(event: PointerEvent, page: SignaturePreview['pages'][number]) {
|
||||
padding: 12px;
|
||||
border: 1px solid #cadce4;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
border-radius: 8px;
|
||||
background: #f8fbfc;
|
||||
}
|
||||
.preview-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
.preview-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.preview-toolbar strong {
|
||||
font-size: 13px;
|
||||
}
|
||||
button {
|
||||
padding: 6px 12px;
|
||||
margin-right: 8px;
|
||||
cursor: pointer;
|
||||
}
|
||||
p {
|
||||
|
||||
@@ -76,9 +76,12 @@ const message = reactive({ text: '', tone: '' as '' | 'ok' | 'warn' })
|
||||
* 那种位置必须有占位符,否则签署时不会回填(系统不会另外在文书上插患者信息)。
|
||||
*/
|
||||
const AUTO_FILL_LABELS = ['患者姓名', '性别', '年龄', '就诊号', '与患者关系', '签署日期']
|
||||
const MANUAL_LABELS = ['患者签名', '勾选项']
|
||||
const MANUAL_LABELS = ['签名', '勾选项']
|
||||
|
||||
function findPreset(label: string): SigningFieldDef | null {
|
||||
if (label === '签名') {
|
||||
return { id: 'signature', label, kind: 'signature', required: false, options: [] }
|
||||
}
|
||||
return SIGNING_FIELD_PRESETS.find((field) => field.label === label) ?? null
|
||||
}
|
||||
|
||||
@@ -139,6 +142,29 @@ const fieldDraft = reactive({
|
||||
pdfCoordinateSystem: 'TOP_LEFT' as 'TOP_LEFT' | 'PDF_BOTTOM_LEFT',
|
||||
})
|
||||
|
||||
const signaturePreviewHtml = computed(() => {
|
||||
const document = new DOMParser().parseFromString(valueHtml.value, 'text/html')
|
||||
// A field converted to “signature” must be locatable before its first coordinates exist.
|
||||
for (const node of document.querySelectorAll('[data-field]')) {
|
||||
if (
|
||||
node.getAttribute('data-field') === selectedFieldId.value &&
|
||||
fieldDraft.kind === 'signature'
|
||||
)
|
||||
node.setAttribute('data-kind', 'signature')
|
||||
}
|
||||
return document.documentElement.outerHTML
|
||||
})
|
||||
|
||||
const signaturePositionEmpty = computed(() =>
|
||||
[
|
||||
fieldDraft.pdfPage,
|
||||
fieldDraft.pdfX,
|
||||
fieldDraft.pdfY,
|
||||
fieldDraft.pdfWidth,
|
||||
fieldDraft.pdfHeight,
|
||||
].every((value) => !value.trim()),
|
||||
)
|
||||
|
||||
const previewPosition = computed<SignaturePosition>(() => ({
|
||||
page: Number(fieldDraft.pdfPage),
|
||||
x: Number(fieldDraft.pdfX),
|
||||
@@ -154,6 +180,12 @@ function changePosition(position: SignaturePosition) {
|
||||
fieldDraft.pdfWidth = String(position.width)
|
||||
fieldDraft.pdfHeight = String(position.height)
|
||||
fieldDraft.pdfCoordinateSystem = position.coordinateSystem
|
||||
// Keep this position when switching to another field; other draft settings still require “更新该项”.
|
||||
if (selectedField.value)
|
||||
paneRef.value?.updateField(selectedField.value.id, {
|
||||
kind: fieldDraft.kind,
|
||||
pdfPosition: position,
|
||||
})
|
||||
}
|
||||
|
||||
/** 类型下拉的取值(原型 fpKind 的 5 个选项) */
|
||||
@@ -240,9 +272,16 @@ function selectFieldFromEditor(field: SigningFieldDef) {
|
||||
function insertField(field: SigningFieldDef) {
|
||||
// 自定义文本项每插一个就是独立填写项,字段键必须当场取一个没被占用的
|
||||
// (见 allocateCustomFieldId)——两个文本框是两处要填的内容,共用一个键才是错的
|
||||
const target = field.id.startsWith('custom:')
|
||||
? { ...field, id: allocateCustomFieldId(valueHtml.value, field.label) }
|
||||
: field
|
||||
const target =
|
||||
field.kind === 'signature' && detectedFields.value.some((item) => item.id === field.id)
|
||||
? {
|
||||
...field,
|
||||
id: `sign_${globalThis.crypto?.randomUUID?.() ?? `${Date.now().toString(36)}_${Math.random().toString(36).slice(2)}`}`,
|
||||
required: false,
|
||||
}
|
||||
: field.id.startsWith('custom:')
|
||||
? { ...field, id: allocateCustomFieldId(valueHtml.value, field.label) }
|
||||
: field
|
||||
|
||||
paneRef.value?.insertField(target)
|
||||
}
|
||||
@@ -272,7 +311,7 @@ function applyFieldDraft() {
|
||||
const options = fieldDraft.options.map((option) => option.trim()).filter(Boolean)
|
||||
|
||||
let pdfPosition: SigningFieldDef['pdfPosition']
|
||||
if (field.id === 'signature' && fieldDraft.kind === 'signature') {
|
||||
if (fieldDraft.kind === 'signature') {
|
||||
const page = Number(fieldDraft.pdfPage)
|
||||
const x = Number(fieldDraft.pdfX)
|
||||
const y = Number(fieldDraft.pdfY)
|
||||
@@ -292,7 +331,7 @@ function applyFieldDraft() {
|
||||
width <= 0 ||
|
||||
height <= 0
|
||||
) {
|
||||
setMessage('患者签名位置须填写有效页码及 PDF point 坐标', 'warn')
|
||||
setMessage('签名位置须填写有效页码及坐标', 'warn')
|
||||
return false
|
||||
}
|
||||
pdfPosition = { page, x, y, width, height, coordinateSystem: fieldDraft.pdfCoordinateSystem }
|
||||
@@ -378,9 +417,12 @@ function validate(requireSignaturePosition = true): string | null {
|
||||
return `「${broken.label}」只配了一个选项,勾选类至少要两个(或删掉选项改成单个勾选框)`
|
||||
}
|
||||
|
||||
const patientSignature = detectedFields.value.find((field) => field.id === 'signature')
|
||||
if (requireSignaturePosition && !patientSignature?.pdfPosition) {
|
||||
return '请选中“患者签名”占位符,配置其 PDF 页码和签名位置后再保存'
|
||||
const signatures = detectedFields.value.filter((field) => field.kind === 'signature')
|
||||
if (
|
||||
requireSignaturePosition &&
|
||||
(!signatures.length || signatures.some((field) => !field.pdfPosition))
|
||||
) {
|
||||
return '请为每个签名占位符识别并保存 PDF 签名位置'
|
||||
}
|
||||
|
||||
return null
|
||||
@@ -391,8 +433,8 @@ const saveHints = computed(() => {
|
||||
const ids = new Set(detectedFields.value.map((field) => field.id))
|
||||
const hints: string[] = []
|
||||
|
||||
if (!ids.has('signature')) {
|
||||
hints.push('文书中没有签名占位符,签署后签名无处落位,建议插入「患者签名」')
|
||||
if (!detectedFields.value.some((field) => field.kind === 'signature')) {
|
||||
hints.push('文书中没有签名占位符,签署后签名无处落位,建议插入「签名」')
|
||||
}
|
||||
|
||||
if (!ids.has('signedDate')) {
|
||||
@@ -476,7 +518,7 @@ async function save() {
|
||||
return
|
||||
}
|
||||
|
||||
if (selectedField.value?.id === 'signature') {
|
||||
if (selectedField.value?.kind === 'signature') {
|
||||
if (!applyFieldDraft()) return
|
||||
await nextTick()
|
||||
valueHtml.value = paneRef.value?.getHtml() ?? valueHtml.value
|
||||
@@ -601,15 +643,6 @@ function requestClose() {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<SignaturePositionPreview
|
||||
v-if="template && selectedField.id === 'signature' && fieldDraft.kind === 'signature'"
|
||||
:key="template.id"
|
||||
:template-id="template.id"
|
||||
:content-html="valueHtml"
|
||||
:position="previewPosition"
|
||||
@change="changePosition"
|
||||
/>
|
||||
|
||||
<div v-if="isOptionKind" class="fp-row">
|
||||
<b class="fp-title">选项设置(支持多个)</b>
|
||||
<span class="fp-hint">选项逐项展示为可勾选框;勾选类默认可多选,选择类默认单选</span>
|
||||
@@ -623,11 +656,8 @@ function requestClose() {
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="selectedField.id === 'signature' && fieldDraft.kind === 'signature'"
|
||||
class="fp-row signature-position-row"
|
||||
>
|
||||
<b class="fp-title">患者签名 PDF 位置</b>
|
||||
<div v-if="fieldDraft.kind === 'signature'" class="fp-row signature-position-row">
|
||||
<b class="fp-title">签名位置</b>
|
||||
<span class="fp-hint">PDF point;页码从 1 开始</span>
|
||||
<label class="fp-field"
|
||||
><span>页码</span><input v-model="fieldDraft.pdfPage" inputmode="numeric"
|
||||
@@ -654,11 +684,13 @@ function requestClose() {
|
||||
</div>
|
||||
|
||||
<SignaturePositionPreview
|
||||
v-if="template && selectedField.id === 'signature' && fieldDraft.kind === 'signature'"
|
||||
:key="template.id"
|
||||
v-if="template && fieldDraft.kind === 'signature'"
|
||||
:key="`${template.id}:${selectedField.id}`"
|
||||
:field-id="selectedField.id"
|
||||
:template-id="template.id"
|
||||
:content-html="valueHtml"
|
||||
:content-html="signaturePreviewHtml"
|
||||
:position="previewPosition"
|
||||
:position-empty="signaturePositionEmpty"
|
||||
@change="changePosition"
|
||||
/>
|
||||
|
||||
@@ -724,16 +756,14 @@ function requestClose() {
|
||||
:class="`is-${button.field.kind}`"
|
||||
:title="
|
||||
button.field.kind === 'signature'
|
||||
? '签署时由患者手写采集,签名图落在文书原位置'
|
||||
? '插入独立签名位置,可改名为患者签名、家属签名或医生签名'
|
||||
: '签署时由患者逐项勾选;点正文里的胶囊可配置多个选项'
|
||||
"
|
||||
@click="insertField(button.field)"
|
||||
>
|
||||
[{{ button.label }}]
|
||||
</button>
|
||||
<span class="pb-note">
|
||||
签名 / 勾选由患者在签署时填写;其余签署时按患者信息自动回填
|
||||
</span>
|
||||
<span class="pb-note"> 签名可重复插入并自定义名称;勾选项在签署时填写 </span>
|
||||
</div>
|
||||
</template>
|
||||
</EditorWangPane>
|
||||
|
||||
Reference in New Issue
Block a user