feat: 提交

This commit is contained in:
xy
2026-09-29 15:50:21 +08:00
parent c4363889b1
commit 8d8fa2af47
14 changed files with 911 additions and 40 deletions
@@ -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)
@@ -14,6 +14,7 @@ defineProps<{
const emit = defineEmits<{
operate: [device: SignatureDevice]
mouseTest: [device: SignatureDevice]
}>()
const kindLabels: Record<DeviceKind, string> = {
@@ -70,9 +71,19 @@ function actionLabel(device: SignatureDevice) {
<span class="status-tag" :class="statusClass(device)">{{ statusText(device) }}</span>
</td>
<td>
<button type="button" class="outline-button" @click="emit('operate', device)">
{{ actionLabel(device) }}
</button>
<div class="device-actions">
<button type="button" class="outline-button" @click="emit('operate', device)">
{{ actionLabel(device) }}
</button>
<button
v-if="device.kind === 'pad'"
type="button"
class="outline-button"
@click="emit('mouseTest', device)"
>
鼠标测试
</button>
</div>
</td>
</tr>
</tbody>
@@ -176,4 +187,10 @@ function actionLabel(device: SignatureDevice) {
.outline-button:hover {
background: var(--brand-l);
}
.device-actions {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
</style>
@@ -0,0 +1,190 @@
<script setup lang="ts">
import { nextTick, ref, watch } from 'vue'
import { ElMessage } from 'element-plus'
import SignatureCanvas from '@/views/workbench/signing/components/SignatureCanvas.vue'
const props = defineProps<{
visible: boolean
deviceName: string
}>()
const emit = defineEmits<{
close: []
}>()
const testActive = ref(false)
const testSignature = ref<string | null>(null)
const canvas = ref<InstanceType<typeof SignatureCanvas> | null>(null)
watch(
() => props.visible,
async (visible) => {
if (!visible) return
testActive.value = false
testSignature.value = null
await nextTick()
canvas.value?.clear()
},
)
function startTest() {
testActive.value = true
testSignature.value = null
canvas.value?.clear()
}
function finishTest() {
if (!testSignature.value) {
ElMessage.warning('请先用鼠标在测试框内画一笔')
return
}
ElMessage.success('鼠标输入正常,手写板测试通过')
emit('close')
}
</script>
<template>
<Teleport to="body">
<div v-if="visible" class="mouse-test-mask" @click.self="emit('close')">
<section
class="mouse-test-dialog"
role="dialog"
aria-modal="true"
aria-labelledby="mouse-test-title"
>
<header class="mouse-test-header">
<div>
<strong id="mouse-test-title">手写板鼠标测试</strong>
<span>{{ deviceName }}</span>
</div>
<button type="button" aria-label="关闭" @click="emit('close')">×</button>
</header>
<div class="mouse-test-body">
<p>点击“鼠标测试”后,在下方用鼠标画一笔。此测试不会关联患者、创建签署任务或保存签名。</p>
<SignatureCanvas
ref="canvas"
:height="220"
:disabled="!testActive"
:placeholder="testActive ? '请用鼠标在此画一笔' : '点击“鼠标测试”启用画布'"
@update:data-url="testSignature = $event"
/>
</div>
<footer class="mouse-test-footer">
<button
type="button"
class="mouse-test-button mouse-test-button--ghost"
@click="emit('close')"
>
关闭
</button>
<button
type="button"
class="mouse-test-button mouse-test-button--ghost"
@click="startTest"
>
{{ testActive ? '重新测试' : '鼠标测试' }}
</button>
<button type="button" class="mouse-test-button" @click="finishTest">测试通过</button>
</footer>
</section>
</div>
</Teleport>
</template>
<style scoped>
.mouse-test-mask {
position: fixed;
z-index: 60;
inset: 0;
display: flex;
padding: 16px;
overflow-y: auto;
background: rgb(9 40 52 / 45%);
}
.mouse-test-dialog {
width: 620px;
max-width: 96vw;
margin: auto;
background: #fff;
border-radius: 14px;
box-shadow: var(--sh-modal);
}
.mouse-test-header {
display: flex;
gap: 12px;
align-items: center;
padding: 14px 18px;
border-bottom: 1px solid var(--line);
}
.mouse-test-header > div {
display: grid;
gap: 4px;
}
.mouse-test-header strong {
color: var(--ink);
font-size: 15px;
}
.mouse-test-header span,
.mouse-test-body p {
color: var(--mut);
font-size: 12px;
}
.mouse-test-header button {
padding: 0;
margin-left: auto;
color: var(--mut);
font-size: 22px;
line-height: 1;
background: transparent;
}
.mouse-test-body {
padding: 16px 18px;
}
.mouse-test-body p {
margin: 0 0 10px;
}
.mouse-test-footer {
display: flex;
gap: 8px;
justify-content: flex-end;
padding: 12px 18px;
background: #f8fbfc;
border-top: 1px solid var(--line);
border-radius: 0 0 14px 14px;
}
.mouse-test-button {
padding: 8px 14px;
color: #fff;
font-size: 13px;
font-weight: 700;
background: var(--brand);
border-radius: 7px;
}
.mouse-test-button:hover {
background: var(--brand-d);
}
.mouse-test-button--ghost {
color: var(--brand);
background: #fff;
border: 1px solid var(--brand);
}
.mouse-test-button--ghost:hover {
background: var(--brand-l);
}
</style>
@@ -20,6 +20,7 @@ import { useLoginStore } from '@/stores/login'
import DeviceSettingsCard from './components/DeviceSettingsCard.vue'
import DocTypeSettingsCard from './components/DocTypeSettingsCard.vue'
import EmbedSettingsCard from './components/EmbedSettingsCard.vue'
import MousePadTestDialog from './components/MousePadTestDialog.vue'
import MenuResourceSettings from './components/MenuResourceSettings.vue'
import PushSettingsCard from './components/PushSettingsCard.vue'
import SignatureSettingsCard from './components/SignatureSettingsCard.vue'
@@ -109,6 +110,13 @@ function sendTestSms() {
/* ---------------- 签署设备管理 ---------------- */
const devices = ref<SignatureDevice[]>(mockSignatureDevices.map((device) => ({ ...device })))
const mouseTestVisible = ref(false)
const mouseTestDeviceName = ref('')
function openMouseTest(device: SignatureDevice) {
mouseTestDeviceName.value = `${device.model} · ${device.location}`
mouseTestVisible.value = true
}
function operateDevice(device: SignatureDevice) {
if (device.kind === 'pad') {
@@ -286,6 +294,7 @@ function removeDocType(name: string) {
v-else-if="activeSection === 'device'"
:devices="devices"
@operate="operateDevice"
@mouse-test="openMouseTest"
/>
<PushSettingsCard
@@ -401,6 +410,12 @@ function removeDocType(name: string) {
<el-button type="danger" @click="confirmRotate">确认轮换</el-button>
</template>
</el-dialog>
<MousePadTestDialog
:visible="mouseTestVisible"
:device-name="mouseTestDeviceName"
@close="mouseTestVisible = false"
/>
</section>
</template>