198 lines
5.9 KiB
Vue
198 lines
5.9 KiB
Vue
<script setup lang="ts">
|
||
import { computed, onMounted, ref, watch } from 'vue'
|
||
import { getSignaturePreview } from '@/api/management/signature-preview'
|
||
import type { SignaturePosition, SignaturePreview } from '@/api/management/signature-preview'
|
||
|
||
const props = defineProps<{
|
||
templateId: string
|
||
contentHtml: string
|
||
position: SignaturePosition
|
||
}>()
|
||
const emit = defineEmits<{ change: [value: SignaturePosition] }>()
|
||
const result = ref<SignaturePreview | null>(null)
|
||
const busy = ref(false)
|
||
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
|
||
const id = props.templateId
|
||
const current = ++generation.value
|
||
busy.value = true
|
||
message.value = ''
|
||
try {
|
||
const response = await getSignaturePreview(id, html)
|
||
if (current !== generation.value || html !== props.contentHtml || id !== props.templateId)
|
||
return
|
||
result.value = response
|
||
renderedHtml.value = html
|
||
if (detect) {
|
||
if (response.candidates.length === 1) {
|
||
emit('change', response.candidates[0]!)
|
||
message.value = '已识别患者签名占位符,请检查阴影位置并微调。'
|
||
} else {
|
||
message.value = response.candidates.length
|
||
? '发现多个患者签名占位符,请保留唯一占位符后重新识别。'
|
||
: '未找到可定位的患者签名占位符,请先在正文中插入。'
|
||
}
|
||
}
|
||
} catch {
|
||
message.value = 'PDF 预览失败,请检查正文格式后重试。'
|
||
} 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)
|
||
})
|
||
function overlay(page: SignaturePreview['pages'][number]) {
|
||
const p = props.position
|
||
const y = p.coordinateSystem === 'PDF_BOTTOM_LEFT' ? page.height - p.y - p.height : p.y
|
||
return {
|
||
left: `${(p.x / page.width) * 100}%`,
|
||
top: `${(y / page.height) * 100}%`,
|
||
width: `${(p.width / page.width) * 100}%`,
|
||
height: `${(p.height / page.height) * 100}%`,
|
||
}
|
||
}
|
||
const valid = computed(() => {
|
||
const p = props.position
|
||
const page = result.value?.pages.find((item) => item.page === p.page)
|
||
return (
|
||
!!page &&
|
||
[p.x, p.y, p.width, p.height].every(Number.isFinite) &&
|
||
p.x >= 0 &&
|
||
p.y >= 0 &&
|
||
p.width > 0 &&
|
||
p.height > 0 &&
|
||
p.x + p.width <= page.width &&
|
||
p.y + p.height <= page.height
|
||
)
|
||
})
|
||
function move(event: PointerEvent, page: SignaturePreview['pages'][number]) {
|
||
if (stale.value || !valid.value) return
|
||
const element = event.currentTarget as HTMLElement
|
||
const bounds = element.parentElement!.getBoundingClientRect()
|
||
const original = { ...props.position }
|
||
const startX = event.clientX,
|
||
startY = event.clientY
|
||
element.setPointerCapture(event.pointerId)
|
||
element.onpointermove = (e) => {
|
||
const dx = ((e.clientX - startX) * page.width) / bounds.width
|
||
const dy = ((e.clientY - startY) * page.height) / bounds.height
|
||
emit('change', {
|
||
...original,
|
||
x:
|
||
Math.round(Math.max(0, Math.min(page.width - original.width, original.x + dx)) * 1000) /
|
||
1000,
|
||
y:
|
||
Math.round(
|
||
Math.max(
|
||
0,
|
||
Math.min(
|
||
page.height - original.height,
|
||
original.y + (original.coordinateSystem === 'PDF_BOTTOM_LEFT' ? -dy : dy),
|
||
),
|
||
) * 1000,
|
||
) / 1000,
|
||
})
|
||
}
|
||
const end = () => {
|
||
element.onpointermove = null
|
||
element.onpointerup = null
|
||
element.onpointercancel = null
|
||
}
|
||
element.onpointerup = end
|
||
element.onpointercancel = end
|
||
}
|
||
</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">
|
||
签名区域无效、页码不存在或超出页面,请调整后再保存。
|
||
</p>
|
||
<div v-if="result && !stale" class="pages">
|
||
<div v-for="page in result.pages" :key="page.page">
|
||
<p>
|
||
第 {{ page.page }} 页({{ page.width.toFixed(1) }} × {{ page.height.toFixed(1) }} pt)
|
||
</p>
|
||
<div class="page" :style="{ aspectRatio: `${page.width} / ${page.height}` }">
|
||
<img :src="page.image" :alt="`PDF 第 ${page.page} 页`" draggable="false" />
|
||
<div
|
||
v-if="position.page === page.page && valid"
|
||
class="shadow"
|
||
:style="overlay(page)"
|
||
@pointerdown.prevent="move($event, page)"
|
||
>
|
||
患者签名
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
</template>
|
||
<style scoped>
|
||
.signature-preview {
|
||
padding: 12px;
|
||
border: 1px solid #cadce4;
|
||
width: 100%;
|
||
}
|
||
button {
|
||
padding: 6px 12px;
|
||
margin-right: 8px;
|
||
cursor: pointer;
|
||
}
|
||
p {
|
||
font-size: 12px;
|
||
margin: 8px 0;
|
||
}
|
||
.warning {
|
||
color: #b54708;
|
||
}
|
||
.pages {
|
||
max-height: 560px;
|
||
overflow: auto;
|
||
background: #edf2f5;
|
||
padding: 16px;
|
||
}
|
||
.page {
|
||
position: relative;
|
||
width: 100%;
|
||
max-width: 650px;
|
||
background: white;
|
||
}
|
||
img {
|
||
width: 100%;
|
||
height: 100%;
|
||
display: block;
|
||
}
|
||
.shadow {
|
||
position: absolute;
|
||
box-sizing: border-box;
|
||
background: #168aad40;
|
||
border: 2px dashed #168aad;
|
||
color: #075c78;
|
||
cursor: move;
|
||
touch-action: none;
|
||
font-size: 10px;
|
||
overflow: hidden;
|
||
}
|
||
</style>
|