diff --git a/patient-h5/src/api/index.ts b/patient-h5/src/api/index.ts index 629b64d..c025fe2 100644 --- a/patient-h5/src/api/index.ts +++ b/patient-h5/src/api/index.ts @@ -1,6 +1,52 @@ -import axios from 'axios' +import { unwrapApiResponse } from '@/utils/api-response' +import { request } from '@/utils/request' -export const apiClient = axios.create({ - baseURL: import.meta.env.VITE_API_BASE_URL || '/api', - timeout: 10_000, -}) +import type { + ApiResponse, + SignatureUploadInput, + SignatureUploadResponse, + TokenConsumeRequest, + TokenConsumeResponse, +} from './types' + +export function consumeSigningToken(payload: TokenConsumeRequest): Promise { + return request + .post>('/v1/sign-deliveries/token/consume', payload) + .then(unwrapApiResponse) +} + +function createIdempotencyKey(value?: string) { + if (value) { + return value + } + + if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') { + return `patient-h5-${crypto.randomUUID()}` + } + + return `patient-h5-${Date.now()}-${Math.random().toString(36).slice(2)}` +} + +export function uploadSigningSignature( + payload: SignatureUploadInput, +): Promise { + const formData = new FormData() + formData.append('file', payload.file, 'signature.png') + formData.append('metadata', payload.metadata) + + return request + .post>( + `/v1/sign-deliveries/${encodeURIComponent(payload.taskId)}/signature`, + formData, + { + params: { + deliveryId: payload.deliveryId, + uploadToken: payload.uploadToken, + }, + headers: { + 'Idempotency-Key': createIdempotencyKey(payload.idempotencyKey), + }, + }, + ) + .then(unwrapApiResponse) +} diff --git a/patient-h5/src/api/types.ts b/patient-h5/src/api/types.ts new file mode 100644 index 0000000..9b04ec7 --- /dev/null +++ b/patient-h5/src/api/types.ts @@ -0,0 +1,51 @@ +export interface ApiResponse { + code: number | string + message: string + data: T | null + traceId?: string + timestamp?: string +} + +export type SignDeliveryChannel = 'PAD' | 'SMS' + +export interface TokenConsumeRequest { + token: string + channel?: SignDeliveryChannel + taskId?: string +} + +export interface TokenConsumeResponse { + deliveryId: string + taskId: string + channel: SignDeliveryChannel + uploadToken: string + traceId?: string | null + timestamp?: string | null +} + +export type SignatureUploadStatus = + 'CREATED' | 'WAITING_SIGN' | 'SIGNED' | 'EXPIRED' | 'VOIDED' | 'GENERATING' | 'FAILED' | string + +export interface SignatureUploadResponse { + taskId: string + pipelineId: string + status: SignatureUploadStatus + originalPdfArtifactId: string + signedPdfArtifactId: string + signatureImageArtifactId: string + originalPdfSha256: string + signedPdfSha256: string + signatureImageSha256: string + completedAt: string | null + traceId?: string | null + timestamp?: string | null +} + +export interface SignatureUploadInput { + taskId: string + deliveryId: string + uploadToken: string + file: Blob + metadata: string + idempotencyKey?: string +} diff --git a/patient-h5/src/composables/useSignFlow.ts b/patient-h5/src/composables/useSignFlow.ts index d3e429a..d3314d6 100644 --- a/patient-h5/src/composables/useSignFlow.ts +++ b/patient-h5/src/composables/useSignFlow.ts @@ -1,21 +1,135 @@ import { reactive } from 'vue' +import { consumeSigningToken, uploadSigningSignature } from '@/api' +import type { SignatureUploadResponse } from '@/api/types' import type { SignFlowState } from '@/types/signing' const initialState: SignFlowState = { + mode: 'mock', + status: 'idle', patientName: '张*', visitNo: 'MZ20260827018', documentTitle: '儿科急诊特殊检查知情同意书', taskNo: 'CT-20260827-001', + taskId: '', + deliveryId: '', + uploadToken: '', + channel: '', consentAccepted: false, signerName: '', relation: '', signatureCaptured: false, submittedAt: '', + backendStatus: '', + pipelineId: '', + uploadResult: null, + errorMessage: '', } export const signingState = reactive({ ...initialState }) export function resetSignFlow() { - Object.assign(signingState, initialState) + Object.assign(signingState, { + ...initialState, + uploadResult: null, + }) +} + +function getErrorMessage(error: unknown) { + return error instanceof Error ? error.message : '签署链接验证失败,请联系现场医务人员。' +} + +export async function initializeSignFlow(token?: string) { + resetSignFlow() + + const candidate = token?.trim() + const shouldUseRealFlow = Boolean(candidate) || import.meta.env.VITE_USE_MOCK === 'false' + + signingState.mode = shouldUseRealFlow ? 'real' : 'mock' + + if (!shouldUseRealFlow) { + signingState.status = 'ready' + return true + } + + if (!candidate) { + signingState.status = 'blocked' + signingState.errorMessage = '请使用短信或二维码中的有效签署链接进入。' + return false + } + + signingState.status = 'loading' + + try { + const session = await consumeSigningToken({ token: candidate, channel: 'SMS' }) + + signingState.taskId = session.taskId + signingState.taskNo = session.taskId + signingState.deliveryId = session.deliveryId + signingState.uploadToken = session.uploadToken + signingState.channel = session.channel + signingState.documentTitle = '待签署知情同意文书' + signingState.patientName = '患者信息待确认' + signingState.visitNo = '' + signingState.status = 'ready' + return true + } catch (error) { + signingState.status = 'error' + signingState.errorMessage = getErrorMessage(error) + return false + } +} + +export async function submitRealSignature(input: { + file: Blob + signerName: string + relation: NonNullable + consentAccepted: boolean +}): Promise { + if (signingState.mode !== 'real') { + throw new Error('当前是演示签署流程') + } + + if ( + !signingState.taskId || + !signingState.deliveryId || + !signingState.uploadToken || + !input.consentAccepted + ) { + throw new Error('签署凭证或知情确认状态无效,请重新打开签署链接。') + } + + signingState.status = 'submitting' + signingState.errorMessage = '' + + try { + const result = await uploadSigningSignature({ + taskId: signingState.taskId, + deliveryId: signingState.deliveryId, + uploadToken: signingState.uploadToken, + file: input.file, + metadata: JSON.stringify({ + signerName: input.signerName, + relation: input.relation, + consentAccepted: input.consentAccepted, + signedAt: new Date().toISOString(), + }), + }) + + signingState.signerName = input.signerName + signingState.relation = input.relation + signingState.consentAccepted = input.consentAccepted + signingState.signatureCaptured = true + signingState.submittedAt = new Date().toLocaleString('zh-CN') + signingState.backendStatus = result.status + signingState.pipelineId = result.pipelineId + signingState.uploadResult = result + signingState.uploadToken = '' + signingState.status = 'submitted' + return result + } catch (error) { + signingState.status = 'ready' + signingState.errorMessage = getErrorMessage(error) + throw error + } } diff --git a/patient-h5/src/router/index.ts b/patient-h5/src/router/index.ts index ce95c08..f05cabe 100644 --- a/patient-h5/src/router/index.ts +++ b/patient-h5/src/router/index.ts @@ -1,5 +1,6 @@ import { createRouter, createWebHistory } from 'vue-router' +import { signingState } from '@/composables/useSignFlow' import ConsentView from '@/views/consent/ConsentView.vue' import EntryView from '@/views/entry/EntryView.vue' import ResultView from '@/views/result/ResultView.vue' @@ -17,4 +18,24 @@ const router = createRouter({ ], }) +router.beforeEach((to) => { + if (to.path === '/entry') { + return true + } + + if (signingState.status === 'idle') { + return '/entry' + } + + if (to.path === '/signer' && !signingState.consentAccepted) { + return '/consent' + } + + if (to.path === '/result' && !signingState.signatureCaptured) { + return '/entry' + } + + return true +}) + export default router diff --git a/patient-h5/src/types/signing.ts b/patient-h5/src/types/signing.ts index 1d7e865..1f598b6 100644 --- a/patient-h5/src/types/signing.ts +++ b/patient-h5/src/types/signing.ts @@ -1,15 +1,32 @@ +import type { SignDeliveryChannel, SignatureUploadResponse } from '@/api/types' + export type SignerRelation = '本人' | '父亲' | '母亲' | '其他监护人' | '其他' +export type SignFlowMode = 'mock' | 'real' + +export type SignFlowStatus = + 'idle' | 'loading' | 'ready' | 'blocked' | 'submitting' | 'submitted' | 'error' + export interface SignFlowState { + mode: SignFlowMode + status: SignFlowStatus patientName: string visitNo: string documentTitle: string taskNo: string + taskId: string + deliveryId: string + uploadToken: string + channel: SignDeliveryChannel | '' consentAccepted: boolean signerName: string relation: SignerRelation | '' signatureCaptured: boolean submittedAt: string + backendStatus: string + pipelineId: string + uploadResult: SignatureUploadResponse | null + errorMessage: string } export const relationOptions: Array<{ value: SignerRelation; label: string }> = [ diff --git a/patient-h5/src/utils/api-response.ts b/patient-h5/src/utils/api-response.ts new file mode 100644 index 0000000..35f0c34 --- /dev/null +++ b/patient-h5/src/utils/api-response.ts @@ -0,0 +1,29 @@ +import type { ApiResponse } from '@/api/types' + +export class ApiResponseError extends Error { + readonly code: number | string + readonly traceId?: string + + constructor(response: ApiResponse) { + super(response.message || '签署服务请求失败') + this.name = 'ApiResponseError' + this.code = response.code + this.traceId = response.traceId + } +} + +export function isSuccessCode(code: number | string) { + return code === 0 || code === '0' +} + +export function unwrapApiResponse(response: ApiResponse): T { + if (!isSuccessCode(response.code)) { + throw new ApiResponseError(response) + } + + if (response.data === null) { + throw new Error(response.message || '签署服务未返回数据') + } + + return response.data +} diff --git a/patient-h5/src/utils/request.ts b/patient-h5/src/utils/request.ts new file mode 100644 index 0000000..87c0f40 --- /dev/null +++ b/patient-h5/src/utils/request.ts @@ -0,0 +1,35 @@ +import axios, { type AxiosInstance, type AxiosRequestConfig, type AxiosResponse } from 'axios' + +const DEFAULT_API_BASE_URL = '/api' +const DEFAULT_TIMEOUT = 15_000 + +class Request { + private readonly instance: AxiosInstance + + constructor( + baseURL = import.meta.env.VITE_API_BASE_URL || DEFAULT_API_BASE_URL, + timeout = DEFAULT_TIMEOUT, + ) { + this.instance = axios.create({ + baseURL, + timeout, + withCredentials: false, + }) + } + + request(config: AxiosRequestConfig): Promise { + return this.instance.request>(config).then((response) => response.data) + } + + get(url: string, config: AxiosRequestConfig = {}): Promise { + return this.request({ ...config, url, method: 'GET' }) + } + + post(url: string, data?: unknown, config: AxiosRequestConfig = {}): Promise { + return this.request({ ...config, url, data, method: 'POST' }) + } +} + +export const request = new Request() + +export default request diff --git a/patient-h5/src/views/entry/EntryView.vue b/patient-h5/src/views/entry/EntryView.vue index 465aa36..9dbe554 100644 --- a/patient-h5/src/views/entry/EntryView.vue +++ b/patient-h5/src/views/entry/EntryView.vue @@ -1,14 +1,45 @@ + + diff --git a/patient-h5/src/views/result/ResultView.vue b/patient-h5/src/views/result/ResultView.vue index 85eea9b..62586a9 100644 --- a/patient-h5/src/views/result/ResultView.vue +++ b/patient-h5/src/views/result/ResultView.vue @@ -1,10 +1,38 @@