This commit is contained in:
yelan
2026-09-29 22:18:01 +08:00
17 changed files with 451 additions and 49 deletions
+28 -11
View File
@@ -37,6 +37,7 @@ import type {
SignTaskResponseDto,
SignTaskSendPreparationResponseDto,
SigningAnswers,
SigningFormState,
SigningApiOptions,
SigningMethod,
SigningTaskEvent,
@@ -917,16 +918,21 @@ function mapSigningTask(
export async function hydrateSigningTaskContent(
task: SigningTaskRecord,
): Promise<SigningTaskRecord> {
if (task.documentContentHtml || useMockData || !task.templateVersionId) {
return task
}
const contentHtml = await fetchTemplateContentByVersion(
task.templateVersionId,
task.contentSha256,
if (useMockData || task.formState) return task
const response = await request.get<ApiResponseOf<SigningFormState>>(
`/v1/sign-tasks/${encodeURIComponent(task.id)}/form`,
)
return contentHtml ? { ...task, documentContentHtml: contentHtml } : task
const form = unwrapApiResponse(response)
const fieldAnswers: SigningAnswers = { ...form.staffAnswers, ...form.answers }
for (const field of form.fields) {
const value = fieldAnswers[field.id]
if (field.editable || typeof value !== 'string') continue
if (field.options.length && field.multi)
fieldAnswers[field.id] = value.split('、').filter(Boolean)
else if (field.kind === 'checkbox' && !field.options.length)
fieldAnswers[field.id] = !['', 'false', '0', 'off'].includes(value.toLowerCase())
}
return { ...task, documentContentHtml: form.contentHtml, fieldAnswers, formState: form }
}
function createTemplateFromInput(input: CreateSigningTaskInput): SigningTemplate {
@@ -1157,8 +1163,6 @@ export async function getSigningTaskDetail(
`/v1/sign-tasks/${encodeURIComponent(id)}`,
)
const task = mapSigningTask(unwrapApiResponse(response), options)
// 任务接口不返回正文,这里按哈希把正文快照补回;取不到就保持空,
// 由详情组件提示"后端未提供正文",不退化成前端写死的回退纸样
return hydrateSigningTaskContent(task)
} catch (error) {
if (isNotFoundError(error)) {
@@ -1731,3 +1735,16 @@ export function getSigningStatusLabel(status: SigningTaskStatus) {
export function getVisitTypeLabel(type: VisitType) {
return type
}
export async function saveSigningForm(
id: string,
expectedRevision: number,
answers: SigningAnswers,
): Promise<SigningFormState> {
return request
.post<ApiResponseOf<SigningFormState>>(`/v1/sign-tasks/${encodeURIComponent(id)}/form`, {
expectedRevision,
answers,
})
.then(unwrapApiResponse)
}
+19
View File
@@ -334,6 +334,7 @@ export interface SigningTaskRecord extends WorkbenchTask {
* 文书填写项取值(含签名图),签署时固化。
* 重新打开文书时用它回显,签名因此落在文书原本的签名位上。
*/
formState?: SigningFormState
fieldAnswers?: SigningAnswers
}
@@ -663,6 +664,7 @@ export interface SigningContextResponseDto {
}
export interface PatientSignerAssertionInput {
answerRevision?: number
type: 'SELF' | 'FAMILY'
name: string
relation: string
@@ -775,3 +777,20 @@ export interface SigningTaskEvent {
}
export type ApiResponseOf<T> = ApiResponse<T>
export interface SigningFormState {
contentHtml: string
fields: {
id: string
label: string
kind: string
required: boolean
multi: boolean
options: string[]
editable: boolean
}[]
answers: SigningAnswers
staffAnswers: Record<string, string>
revision: number
editable: boolean
}
@@ -123,6 +123,28 @@ function syncFromDom() {
emit('selectionChange', [...selected])
}
/** Repeated occurrences of one field must share the same answer. */
function syncEditedField(target: EventTarget | null) {
const root = containerRef.value
const host = target instanceof Element ? target.closest<HTMLElement>('[data-sign-field]') : null
const id = host?.getAttribute('data-sign-field')
if (root && host && id) {
const wrapper = document.createElement('div')
wrapper.append(host.cloneNode(true))
const value = readAnswersFromRoot(wrapper)[id]
if (value !== undefined)
root.querySelectorAll<HTMLElement>('[data-sign-field]').forEach((other) => {
if (other !== host && other.getAttribute('data-sign-field') === id)
applyAnswerToFieldHost(other, value)
})
}
syncFromDom()
}
function handleInput(event: Event) {
if (props.interactive) syncEditedField(event.target)
}
/** 按已固化的取值还原 DOM 状态(存量勾选表 + 填写项都覆盖) */
function applyStoredValues() {
const container = containerRef.value
@@ -201,7 +223,7 @@ function handleDocumentClick(event: MouseEvent) {
const container = containerRef.value
if (option && container && toggleOptionAt(container, option)) {
syncFromDom()
syncEditedField(option)
return
}
}
@@ -219,6 +241,13 @@ function handleDocumentKeydown(event: KeyboardEvent) {
return
}
const option =
event.target instanceof Element ? event.target.closest<HTMLElement>(OPTION_TICK_SELECTOR) : null
if (option && containerRef.value && toggleOptionAt(containerRef.value, option)) {
event.preventDefault()
syncEditedField(option)
return
}
const host = resolveSignatureHost(event.target)
if (host) {
@@ -285,8 +314,8 @@ defineExpose({ getDocumentElement })
<div
ref="containerRef"
class="doc-paper__body"
@change="syncFromDom"
@input="syncFromDom"
@change="handleInput"
@input="handleInput"
@click="handleDocumentClick"
@keydown="handleDocumentKeydown"
v-html="processedHtml"
@@ -119,7 +119,11 @@ export function useSigningTaskForm(options: UseSigningTaskFormOptions) {
'signedDate',
])
return (selectedTemplate.value?.formFields ?? []).filter(
(field) => field.kind !== 'signature' && !systemFields.has(field.id),
(field) =>
field.kind !== 'signature' &&
field.kind !== 'checkbox' &&
field.kind !== 'choice' &&
!systemFields.has(field.id),
)
})
+22 -11
View File
@@ -271,13 +271,15 @@ function buildOptionItem(
displayValue: string = itemValue,
): HTMLElement {
const item = doc.createElement('span')
item.className = [
OPTION_CLASS,
on ? OPTION_ON_CLASS : '',
interactive ? OPTION_TICK_CLASS : '',
]
item.className = [OPTION_CLASS, on ? OPTION_ON_CLASS : '', interactive ? OPTION_TICK_CLASS : '']
.filter(Boolean)
.join(' ')
if (interactive) {
item.setAttribute('role', 'checkbox')
item.setAttribute('tabindex', '0')
item.setAttribute('aria-checked', String(on))
item.setAttribute('aria-label', displayValue || '勾选此项')
}
item.setAttribute(OPTION_VALUE_ATTR, itemValue)
item.setAttribute(OPTION_TEXT_ATTR, displayValue)
item.textContent = optionDisplay(displayValue, on)
@@ -288,6 +290,7 @@ function buildOptionItem(
/** 切换一个选项的勾选态(改类名 + 重建 ☑ / ☐ 文案),返回切换后的状态 */
function setOptionState(item: HTMLElement, on: boolean): boolean {
item.classList.toggle(OPTION_ON_CLASS, on)
if (item.hasAttribute('aria-checked')) item.setAttribute('aria-checked', String(on))
item.textContent = optionDisplay(item.getAttribute(OPTION_TEXT_ATTR) ?? '', on)
return on
@@ -409,7 +412,8 @@ function buildFieldWidget(
if (interactive) {
const input = doc.createElement('input')
input.className = INPUT_CLASS
input.type = 'text'
input.type = field.kind === 'date' ? 'date' : 'text'
input.maxLength = 4000
input.value = text
// value 属性同理:只设 property 的话序列化出来是空值
input.setAttribute('value', text)
@@ -509,7 +513,15 @@ function renderStructuredFields(
return
}
element.replaceWith(buildFieldWidget(doc, field, answers, interactive, signatureActionable))
const readonly = element.getAttribute('data-readonly') === 'true'
const widget = buildFieldWidget(
doc,
readonly ? { ...field, required: false } : field,
answers,
interactive && !readonly,
signatureActionable && (!readonly || field.id === PRIMARY_SIGNATURE_FIELD_ID),
)
element.replaceWith(widget)
})
}
@@ -555,7 +567,7 @@ function convertCheckboxTables(root: Element, interactive: boolean, selected: Se
const cells = Array.from(row.querySelectorAll('td, th'))
const checkCell = cells[checkIndex]
if (!checkCell) {
if (!checkCell || checkCell.querySelector('[data-sign-field], [data-field]')) {
return
}
@@ -853,9 +865,8 @@ export function readAnswersFromRoot(root: HTMLElement): SigningAnswers {
// 直接落库会在证据里留下一个 undefined 而不是 false
if (kind === 'checkbox') {
answers[id] =
host
.querySelector<HTMLElement>(`.${OPTION_CLASS}`)
?.classList.contains(OPTION_ON_CLASS) ?? false
host.querySelector<HTMLElement>(`.${OPTION_CLASS}`)?.classList.contains(OPTION_ON_CLASS) ??
false
return
}
@@ -30,6 +30,8 @@ const props = defineProps<{
auditLoading: boolean
auditError: boolean
allowLocalSigning: boolean
formSaveMessage?: string
formSaving?: boolean
/** 真实模式下允许有签署权限的用户通过浏览器鼠标采集现场手写签名。 */
allowMouseSigning: boolean
artifacts: SigningArtifact[]
@@ -414,12 +416,15 @@ defineExpose({ getDocumentElement, locateSignatureSlot })
<div class="detail-body">
<!-- 任务接口只返回模板版本哈希,正文由 API 层按哈希补回;
补不到时如实提示并给出占位版式,不退化成"看起来像真文书"的假纸样 -->
<p v-if="formSaveMessage" role="status" class="action-hint">
{{ formSaving ? '正在保存填写内容…' : formSaveMessage }}
</p>
<SigningDocumentInteractive
v-if="task.documentContentHtml"
ref="documentRef"
:task="task"
:interactive="
allowLocalSigning &&
(allowLocalSigning || (allowMouseSigning && task.formState?.editable)) &&
task.status !== 'signed' &&
task.status !== 'void' &&
task.status !== 'confirming'
@@ -16,6 +16,7 @@ import {
uploadSigningSignature,
} from '@/api/workbench/deliveries'
import {
saveSigningForm,
completeSigningTask,
confirmDepartmentSigning,
getSigningTaskDetail,
@@ -284,6 +285,12 @@ async function loadOrganizationOptions() {
async function loadTaskDetail(id: string | null) {
const requestId = ++detailRequestId
detailLoading.value = true
await formSaveQueue
if (requestId !== detailRequestId) return
formDraft = null
formSaveError = null
formSaveMessage.value = ''
// 换任务时先清空勾选与填写项,避免把上一个任务的取值带到新任务上;
// 文书组件挂载后会用任务自身固化的取值重新上报
signingSelection.value = []
@@ -447,8 +454,67 @@ function handleSelectionChange(items: string[]) {
signingSelection.value = items
}
let formSaveQueue: Promise<void> = Promise.resolve()
const formSaving = ref(false)
const formSaveMessage = ref('')
let formSaveError: unknown = null
let formDraft: {
taskId: string
revision: number
fingerprint: string
fields: Set<string>
} | null = null
function persistFormAnswers(answers: SigningAnswers) {
const task = selectedTask.value
if (
detailLoading.value ||
signatureSubmitting.value ||
isSigningMockEnabled ||
!task?.formState?.editable ||
task.method !== 'pad' ||
!canSignTask.value
)
return
if (!formDraft || formDraft.taskId !== task.id) {
formDraft = {
taskId: task.id,
revision: task.formState.revision,
fingerprint: JSON.stringify(task.formState.answers),
fields: new Set(task.formState.fields.filter((f) => f.editable).map((f) => f.id)),
}
formSaveError = null
}
const draft = formDraft
const filtered = Object.fromEntries(
Object.entries(answers).filter(([key]) => draft.fields.has(key)),
) as SigningAnswers
const fingerprint = JSON.stringify(filtered)
if (fingerprint === draft.fingerprint) return
draft.fingerprint = fingerprint
formSaving.value = true
formSaveMessage.value = '正在保存填写内容…'
formSaveQueue = formSaveQueue.then(async () => {
if (formSaveError) return
try {
const saved = await saveSigningForm(draft.taskId, draft.revision, filtered)
draft.revision = saved.revision
formSaveMessage.value = '填写内容已保存'
} catch (error) {
formSaveError = error
formSaveMessage.value = '保存失败,请刷新核对后重试'
ElMessage.error('填写内容保存失败,请刷新核对后重试;本次签署已阻止')
}
})
const current = formSaveQueue
void current.finally(() => {
if (current === formSaveQueue) formSaving.value = false
})
}
function handleFieldChange(answers: SigningAnswers, progress: SigningFieldProgress) {
signingAnswers.value = answers
persistFormAnswers(answers)
signingProgress.value = progress
}
@@ -461,6 +527,12 @@ function handleFieldChange(answers: SigningAnswers, progress: SigningFieldProgre
* 但也不能不提醒,否则文书会带着空签名位被固化。
*/
async function assertSigningReady() {
await formSaveQueue
if (formSaveError) {
ElMessage.error('填写内容未保存,请刷新后重试')
return false
}
const missing = signingProgress.value?.missingRequired ?? []
if (missing.length) {
@@ -1085,6 +1157,10 @@ async function submitMousePadSignature(task: SigningTaskRecord, signatureDataUrl
throw new Error('手写板会话已失效,请重新打开鼠标签署')
}
await formSaveQueue
if (formSaveError) throw new Error('填写内容未保存,请刷新后重试')
const answerRevision =
formDraft?.taskId === task.id ? formDraft.revision : task.formState?.revision
const image = decodeSignaturePng(signatureDataUrl)
const consumed = await consumeSigningToken({
token: session.token,
@@ -1114,7 +1190,7 @@ async function submitMousePadSignature(task: SigningTaskRecord, signatureDataUrl
uploadToken: consumed.uploadToken,
file: image.blob,
metadata: buildSignatureMetadata(field, image, sha256),
signer: createPatientSignerAssertion(context.signer),
signer: { ...createPatientSignerAssertion(context.signer), answerRevision },
})
signatureDialogVisible.value = false
@@ -1335,6 +1411,8 @@ onMounted(() => {
:audit-loading="eventsLoading"
:audit-error="eventsError"
:allow-local-signing="isSigningMockEnabled"
:form-save-message="formSaveMessage"
:form-saving="formSaving"
:allow-mouse-signing="canSignTask && selectedTask?.method === 'pad'"
:artifacts="artifacts"
:artifacts-loading="artifactsLoading"