feat: enable patient form interactions and revision-aware signing
This commit is contained in:
@@ -269,3 +269,10 @@ API 请求和响应类型按业务域放在 `api/workbench/types.ts`、`api/mana
|
||||
当前尚未接入的签署能力包括手写板设备桥接、线上签署页面/签名回调和打印服务。签署投递、一次性 Token 消费和真实 PNG 上传 API 已完成封装,但页面不能用 Canvas 演示数据冒充真实签名;需要接入设备适配器或患者 H5 回调后再启用。短信初次发送接口需要完整手机号,而患者查询只返回脱敏手机号,因此真实模式会要求当前操作人员确认完整投递地址;生产环境也可以改为由后端根据患者 ID 解析投递地址。签署工作台缺省即使用演示数据(`VITE_MOCK_SIGNING`),需要走真实投递时把它设为 `false`。
|
||||
|
||||
文档库正文编辑器:选中「患者签名」占位符后,可以自动识别 PDF 位置,或生成 PDF 预览后输入坐标。阴影框支持拖动定位,宽高输入即时更新;点击「更新该项」或保存正文写入位置。正文变化后需重新生成预览。预览使用示例患者,实际患者文字长度可能影响排版,建议为填写项预留固定空间。重复的患者签名占位符不会自动选取。后端在保存及发布时检查页码与页面边界。
|
||||
|
||||
|
||||
### 工作台患者填写项
|
||||
|
||||
真实 PAD 任务的勾选表格、结构化单选/多选、文本与日期项,使用后端 `/v1/sign-tasks/{taskId}/form` 返回的字段定义和规范化正文。每次修改串行保存完整患者答案,使用 `expectedRevision` 防止覆盖其他端的修改;保存失败会阻止签署。系统快照及医护已填项只读。上传签名时带 `signer.answerRevision`,后端完成最终必填校验并固化答案。已签任务和记录详情读取签署快照,下载 PDF 使用同一份答案。
|
||||
|
||||
旧文书的勾选表格由后端按行生成独立编号;裸方框按独立勾选处理。需要互斥单选时应在模板中定义 `choice` 及选项。创建任务时单选/勾选项留给患者填写,文本项仍可由医护预填。页面显示“填写内容已保存”后可刷新回显。
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
})
|
||||
|
||||
|
||||
@@ -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[]
|
||||
@@ -400,12 +402,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"
|
||||
|
||||
@@ -83,3 +83,10 @@ patient-h5 不直接访问医院完整电子病历。
|
||||
## 当前状态
|
||||
|
||||
已完成 Vite 基础初始化、路由、签署流程状态管理和第一版移动端页面骨架。Mock 模式可以演示完整交互;真实模式已经接入一次性 Token 消费和 PNG 签名上传请求,并增加路由保护、重复提交保护和服务端结果展示。正式文书公开读取接口尚未提供,因此真实模式暂不会进入签署步骤。
|
||||
|
||||
|
||||
### 患者填写项
|
||||
|
||||
真实上下文响应新增 `form`(规范化正文、字段定义、答案、版本号、可编辑状态)。阅读页支持复选框、单选/多选、文本及日期输入,调用 `/v1/sign-deliveries/token/form` 自动保存;请求携带当前短期签署凭证、完整患者答案和 `expectedRevision`。凭证和患者答案不放在 URL 中。
|
||||
|
||||
进入签署确认前等待保存并检查必填项;最终上传携带 `signer.answerRevision`。恢复上传保留同一份 PNG、答案版本和幂等键。保存失败或版本冲突时需重新加载并核对内容;不会继续签署未保存的答案。签署确认页只读回显本次填写结果,签署后由服务端快照决定正文与 PDF 内容。
|
||||
|
||||
@@ -6,6 +6,8 @@ import type {
|
||||
SignatureUploadInput,
|
||||
SignatureUploadResponse,
|
||||
SigningProof,
|
||||
SigningFormState,
|
||||
PatientAnswer,
|
||||
SigningContextResponse,
|
||||
TokenConsumeRequest,
|
||||
TokenConsumeResponse,
|
||||
@@ -63,3 +65,17 @@ export function uploadSigningSignature(
|
||||
)
|
||||
.then(unwrapApiResponse)
|
||||
}
|
||||
|
||||
export function saveSigningForm(
|
||||
proof: SigningProof,
|
||||
expectedRevision: number,
|
||||
answers: Record<string, PatientAnswer>,
|
||||
): Promise<SigningFormState> {
|
||||
return request
|
||||
.post<ApiResponse<SigningFormState>>('/v1/sign-deliveries/token/form', {
|
||||
...proof,
|
||||
expectedRevision,
|
||||
answers,
|
||||
})
|
||||
.then(unwrapApiResponse)
|
||||
}
|
||||
|
||||
@@ -116,6 +116,7 @@ export interface SigningContextField {
|
||||
* 这个接口要能**重复调用**(患者刷新、返回上一步都要能重进)。
|
||||
*/
|
||||
export interface SigningContextResponse {
|
||||
form?: SigningFormState | null
|
||||
taskId: string
|
||||
deliveryId?: string | null
|
||||
channel?: SignDeliveryChannel | null
|
||||
@@ -179,7 +180,27 @@ export interface SignatureUploadInput {
|
||||
name: string
|
||||
relation: string
|
||||
consentAccepted: true
|
||||
answerRevision?: number
|
||||
observedSignedAt: string
|
||||
}
|
||||
idempotencyKey: string
|
||||
}
|
||||
|
||||
export type PatientAnswer = string | string[] | boolean
|
||||
export interface SigningFormField {
|
||||
id: string
|
||||
label: string
|
||||
kind: string
|
||||
required: boolean
|
||||
multi: boolean
|
||||
options: string[]
|
||||
editable: boolean
|
||||
}
|
||||
export interface SigningFormState {
|
||||
contentHtml: string
|
||||
fields: SigningFormField[]
|
||||
answers: Record<string, PatientAnswer>
|
||||
staffAnswers: Record<string, string>
|
||||
revision: number
|
||||
editable: boolean
|
||||
}
|
||||
|
||||
@@ -1,24 +1,14 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 患者端文书纸。
|
||||
*
|
||||
* 与 clinical-web 的 `components/document/DocumentPaper.vue` 是**两份独立实现**:
|
||||
* 那边排的是 A4 纸(打印与 PDF 导出要对齐),这边排的是手机屏(大字、宽行距、可滚动),
|
||||
* 排版目标不同,硬复用只会两头都别扭。
|
||||
*
|
||||
* 真正共用的是**占位符约定**(`<span data-field data-kind data-label>`),
|
||||
* 由 clinical-web 的 `utils/signing-fields.ts` 写入正文 —— 改那边要同步看这里。
|
||||
*
|
||||
* 填充口径(本次业务口径:医生先填好,患者只签自己的名):
|
||||
* - 医生已填的字段(勾选项 / 文本 / 医师签名)→ **只读**渲染;
|
||||
* - 患者自己的签名位(`id === patientFieldId`)→ 未签时显示「待患者签名」占位。
|
||||
*/
|
||||
import { nextTick, onMounted, ref, watch } from 'vue'
|
||||
import type { PatientAnswer, SigningFormField } from '@/api/types'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
/** 文书正文 HTML(含占位符),由 context 接口返回 */
|
||||
contentHtml: string
|
||||
fields?: SigningFormField[]
|
||||
patientAnswers?: Record<string, PatientAnswer>
|
||||
interactive?: boolean
|
||||
/** 医生已填的结果:键是字段 id,值是文本或签名图地址 */
|
||||
answers?: Record<string, string>
|
||||
/** 患者签名图。签署完成后回填到患者签名位 */
|
||||
@@ -33,12 +23,17 @@ const props = withDefaults(
|
||||
}>(),
|
||||
{
|
||||
answers: () => ({}),
|
||||
fields: () => [],
|
||||
patientAnswers: () => ({}),
|
||||
interactive: false,
|
||||
patientSignature: null,
|
||||
patientFieldId: 'signature',
|
||||
signedAt: null,
|
||||
},
|
||||
)
|
||||
|
||||
const emit = defineEmits<{ answerChange: [id: string, value: PatientAnswer] }>()
|
||||
|
||||
const paperRef = ref<HTMLElement | null>(null)
|
||||
|
||||
/** 勾选值判定:空串 / '0' / 'false' / 'off' 都算没勾 */
|
||||
@@ -62,6 +57,71 @@ function appendSignatureImage(node: HTMLElement, src: string, alt: string) {
|
||||
node.appendChild(image)
|
||||
}
|
||||
|
||||
function paintPatientField(node: HTMLElement, field: SigningFormField) {
|
||||
const value = props.patientAnswers[field.id]
|
||||
const enabled = props.interactive
|
||||
if (!enabled) {
|
||||
if (field.options.length) {
|
||||
const selected = Array.isArray(value) ? value : [value]
|
||||
node.textContent = field.options
|
||||
.map((option) => `${selected.includes(option) ? '☑' : '☐'} ${option}`)
|
||||
.join(' ')
|
||||
} else
|
||||
node.textContent =
|
||||
field.kind === 'checkbox' ? (value === true ? '☑' : '☐') : String(value ?? '')
|
||||
return
|
||||
}
|
||||
// Preserve focused inputs while the parent echoes answers and saves them.
|
||||
if (node.querySelector('input[data-patient-input]')) {
|
||||
node.querySelectorAll<HTMLInputElement>('input').forEach((input) => {
|
||||
if (input.type === 'checkbox' || input.type === 'radio') {
|
||||
input.checked = field.options.length
|
||||
? Array.isArray(value)
|
||||
? value.includes(input.value)
|
||||
: value === input.value
|
||||
: value === true
|
||||
} else if (document.activeElement !== input)
|
||||
input.value = typeof value === 'string' ? value : ''
|
||||
})
|
||||
return
|
||||
}
|
||||
node.textContent = ''
|
||||
const makeInput = (type: string, option?: string) => {
|
||||
const input = document.createElement('input')
|
||||
input.type = type
|
||||
input.dataset.patientInput = field.id
|
||||
input.setAttribute('aria-label', option || field.label)
|
||||
input.required = field.required && type !== 'checkbox'
|
||||
if (option !== undefined) {
|
||||
input.value = option
|
||||
input.name = `patient-field-${props.fields.indexOf(field)}-${Array.from(paperRef.value?.querySelectorAll('[data-field]') ?? []).indexOf(node)}`
|
||||
input.checked = Array.isArray(value) ? value.includes(option) : value === option
|
||||
} else if (type === 'checkbox') input.checked = value === true
|
||||
else {
|
||||
input.value = typeof value === 'string' ? value : ''
|
||||
input.maxLength = 4000
|
||||
}
|
||||
input.addEventListener(type === 'text' || type === 'date' ? 'input' : 'change', () => {
|
||||
let answer: PatientAnswer = input.value
|
||||
if (option !== undefined && field.multi) {
|
||||
const prior = props.patientAnswers[field.id]
|
||||
const selected = Array.isArray(prior) ? prior : []
|
||||
answer = input.checked
|
||||
? [...new Set([...selected, option])]
|
||||
: selected.filter((item) => item !== option)
|
||||
} else if (option === undefined && type === 'checkbox') answer = input.checked
|
||||
emit('answerChange', field.id, answer)
|
||||
})
|
||||
const label = document.createElement('label')
|
||||
label.append(input)
|
||||
if (option !== undefined) label.append(document.createTextNode(option))
|
||||
node.append(label)
|
||||
}
|
||||
if (field.options.length)
|
||||
field.options.forEach((option) => makeInput(field.multi ? 'checkbox' : 'radio', option))
|
||||
else makeInput(field.kind === 'checkbox' ? 'checkbox' : field.kind === 'date' ? 'date' : 'text')
|
||||
}
|
||||
|
||||
/**
|
||||
* 把结果填进正文占位符。
|
||||
*
|
||||
@@ -97,6 +157,12 @@ function paint() {
|
||||
return
|
||||
}
|
||||
|
||||
const field = props.fields.find((item) => item.id === id && item.editable)
|
||||
if (field) {
|
||||
paintPatientField(node, field)
|
||||
return
|
||||
}
|
||||
|
||||
const value = (props.answers?.[id] ?? '').trim()
|
||||
|
||||
// ② 签字日期不劳患者填:签了字,日期就是签署当天。
|
||||
@@ -146,7 +212,14 @@ onMounted(() => {
|
||||
})
|
||||
|
||||
watch(
|
||||
() => [props.contentHtml, props.answers, props.patientSignature],
|
||||
() => [
|
||||
props.contentHtml,
|
||||
props.answers,
|
||||
props.patientSignature,
|
||||
props.fields,
|
||||
props.patientAnswers,
|
||||
props.interactive,
|
||||
],
|
||||
() => {
|
||||
void nextTick(paint)
|
||||
},
|
||||
@@ -253,4 +326,23 @@ watch(
|
||||
max-height: 90px;
|
||||
margin: 2px 0;
|
||||
}
|
||||
.patient-paper :deep([data-patient-input]) {
|
||||
max-width: 100%;
|
||||
font: inherit;
|
||||
accent-color: #078669;
|
||||
}
|
||||
.patient-paper :deep(input[type='text']),
|
||||
.patient-paper :deep(input[type='date']) {
|
||||
width: 100%;
|
||||
min-width: 70px;
|
||||
border: 0;
|
||||
border-bottom: 1px solid #078669;
|
||||
background: #f2fbf7;
|
||||
}
|
||||
.patient-paper :deep([data-field] label) {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-right: 10px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
import { reactive } from 'vue'
|
||||
|
||||
import { consumeSigningToken, fetchSigningContext, uploadSigningSignature } from '@/api'
|
||||
import {
|
||||
consumeSigningToken,
|
||||
fetchSigningContext,
|
||||
saveSigningForm,
|
||||
uploadSigningSignature,
|
||||
} from '@/api'
|
||||
import type {
|
||||
SignDeliveryChannel,
|
||||
PatientAnswer,
|
||||
SignatureUploadResponse,
|
||||
SigningContextResponse,
|
||||
} from '@/api/types'
|
||||
@@ -58,6 +64,9 @@ const initialState: SignFlowState = {
|
||||
documentContentHtml: '',
|
||||
contentSha256: '',
|
||||
staffAnswers: {},
|
||||
form: null,
|
||||
formSaving: false,
|
||||
formError: '',
|
||||
fields: [],
|
||||
signatureFields: [],
|
||||
documentLoaded: false,
|
||||
@@ -88,6 +97,51 @@ let pendingToken: string | null = null
|
||||
let consumeIdempotencyKey: string | null = null
|
||||
let uploadIdempotencyKey: string | null = null
|
||||
|
||||
let formQueue: Promise<void> = Promise.resolve()
|
||||
|
||||
export function updatePatientAnswer(id: string, value: PatientAnswer) {
|
||||
const form = signingState.form
|
||||
const proof = currentProof()
|
||||
if (!form?.editable || !proof || signingState.status === 'submitting' || signingState.formError)
|
||||
return
|
||||
if (!form.fields.some((field) => field.id === id && field.editable)) return
|
||||
form.answers = { ...form.answers, [id]: value }
|
||||
const answers = JSON.parse(JSON.stringify(form.answers)) as Record<string, PatientAnswer>
|
||||
signingState.formSaving = true
|
||||
formQueue = formQueue.then(async () => {
|
||||
if (signingState.form !== form || signingState.formError) return
|
||||
try {
|
||||
const saved = await saveSigningForm(proof, form.revision, answers)
|
||||
form.revision = saved.revision
|
||||
} catch {
|
||||
signingState.formError = '填写内容保存失败,请重新加载并核对后再签署。'
|
||||
}
|
||||
})
|
||||
const current = formQueue
|
||||
void current.finally(() => {
|
||||
if (current === formQueue) signingState.formSaving = false
|
||||
})
|
||||
}
|
||||
|
||||
export async function flushPatientAnswers() {
|
||||
await formQueue
|
||||
if (signingState.formError) throw new Error(signingState.formError)
|
||||
const form = signingState.form
|
||||
if (!form) return
|
||||
for (const field of form.fields) {
|
||||
if (!field.editable || !field.required) continue
|
||||
const value = form.answers[field.id]
|
||||
if (
|
||||
value === undefined ||
|
||||
value === false ||
|
||||
(typeof value === 'string' && !value.trim()) ||
|
||||
(Array.isArray(value) && !value.length)
|
||||
) {
|
||||
throw new Error(`请完成必填项:${field.label}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function resetSignFlow() {
|
||||
Object.assign(signingState, {
|
||||
...initialState,
|
||||
@@ -287,6 +341,12 @@ function applyContext(context: SigningContextResponse) {
|
||||
signingState.staffAnswers = { ...context.staffAnswers }
|
||||
}
|
||||
|
||||
signingState.form = context.form
|
||||
? { ...context.form, answers: { ...context.form.answers } }
|
||||
: null
|
||||
if (context.form) signingState.documentContentHtml = context.form.contentHtml
|
||||
signingState.formError = ''
|
||||
|
||||
if (context.fields) {
|
||||
signingState.fields = [...context.fields]
|
||||
}
|
||||
@@ -547,7 +607,9 @@ export async function submitRealSignature(input: {
|
||||
const pending = await readPendingSigningUpload(signingState.taskId)
|
||||
const idempotencyKey = pending?.idempotencyKey ?? uploadIdempotencyKey ?? createIdempotencyKey()
|
||||
uploadIdempotencyKey = idempotencyKey
|
||||
if (!pending) await flushPatientAnswers()
|
||||
const signer = {
|
||||
answerRevision: signingState.form?.revision,
|
||||
type: signerType,
|
||||
name: signerName,
|
||||
relation,
|
||||
@@ -603,6 +665,10 @@ export async function submitRealSignature(input: {
|
||||
persistSession()
|
||||
return null
|
||||
}
|
||||
if ([400, 409, 422].includes(getHttpStatus(error) ?? 0)) {
|
||||
await removePendingSigningUpload(signingState.taskId).catch(() => undefined)
|
||||
uploadIdempotencyKey = null
|
||||
}
|
||||
signingState.status = 'ready'
|
||||
signingState.errorMessage = getErrorMessage(error)
|
||||
throw error
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type {
|
||||
SignDeliveryChannel,
|
||||
SigningFormState,
|
||||
SignatureFieldPosition,
|
||||
SignatureUploadResponse,
|
||||
SigningContextField,
|
||||
@@ -37,6 +38,9 @@ export interface SignFlowState {
|
||||
contentSha256: string
|
||||
/** 医生已填的结果(含医师签名图),患者端**只读**渲染 */
|
||||
staffAnswers: Record<string, string>
|
||||
form: SigningFormState | null
|
||||
formSaving: boolean
|
||||
formError: string
|
||||
/** 患者端可交互的字段。按约定只有主签名位(`id === 'signature'`) */
|
||||
fields: SigningContextField[]
|
||||
/** PDF points and page number supplied by the task's frozen template version. */
|
||||
|
||||
@@ -12,6 +12,7 @@ export interface PendingSigningUpload {
|
||||
relation: string
|
||||
consentAccepted: true
|
||||
observedSignedAt: string
|
||||
answerRevision?: number
|
||||
}
|
||||
idempotencyKey: string
|
||||
savedAt: number
|
||||
|
||||
@@ -12,20 +12,30 @@ import { ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
import DocumentPaper from '@/components/DocumentPaper.vue'
|
||||
import { signingState } from '@/composables/useSignFlow'
|
||||
import { signingState, updatePatientAnswer, flushPatientAnswers } from '@/composables/useSignFlow'
|
||||
|
||||
const router = useRouter()
|
||||
const hasRead = ref(false)
|
||||
const errorMessage = ref('')
|
||||
|
||||
function continueToSigner() {
|
||||
const continuing = ref(false)
|
||||
|
||||
async function continueToSigner() {
|
||||
if (!hasRead.value) {
|
||||
errorMessage.value = '请先确认已阅读文书内容。'
|
||||
return
|
||||
}
|
||||
|
||||
signingState.consentAccepted = true
|
||||
void router.push('/signer')
|
||||
continuing.value = true
|
||||
try {
|
||||
await flushPatientAnswers()
|
||||
signingState.consentAccepted = true
|
||||
await router.push('/signer')
|
||||
} catch (error) {
|
||||
errorMessage.value = error instanceof Error ? error.message : '填写内容尚未保存'
|
||||
} finally {
|
||||
continuing.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -48,15 +58,28 @@ function continueToSigner() {
|
||||
<DocumentPaper
|
||||
:content-html="signingState.documentContentHtml"
|
||||
:answers="signingState.staffAnswers"
|
||||
:fields="signingState.form?.fields"
|
||||
:patient-answers="signingState.form?.answers"
|
||||
:interactive="Boolean(signingState.form?.editable) && !continuing && !signingState.formError"
|
||||
@answer-change="updatePatientAnswer"
|
||||
/>
|
||||
|
||||
<p v-if="signingState.formError" class="form-error">{{ signingState.formError }}</p>
|
||||
<p v-else-if="signingState.formSaving">正在保存填写内容…</p>
|
||||
<label class="check-row">
|
||||
<input v-model="hasRead" type="checkbox" />
|
||||
<span>我已阅读并理解上述内容,愿意继续确认签署人信息。</span>
|
||||
</label>
|
||||
<p v-if="errorMessage" class="form-error">{{ errorMessage }}</p>
|
||||
|
||||
<button class="mobile-primary-button" type="button" @click="continueToSigner">下一步</button>
|
||||
<button
|
||||
class="mobile-primary-button"
|
||||
type="button"
|
||||
:disabled="continuing || Boolean(signingState.formError)"
|
||||
@click="continueToSigner"
|
||||
>
|
||||
下一步
|
||||
</button>
|
||||
<button class="mobile-text-button" type="button" @click="router.back()">返回</button>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
@@ -119,6 +119,8 @@ async function submitSigning() {
|
||||
<DocumentPaper
|
||||
:content-html="signingState.documentContentHtml"
|
||||
:answers="signingState.staffAnswers"
|
||||
:fields="signingState.form?.fields"
|
||||
:patient-answers="signingState.form?.answers"
|
||||
:patient-signature="signatureDataUrl || null"
|
||||
:signed-at="signedDate"
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user