feat: enable patient form interactions and revision-aware signing

This commit is contained in:
cnxianyi
2026-09-29 21:37:30 +08:00
parent dc94265703
commit 9173d623a1
17 changed files with 451 additions and 49 deletions
+16
View File
@@ -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)
}
+21
View File
@@ -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
}
+107 -15
View File
@@ -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>
+67 -1
View File
@@ -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
+4
View File
@@ -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. */
+1
View File
@@ -12,6 +12,7 @@ export interface PendingSigningUpload {
relation: string
consentAccepted: true
observedSignedAt: string
answerRevision?: number
}
idempotencyKey: string
savedAt: number
+28 -5
View File
@@ -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"
/>