feat: 提交
This commit is contained in:
@@ -0,0 +1,197 @@
|
||||
<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>
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, reactive, ref, watch } from 'vue'
|
||||
import { computed, nextTick, reactive, ref, watch } from 'vue'
|
||||
|
||||
import type { SigningFieldDef, SigningFieldKind } from '@/api/workbench/types'
|
||||
import {
|
||||
@@ -12,6 +12,8 @@ import {
|
||||
} from '@/utils/signing-fields'
|
||||
|
||||
import DocumentPaper from '@/components/document/DocumentPaper.vue'
|
||||
import SignaturePositionPreview from './SignaturePositionPreview.vue'
|
||||
import type { SignaturePosition } from '@/api/management/signature-preview'
|
||||
|
||||
import EditorWangPane from './editors/EditorWangPane.vue'
|
||||
import { DOC_TEMPLATE_PERMISSIONS } from '../permissions'
|
||||
@@ -47,6 +49,7 @@ const savePermission = DOC_TEMPLATE_PERMISSIONS.edit
|
||||
// 并把占位符的元素节点与渲染钩子重新注册一遍(见 utils/signing-field-node)。
|
||||
interface EditorPaneHandle {
|
||||
getText: () => string
|
||||
getHtml: () => string | undefined
|
||||
insertField: (field: SigningFieldDef) => void
|
||||
/** 就地改写某个字段的占位符,返回命中的节点数;0 表示正文里已经没有它 */
|
||||
updateField: (fieldId: string, patch: Partial<Omit<SigningFieldDef, 'id'>>) => number
|
||||
@@ -137,6 +140,23 @@ const fieldDraft = reactive({
|
||||
pdfCoordinateSystem: 'TOP_LEFT' as 'TOP_LEFT' | 'PDF_BOTTOM_LEFT',
|
||||
})
|
||||
|
||||
const previewPosition = computed<SignaturePosition>(() => ({
|
||||
page: Number(fieldDraft.pdfPage),
|
||||
x: Number(fieldDraft.pdfX),
|
||||
y: Number(fieldDraft.pdfY),
|
||||
width: Number(fieldDraft.pdfWidth),
|
||||
height: Number(fieldDraft.pdfHeight),
|
||||
coordinateSystem: fieldDraft.pdfCoordinateSystem,
|
||||
}))
|
||||
function changePosition(position: SignaturePosition) {
|
||||
fieldDraft.pdfPage = String(position.page)
|
||||
fieldDraft.pdfX = String(position.x)
|
||||
fieldDraft.pdfY = String(position.y)
|
||||
fieldDraft.pdfWidth = String(position.width)
|
||||
fieldDraft.pdfHeight = String(position.height)
|
||||
fieldDraft.pdfCoordinateSystem = position.coordinateSystem
|
||||
}
|
||||
|
||||
/** 类型下拉的取值(原型 fpKind 的 5 个选项) */
|
||||
const KIND_OPTIONS = Object.entries(SIGNING_FIELD_KIND_LABELS) as Array<[SigningFieldKind, string]>
|
||||
|
||||
@@ -240,14 +260,14 @@ function applyFieldDraft() {
|
||||
const field = selectedField.value
|
||||
|
||||
if (!field) {
|
||||
return
|
||||
return false
|
||||
}
|
||||
|
||||
const label = fieldDraft.label.trim()
|
||||
|
||||
if (!label) {
|
||||
setMessage('请填写填写项名称', 'warn')
|
||||
return
|
||||
return false
|
||||
}
|
||||
|
||||
const options = fieldDraft.options.map((option) => option.trim()).filter(Boolean)
|
||||
@@ -274,19 +294,19 @@ function applyFieldDraft() {
|
||||
height <= 0
|
||||
) {
|
||||
setMessage('患者签名位置须填写有效页码及 PDF point 坐标', 'warn')
|
||||
return
|
||||
return false
|
||||
}
|
||||
pdfPosition = { page, x, y, width, height, coordinateSystem: fieldDraft.pdfCoordinateSystem }
|
||||
}
|
||||
|
||||
if (isOptionKind.value && new Set(options).size !== options.length) {
|
||||
setMessage('存在重复选项,请修改后重试', 'warn')
|
||||
return
|
||||
return false
|
||||
}
|
||||
|
||||
if (fieldDraft.kind === 'choice' && options.length < 2) {
|
||||
setMessage(`选择项「${label}」至少需要两个选项`, 'warn')
|
||||
return
|
||||
return false
|
||||
}
|
||||
|
||||
// 字段键(field.id)刻意保持不变:id 是取值的归属,改叫法不该让历史取值对不上。
|
||||
@@ -308,7 +328,7 @@ function applyFieldDraft() {
|
||||
if (!changed) {
|
||||
setMessage(`正文里已经没有「${field.label}」了,请重新选择要修改的填写项`, 'warn')
|
||||
selectedFieldId.value = ''
|
||||
return
|
||||
return false
|
||||
}
|
||||
|
||||
setMessage(
|
||||
@@ -318,6 +338,7 @@ function applyFieldDraft() {
|
||||
: ''),
|
||||
'ok',
|
||||
)
|
||||
return true
|
||||
}
|
||||
|
||||
function removeField() {
|
||||
@@ -438,7 +459,7 @@ async function handleDocxChange(event: Event) {
|
||||
}
|
||||
}
|
||||
|
||||
function save() {
|
||||
async function save() {
|
||||
if (!props.template) {
|
||||
return
|
||||
}
|
||||
@@ -456,6 +477,11 @@ function save() {
|
||||
return
|
||||
}
|
||||
|
||||
if (selectedField.value?.id === 'signature') {
|
||||
if (!applyFieldDraft()) return
|
||||
await nextTick()
|
||||
valueHtml.value = paneRef.value?.getHtml() ?? valueHtml.value
|
||||
}
|
||||
const error = validate()
|
||||
|
||||
if (error) {
|
||||
@@ -576,6 +602,15 @@ 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>
|
||||
@@ -619,6 +654,15 @@ function requestClose() {
|
||||
</label>
|
||||
</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">
|
||||
<span v-for="(_, index) in fieldDraft.options" :key="index" class="option-item">
|
||||
<input
|
||||
|
||||
@@ -226,7 +226,14 @@ function removeField(fieldId: string): number {
|
||||
return removeSignFieldNodes(editor, fieldId)
|
||||
}
|
||||
|
||||
defineExpose({ getText, insertText, insertField, updateField, removeField })
|
||||
defineExpose({
|
||||
getText,
|
||||
getHtml: () => editorRef.value?.getHtml(),
|
||||
insertText,
|
||||
insertField,
|
||||
updateField,
|
||||
removeField,
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
editableRef.value?.removeEventListener('pointerdown', markUserCaret)
|
||||
|
||||
Reference in New Issue
Block a user