feat: 提交
This commit is contained in:
@@ -267,3 +267,5 @@ API 请求和响应类型按业务域放在 `api/workbench/types.ts`、`api/mana
|
||||
生产环境不会使用 Vite 的开发代理,需要在 Nginx 或其他网关中配置同样的 `/api` 反向代理。Mock 开关按业务模块(对齐菜单)拆分,缺省使用演示数据的只有签署侧(签署工作台 `VITE_MOCK_SIGNING`、签署记录查询 `VITE_MOCK_SIGN_RECORDS`)与「报表分析」目录下的四个菜单(`VITE_MOCK_REPORT_ANALYTICS`,后端没有分析聚合接口),其余模块直接调用真实后端;各开关的环境变量名与缺省值见 `src/utils/mock-flags.ts`,需要临时切换时在本地 `.env.*.local` 里覆盖即可。使用本地开发代理时,`VITE_API_BASE_URL` 应填写 `/api`;如果改为直连后端,则需要后端配置允许当前前端源的 CORS。
|
||||
|
||||
当前尚未接入的签署能力包括手写板设备桥接、线上签署页面/签名回调和打印服务。签署投递、一次性 Token 消费和真实 PNG 上传 API 已完成封装,但页面不能用 Canvas 演示数据冒充真实签名;需要接入设备适配器或患者 H5 回调后再启用。短信初次发送接口需要完整手机号,而患者查询只返回脱敏手机号,因此真实模式会要求当前操作人员确认完整投递地址;生产环境也可以改为由后端根据患者 ID 解析投递地址。签署工作台缺省即使用演示数据(`VITE_MOCK_SIGNING`),需要走真实投递时把它设为 `false`。
|
||||
|
||||
文档库正文编辑器:选中「患者签名」占位符后,可以自动识别 PDF 位置,或生成 PDF 预览后输入坐标。阴影框支持拖动定位,宽高输入即时更新;点击「更新该项」或保存正文写入位置。正文变化后需重新生成预览。预览使用示例患者,实际患者文字长度可能影响排版,建议为填写项预留固定空间。重复的患者签名占位符不会自动选取。后端在保存及发布时检查页码与页面边界。
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import { request } from '@/utils/request'
|
||||
import { unwrapApiResponse } from '@/utils/api-response'
|
||||
import type { ApiResponseOf, SigningFieldDef } from '@/api/workbench/types'
|
||||
|
||||
export type SignaturePosition = NonNullable<SigningFieldDef['pdfPosition']>
|
||||
export interface SignaturePreview {
|
||||
pages: { page: number; width: number; height: number; image: string }[]
|
||||
candidates: (SignaturePosition & { id: string })[]
|
||||
}
|
||||
export async function getSignaturePreview(
|
||||
templateId: string,
|
||||
contentHtml: string,
|
||||
): Promise<SignaturePreview> {
|
||||
const response = await request.post<ApiResponseOf<SignaturePreview>>(
|
||||
`/v1/templates/${encodeURIComponent(templateId)}/signature-preview`,
|
||||
{ contentHtml },
|
||||
{ timeout: 60000 },
|
||||
)
|
||||
return unwrapApiResponse(response)
|
||||
}
|
||||
@@ -3,7 +3,10 @@ import { request } from '@/utils/request'
|
||||
|
||||
import type {
|
||||
ApiResponseOf,
|
||||
SigningContextRequest,
|
||||
SigningContextResponseDto,
|
||||
PadBindingRequest,
|
||||
PatientSignerAssertionInput,
|
||||
PadSessionUploadInput,
|
||||
SendSmsDeliveryRequest,
|
||||
SignDeliveryResponseDto,
|
||||
@@ -77,6 +80,14 @@ export function consumeSigningToken(
|
||||
.then(unwrapApiResponse)
|
||||
}
|
||||
|
||||
export function getSigningContext(
|
||||
payload: SigningContextRequest,
|
||||
): Promise<SigningContextResponseDto> {
|
||||
return request
|
||||
.post<ApiResponseOf<SigningContextResponseDto>>('/v1/sign-deliveries/token/context', payload)
|
||||
.then(unwrapApiResponse)
|
||||
}
|
||||
|
||||
function createSignatureFormData(file: File | Blob, metadata: string) {
|
||||
const formData = new FormData()
|
||||
const fileName =
|
||||
@@ -86,20 +97,31 @@ function createSignatureFormData(file: File | Blob, metadata: string) {
|
||||
return formData
|
||||
}
|
||||
|
||||
function appendJsonPart(formData: FormData, name: string, value: unknown) {
|
||||
formData.append(
|
||||
name,
|
||||
new Blob([JSON.stringify(value)], { type: 'application/json' }),
|
||||
`${name}.json`,
|
||||
)
|
||||
}
|
||||
|
||||
function appendSigner(formData: FormData, signer: PatientSignerAssertionInput) {
|
||||
appendJsonPart(formData, 'signer', signer)
|
||||
}
|
||||
|
||||
export function uploadSigningSignature(
|
||||
payload: SigningTokenUploadInput,
|
||||
): Promise<SignatureUploadResponse> {
|
||||
const formData = createSignatureFormData(payload.file, payload.metadata)
|
||||
appendJsonPart(formData, 'deliveryId', payload.deliveryId)
|
||||
formData.append('uploadToken', payload.uploadToken)
|
||||
appendSigner(formData, payload.signer)
|
||||
|
||||
return request
|
||||
.post<ApiResponseOf<SignatureUploadResponse>>(
|
||||
`/v1/sign-deliveries/${encodeURIComponent(payload.taskId)}/signature`,
|
||||
formData,
|
||||
{
|
||||
params: {
|
||||
deliveryId: payload.deliveryId,
|
||||
uploadToken: payload.uploadToken,
|
||||
},
|
||||
headers: idempotencyHeaders(payload.idempotencyKey),
|
||||
},
|
||||
)
|
||||
@@ -140,14 +162,13 @@ export function uploadSigningSignatureByProof(
|
||||
payload: SigningTokenUploadInput,
|
||||
): Promise<SignatureUploadResponse> {
|
||||
const formData = createSignatureFormData(payload.file, payload.metadata)
|
||||
appendJsonPart(formData, 'taskId', payload.taskId)
|
||||
appendJsonPart(formData, 'deliveryId', payload.deliveryId)
|
||||
formData.append('uploadToken', payload.uploadToken)
|
||||
appendSigner(formData, payload.signer)
|
||||
|
||||
return request
|
||||
.post<ApiResponseOf<SignatureUploadResponse>>('/v1/sign-deliveries/token/signature', formData, {
|
||||
params: {
|
||||
taskId: payload.taskId,
|
||||
deliveryId: payload.deliveryId,
|
||||
uploadToken: payload.uploadToken,
|
||||
},
|
||||
headers: idempotencyHeaders(payload.idempotencyKey),
|
||||
})
|
||||
.then(unwrapApiResponse)
|
||||
|
||||
@@ -633,6 +633,43 @@ export interface TokenConsumeResponse {
|
||||
timestamp?: string | null
|
||||
}
|
||||
|
||||
export interface SigningContextRequest {
|
||||
taskId: string
|
||||
deliveryId: string
|
||||
uploadToken: string
|
||||
}
|
||||
|
||||
export interface SigningContextSignatureFieldDto {
|
||||
id: string
|
||||
page: number
|
||||
x: number
|
||||
y: number
|
||||
width: number
|
||||
height: number
|
||||
coordinateSystem: 'TOP_LEFT' | 'PDF_BOTTOM_LEFT'
|
||||
}
|
||||
|
||||
export interface SigningContextResponseDto {
|
||||
taskId: string
|
||||
deliveryId: string
|
||||
channel: SignDeliveryChannel
|
||||
status: BackendSigningTaskStatus
|
||||
signer: {
|
||||
type: string | null
|
||||
name: string | null
|
||||
relation: string | null
|
||||
}
|
||||
signatureFields: SigningContextSignatureFieldDto[]
|
||||
}
|
||||
|
||||
export interface PatientSignerAssertionInput {
|
||||
type: 'SELF' | 'FAMILY'
|
||||
name: string
|
||||
relation: string
|
||||
consentAccepted: true
|
||||
observedSignedAt: string
|
||||
}
|
||||
|
||||
export type SignatureUploadStatus = BackendSigningTaskStatus
|
||||
|
||||
export interface SignatureUploadResponse {
|
||||
@@ -661,6 +698,7 @@ export interface SignatureUploadInput {
|
||||
|
||||
export interface SigningTokenUploadInput extends Omit<SignatureUploadInput, 'pad'> {
|
||||
taskId: string
|
||||
signer: PatientSignerAssertionInput
|
||||
}
|
||||
|
||||
export interface PadSessionUploadInput extends SignatureUploadInput {
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -5,10 +5,12 @@ const props = withDefaults(
|
||||
defineProps<{
|
||||
height?: number
|
||||
placeholder?: string
|
||||
disabled?: boolean
|
||||
}>(),
|
||||
{
|
||||
height: 300,
|
||||
placeholder: '请 在 此 签 名',
|
||||
disabled: false,
|
||||
},
|
||||
)
|
||||
|
||||
@@ -66,7 +68,7 @@ function getPoint(event: PointerEvent) {
|
||||
}
|
||||
|
||||
function startDrawing(event: PointerEvent) {
|
||||
if (!context || !canvas.value) {
|
||||
if (props.disabled || !context || !canvas.value) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -80,7 +82,7 @@ function startDrawing(event: PointerEvent) {
|
||||
}
|
||||
|
||||
function draw(event: PointerEvent) {
|
||||
if (!drawing || !context) {
|
||||
if (props.disabled || !drawing || !context) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -150,6 +152,7 @@ onBeforeUnmount(() => {
|
||||
<div class="signature-canvas" :style="{ height: `${height}px` }">
|
||||
<canvas
|
||||
ref="canvas"
|
||||
:aria-disabled="disabled"
|
||||
@pointercancel="stopDrawing"
|
||||
@pointerdown="startDrawing"
|
||||
@pointerleave="stopDrawing"
|
||||
@@ -178,6 +181,10 @@ onBeforeUnmount(() => {
|
||||
cursor: crosshair;
|
||||
}
|
||||
|
||||
.signature-canvas canvas[aria-disabled='true'] {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.signature-placeholder {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
|
||||
@@ -23,11 +23,19 @@ const props = withDefaults(
|
||||
/** 说明这一笔签名要落在哪里,避免医师签在了患者签名位 */
|
||||
hint?: string
|
||||
confirmLabel?: string
|
||||
/** 在真实模式下要求先显式启用鼠标输入,避免把本地画布误当成硬件已连接。 */
|
||||
mouseTestRequired?: boolean
|
||||
/** 真实患者签署需由签署人明确确认知情同意。 */
|
||||
patientConsentRequired?: boolean
|
||||
submitting?: boolean
|
||||
}>(),
|
||||
{
|
||||
title: undefined,
|
||||
hint: '请患者在下框内签名(当前为 Canvas 演示,真实手写板接入走设备适配器):',
|
||||
confirmLabel: '确认签署',
|
||||
mouseTestRequired: false,
|
||||
patientConsentRequired: false,
|
||||
submitting: false,
|
||||
},
|
||||
)
|
||||
|
||||
@@ -44,13 +52,24 @@ const emit = defineEmits<{
|
||||
const titleId = useId()
|
||||
|
||||
const signatureDataUrl = ref<string | null>(null)
|
||||
const mouseTestActive = ref(false)
|
||||
const patientConsentAccepted = ref(false)
|
||||
const signatureCanvas = ref<{ clear: () => void } | null>(null)
|
||||
const signatureInputEnabled = computed(() => !props.mouseTestRequired || mouseTestActive.value)
|
||||
const signatureHint = computed(() => {
|
||||
if (!props.mouseTestRequired) return props.hint
|
||||
return mouseTestActive.value
|
||||
? '鼠标测试已启用。请让任务中登记的签署人使用鼠标完成签名,签名确认后将提交并生成正式签署文件。'
|
||||
: '点击“鼠标测试”启用鼠标签名;测试画布中的笔迹将作为本次正式签名提交。'
|
||||
})
|
||||
|
||||
watch(
|
||||
() => props.visible,
|
||||
async (visible) => {
|
||||
if (visible) {
|
||||
signatureDataUrl.value = null
|
||||
mouseTestActive.value = !props.mouseTestRequired
|
||||
patientConsentAccepted.value = false
|
||||
await nextTick()
|
||||
signatureCanvas.value?.clear()
|
||||
}
|
||||
@@ -62,7 +81,22 @@ function clearSignature() {
|
||||
signatureDataUrl.value = null
|
||||
}
|
||||
|
||||
function startMouseTest() {
|
||||
mouseTestActive.value = true
|
||||
clearSignature()
|
||||
}
|
||||
|
||||
function confirmSignature() {
|
||||
if (props.mouseTestRequired && !mouseTestActive.value) {
|
||||
ElMessage.warning('请先点击“鼠标测试”启用鼠标签名')
|
||||
return
|
||||
}
|
||||
|
||||
if (props.patientConsentRequired && !patientConsentAccepted.value) {
|
||||
ElMessage.warning('请先确认签署人已阅读并同意签署')
|
||||
return
|
||||
}
|
||||
|
||||
if (!signatureDataUrl.value) {
|
||||
ElMessage.warning('请先完成签名')
|
||||
return
|
||||
@@ -84,26 +118,61 @@ function confirmSignature() {
|
||||
<header class="dialog-header">
|
||||
<strong :id="titleId">{{ dialogTitle }}</strong>
|
||||
<span>{{ task.patientName }} · {{ task.documentName }}</span>
|
||||
<button type="button" class="dialog-close" aria-label="关闭" @click="emit('close')">
|
||||
<button
|
||||
type="button"
|
||||
class="dialog-close"
|
||||
aria-label="关闭"
|
||||
:disabled="submitting"
|
||||
@click="emit('close')"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</header>
|
||||
<div class="dialog-body">
|
||||
<p class="dialog-hint">{{ hint }}</p>
|
||||
<div v-if="mouseTestRequired" class="mouse-test-control">
|
||||
<span>{{ mouseTestActive ? '鼠标输入已启用' : '鼠标签名尚未启用' }}</span>
|
||||
<button
|
||||
type="button"
|
||||
class="action-button action-button--ghost"
|
||||
:disabled="submitting"
|
||||
@click="startMouseTest"
|
||||
>
|
||||
{{ mouseTestActive ? '重新测试鼠标' : '鼠标测试' }}
|
||||
</button>
|
||||
</div>
|
||||
<p class="dialog-hint">{{ signatureHint }}</p>
|
||||
<SignatureCanvas
|
||||
ref="signatureCanvas"
|
||||
:height="300"
|
||||
:disabled="!signatureInputEnabled"
|
||||
:placeholder="signatureInputEnabled ? '请 在 此 签 名' : '点击鼠标测试启用签名'"
|
||||
@update:data-url="signatureDataUrl = $event"
|
||||
/>
|
||||
<label v-if="patientConsentRequired" class="consent-control">
|
||||
<input v-model="patientConsentAccepted" type="checkbox" />
|
||||
签署人已阅读文书内容并同意签署
|
||||
</label>
|
||||
</div>
|
||||
<footer class="dialog-footer">
|
||||
<span>签名将实时合成到文档签名域</span>
|
||||
<span>{{
|
||||
mouseTestRequired ? '鼠标绘制的签名将进入正式签署流水线' : '签名将实时合成到文档签名域'
|
||||
}}</span>
|
||||
<span class="footer-spacer" />
|
||||
<button type="button" class="action-button action-button--ghost" @click="clearSignature">
|
||||
<button
|
||||
type="button"
|
||||
class="action-button action-button--ghost"
|
||||
:disabled="submitting"
|
||||
@click="clearSignature"
|
||||
>
|
||||
清空重写
|
||||
</button>
|
||||
<button type="button" class="action-button" @click="confirmSignature">
|
||||
{{ confirmLabel }}
|
||||
<button
|
||||
type="button"
|
||||
class="action-button"
|
||||
:disabled="submitting"
|
||||
@click="confirmSignature"
|
||||
>
|
||||
{{ submitting ? '正在提交…' : confirmLabel }}
|
||||
</button>
|
||||
</footer>
|
||||
</section>
|
||||
@@ -177,6 +246,31 @@ function confirmSignature() {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.mouse-test-control {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 8px;
|
||||
color: var(--mut);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.consent-control {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
margin-top: 12px;
|
||||
color: var(--ink);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.consent-control input {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
accent-color: var(--brand);
|
||||
}
|
||||
|
||||
.dialog-footer {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
@@ -206,6 +300,12 @@ function confirmSignature() {
|
||||
background: var(--brand-d);
|
||||
}
|
||||
|
||||
.action-button:disabled,
|
||||
.dialog-close:disabled {
|
||||
cursor: wait;
|
||||
opacity: 0.65;
|
||||
}
|
||||
|
||||
.action-button--ghost {
|
||||
color: var(--brand);
|
||||
background: #fff;
|
||||
|
||||
@@ -30,6 +30,8 @@ const props = defineProps<{
|
||||
auditLoading: boolean
|
||||
auditError: boolean
|
||||
allowLocalSigning: boolean
|
||||
/** 真实模式下允许有签署权限的用户通过浏览器鼠标采集现场手写签名。 */
|
||||
allowMouseSigning: boolean
|
||||
artifacts: SigningArtifact[]
|
||||
artifactsLoading: boolean
|
||||
artifactsError: boolean
|
||||
@@ -188,9 +190,16 @@ defineExpose({ getDocumentElement, locateSignatureSlot })
|
||||
手写板 → 直接开签名板;Pad → 把文书滚到签名位等高亮;短信 → 开「发送签署短信」;
|
||||
公众号 → 开「消息推送」 -->
|
||||
<template v-if="isOnsiteMethod(task.method)">
|
||||
<template v-if="allowLocalSigning">
|
||||
<button type="button" class="action-button" @click="emit('action', 'pad-sign')">
|
||||
{{ formatSigningMethodTitle(task.method) }}
|
||||
<template v-if="allowLocalSigning || allowMouseSigning">
|
||||
<button
|
||||
type="button"
|
||||
class="action-button"
|
||||
:disabled="padSessionLoading"
|
||||
@click="emit('action', 'pad-sign')"
|
||||
>
|
||||
{{
|
||||
padSessionLoading ? '正在创建签署会话…' : formatSigningMethodTitle(task.method)
|
||||
}}
|
||||
</button>
|
||||
</template>
|
||||
<template v-else-if="canSignTask">
|
||||
|
||||
@@ -9,7 +9,12 @@ import {
|
||||
fetchSignatureDataUrl,
|
||||
getSigningArtifacts,
|
||||
} from '@/api/workbench/artifacts'
|
||||
import { createPadSigningSession } from '@/api/workbench/deliveries'
|
||||
import {
|
||||
consumeSigningToken,
|
||||
createPadSigningSession,
|
||||
getSigningContext,
|
||||
uploadSigningSignature,
|
||||
} from '@/api/workbench/deliveries'
|
||||
import {
|
||||
completeSigningTask,
|
||||
confirmDepartmentSigning,
|
||||
@@ -32,7 +37,9 @@ import type {
|
||||
SigningDateRange,
|
||||
SigningMethod,
|
||||
SigningArtifact,
|
||||
PatientSignerAssertionInput,
|
||||
SignDeliveryResponseDto,
|
||||
SigningContextSignatureFieldDto,
|
||||
SigningTaskEvent,
|
||||
SigningTaskRecord,
|
||||
SigningTaskStatus,
|
||||
@@ -116,11 +123,13 @@ const artifactsLoading = ref(false)
|
||||
const artifactsError = ref(false)
|
||||
const padSessionStatus = ref<string | null>(null)
|
||||
const padSessionLoading = ref(false)
|
||||
const mousePadSession = ref<{ deliveryId: string; token: string } | null>(null)
|
||||
const campusIds = ref<Record<string, string>>({})
|
||||
const campusNames = ref<Record<string, WorkbenchCampus>>({})
|
||||
const departmentIds = ref<Record<string, string>>({})
|
||||
const newDialogVisible = ref(false)
|
||||
const signatureDialogVisible = ref(false)
|
||||
const signatureSubmitting = ref(false)
|
||||
const onlineDialogVisible = ref(false)
|
||||
/** 「发送签署短信」弹窗(原型 #mdSmsFlow) */
|
||||
const smsSendVisible = ref(false)
|
||||
@@ -281,6 +290,8 @@ async function loadTaskDetail(id: string | null) {
|
||||
fieldSignatureInjections.value = {}
|
||||
pendingSignatureField.value = null
|
||||
fieldSignatureDialogVisible.value = false
|
||||
signatureDialogVisible.value = false
|
||||
mousePadSession.value = null
|
||||
// 预览弹窗跟着任务走:换任务时留在上一份文书的预览上会让人以为看的是当前任务
|
||||
signedPreviewVisible.value = false
|
||||
|
||||
@@ -294,6 +305,7 @@ async function loadTaskDetail(id: string | null) {
|
||||
artifactsLoading.value = false
|
||||
artifactsError.value = false
|
||||
padSessionStatus.value = null
|
||||
mousePadSession.value = null
|
||||
return
|
||||
}
|
||||
|
||||
@@ -305,6 +317,7 @@ async function loadTaskDetail(id: string | null) {
|
||||
taskEvents.value = []
|
||||
artifacts.value = []
|
||||
padSessionStatus.value = null
|
||||
mousePadSession.value = null
|
||||
|
||||
try {
|
||||
const [detailResult, eventsResult, artifactsResult] = await Promise.allSettled([
|
||||
@@ -508,9 +521,38 @@ async function handleSignatureRequest(request: SigningSignatureRequest) {
|
||||
return
|
||||
}
|
||||
|
||||
await openTaskPadSignature(task)
|
||||
}
|
||||
|
||||
async function openTaskPadSignature(task: SigningTaskRecord) {
|
||||
mousePadSession.value = null
|
||||
|
||||
if (!isSigningMockEnabled) {
|
||||
padSessionLoading.value = true
|
||||
try {
|
||||
const session = await createPadSigningSession(task.id, { deviceId: 'browser-mouse-test' })
|
||||
|
||||
if (!session.token) {
|
||||
throw new Error('后端未返回手写板会话令牌')
|
||||
}
|
||||
|
||||
mousePadSession.value = { deliveryId: session.deliveryId, token: session.token }
|
||||
} catch (error) {
|
||||
ElMessage.error(error instanceof Error ? error.message : '鼠标测试会话创建失败,请稍后重试')
|
||||
return
|
||||
} finally {
|
||||
padSessionLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
signatureDialogVisible.value = true
|
||||
}
|
||||
|
||||
function closeTaskPadSignature() {
|
||||
signatureDialogVisible.value = false
|
||||
mousePadSession.value = null
|
||||
}
|
||||
|
||||
function closeFieldSignatureDialog() {
|
||||
fieldSignatureDialogVisible.value = false
|
||||
pendingSignatureField.value = null
|
||||
@@ -588,11 +630,6 @@ async function handleTaskAction(action: SigningTaskAction, targetMethod?: Signin
|
||||
}
|
||||
|
||||
if (action === 'pad-sign') {
|
||||
if (!isSigningMockEnabled) {
|
||||
ElMessage.info(`${readSigningMethod(task.method).short}设备签署接口待接入`)
|
||||
return
|
||||
}
|
||||
|
||||
/**
|
||||
* 原型把两种现场方式分得很清楚:
|
||||
* - 手写板 → `openPad()`,直接开签名板;
|
||||
@@ -613,7 +650,7 @@ async function handleTaskAction(action: SigningTaskAction, targetMethod?: Signin
|
||||
return
|
||||
}
|
||||
|
||||
signatureDialogVisible.value = true
|
||||
await openTaskPadSignature(task)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -947,6 +984,147 @@ async function downloadArtifact(artifact: SigningArtifact) {
|
||||
}
|
||||
}
|
||||
|
||||
function decodeSignaturePng(dataUrl: string) {
|
||||
const match = /^data:image\/png;base64,([\s\S]+)$/.exec(dataUrl)
|
||||
|
||||
if (!match) {
|
||||
throw new Error('签名画布没有生成有效的 PNG 图片')
|
||||
}
|
||||
|
||||
const binary = atob(match[1])
|
||||
const buffer = new ArrayBuffer(binary.length)
|
||||
const bytes = new Uint8Array(buffer)
|
||||
|
||||
for (let index = 0; index < binary.length; index += 1) {
|
||||
bytes[index] = binary.charCodeAt(index)
|
||||
}
|
||||
|
||||
const pngSignature = [137, 80, 78, 71, 13, 10, 26, 10]
|
||||
if (pngSignature.some((value, index) => bytes[index] !== value) || bytes.length < 24) {
|
||||
throw new Error('签名 PNG 格式无效')
|
||||
}
|
||||
|
||||
const view = new DataView(buffer)
|
||||
const width = view.getUint32(16, false)
|
||||
const height = view.getUint32(20, false)
|
||||
|
||||
if (!width || !height) {
|
||||
throw new Error('签名图片尺寸无效')
|
||||
}
|
||||
|
||||
return {
|
||||
blob: new Blob([buffer], { type: 'image/png' }),
|
||||
width,
|
||||
height,
|
||||
}
|
||||
}
|
||||
|
||||
async function signatureSha256(blob: Blob) {
|
||||
if (!globalThis.crypto?.subtle) {
|
||||
throw new Error('当前浏览器环境不支持签名校验,请通过 HTTPS 或 localhost 访问')
|
||||
}
|
||||
|
||||
const digest = await globalThis.crypto.subtle.digest('SHA-256', await blob.arrayBuffer())
|
||||
return Array.from(new Uint8Array(digest), (value) => value.toString(16).padStart(2, '0')).join('')
|
||||
}
|
||||
|
||||
function createPatientSignerAssertion(signer: {
|
||||
type: string | null
|
||||
name: string | null
|
||||
relation: string | null
|
||||
}): PatientSignerAssertionInput {
|
||||
const type = signer.type?.trim().toUpperCase()
|
||||
|
||||
if (type !== 'SELF' && type !== 'FAMILY') {
|
||||
throw new Error('任务中没有有效的签署人类型,请刷新任务后重试')
|
||||
}
|
||||
|
||||
const name = signer.name?.trim() ?? ''
|
||||
const relation = signer.relation?.trim() ?? ''
|
||||
|
||||
if (!name || !relation) {
|
||||
throw new Error('任务中缺少签署人姓名或关系,请检查任务信息')
|
||||
}
|
||||
|
||||
return {
|
||||
type,
|
||||
name,
|
||||
relation,
|
||||
consentAccepted: true,
|
||||
observedSignedAt: new Date().toISOString(),
|
||||
}
|
||||
}
|
||||
|
||||
function buildSignatureMetadata(
|
||||
field: SigningContextSignatureFieldDto,
|
||||
image: { blob: Blob; width: number; height: number },
|
||||
sha256: string,
|
||||
) {
|
||||
return JSON.stringify({
|
||||
fieldId: field.id,
|
||||
page: field.page,
|
||||
x: field.x,
|
||||
y: field.y,
|
||||
width: field.width,
|
||||
height: field.height,
|
||||
coordinateSystem: field.coordinateSystem,
|
||||
imageWidth: image.width,
|
||||
imageHeight: image.height,
|
||||
sha256,
|
||||
contentType: 'image/png',
|
||||
sizeBytes: image.blob.size,
|
||||
})
|
||||
}
|
||||
|
||||
async function submitMousePadSignature(task: SigningTaskRecord, signatureDataUrl: string) {
|
||||
const session = mousePadSession.value
|
||||
|
||||
if (!session?.token) {
|
||||
throw new Error('手写板会话已失效,请重新打开鼠标签署')
|
||||
}
|
||||
|
||||
const image = decodeSignaturePng(signatureDataUrl)
|
||||
const consumed = await consumeSigningToken({
|
||||
token: session.token,
|
||||
channel: 'PAD',
|
||||
taskId: task.id,
|
||||
})
|
||||
|
||||
if (!consumed.uploadToken) {
|
||||
throw new Error('签署会话未返回一次性上传授权')
|
||||
}
|
||||
|
||||
const context = await getSigningContext({
|
||||
taskId: task.id,
|
||||
deliveryId: consumed.deliveryId,
|
||||
uploadToken: consumed.uploadToken,
|
||||
})
|
||||
const field = context.signatureFields.find((item) => item.id === PRIMARY_SIGNATURE_FIELD_ID)
|
||||
|
||||
if (!field) {
|
||||
throw new Error('模板没有患者签名位置,无法提交鼠标签名')
|
||||
}
|
||||
|
||||
const sha256 = await signatureSha256(image.blob)
|
||||
await uploadSigningSignature({
|
||||
taskId: task.id,
|
||||
deliveryId: consumed.deliveryId,
|
||||
uploadToken: consumed.uploadToken,
|
||||
file: image.blob,
|
||||
metadata: buildSignatureMetadata(field, image, sha256),
|
||||
signer: createPatientSignerAssertion(context.signer),
|
||||
})
|
||||
|
||||
signatureDialogVisible.value = false
|
||||
mousePadSession.value = null
|
||||
await loadTasks(task.id, true)
|
||||
ElMessage.success(
|
||||
task.needConfirm
|
||||
? '现场鼠标签署完成,任务已流转至科室确认节点'
|
||||
: '鼠标签署完成,签署文件已生成',
|
||||
)
|
||||
}
|
||||
|
||||
async function confirmPadSignature(signatureDataUrl: string) {
|
||||
const task = selectedTask.value
|
||||
|
||||
@@ -954,6 +1132,23 @@ async function confirmPadSignature(signatureDataUrl: string) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!isSigningMockEnabled) {
|
||||
signatureSubmitting.value = true
|
||||
try {
|
||||
await submitMousePadSignature(task, signatureDataUrl)
|
||||
} catch (error) {
|
||||
signatureDialogVisible.value = false
|
||||
mousePadSession.value = null
|
||||
ElMessage.error(
|
||||
error instanceof Error ? `鼠标签署失败:${error.message}` : '鼠标签署失败,请稍后重试',
|
||||
)
|
||||
} finally {
|
||||
signatureSubmitting.value = false
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
signatureSubmitting.value = true
|
||||
try {
|
||||
const updated = await completeSigningTask(
|
||||
task.id,
|
||||
@@ -965,6 +1160,8 @@ async function confirmPadSignature(signatureDataUrl: string) {
|
||||
signatureDialogVisible.value = false
|
||||
} catch {
|
||||
ElMessage.error('手写签署失败,请稍后重试')
|
||||
} finally {
|
||||
signatureSubmitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1136,6 +1333,7 @@ onMounted(() => {
|
||||
:audit-loading="eventsLoading"
|
||||
:audit-error="eventsError"
|
||||
:allow-local-signing="isSigningMockEnabled"
|
||||
:allow-mouse-signing="canSignTask && selectedTask?.method === 'pad'"
|
||||
:artifacts="artifacts"
|
||||
:artifacts-loading="artifactsLoading"
|
||||
:artifacts-error="artifactsError"
|
||||
@@ -1166,7 +1364,13 @@ onMounted(() => {
|
||||
<SignaturePadDialog
|
||||
:visible="signatureDialogVisible"
|
||||
:task="selectedTask"
|
||||
@close="signatureDialogVisible = false"
|
||||
:mouse-test-required="!isSigningMockEnabled && selectedTask?.method === 'pad'"
|
||||
:patient-consent-required="!isSigningMockEnabled && selectedTask?.method === 'pad'"
|
||||
:submitting="signatureSubmitting"
|
||||
:confirm-label="
|
||||
!isSigningMockEnabled && selectedTask?.method === 'pad' ? '提交鼠标签署' : '确认签署'
|
||||
"
|
||||
@close="closeTaskPadSignature"
|
||||
@confirm="confirmPadSignature"
|
||||
/>
|
||||
<!-- 文书里单个签名位(医师签名等)的采集入口:签完只回填该位置,不结束任务 -->
|
||||
|
||||
Reference in New Issue
Block a user