feat(patient-h5): 接入签署令牌与签名上传
This commit is contained in:
@@ -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<TokenConsumeResponse> {
|
||||
return request
|
||||
.post<ApiResponse<TokenConsumeResponse>>('/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<SignatureUploadResponse> {
|
||||
const formData = new FormData()
|
||||
formData.append('file', payload.file, 'signature.png')
|
||||
formData.append('metadata', payload.metadata)
|
||||
|
||||
return request
|
||||
.post<ApiResponse<SignatureUploadResponse>>(
|
||||
`/v1/sign-deliveries/${encodeURIComponent(payload.taskId)}/signature`,
|
||||
formData,
|
||||
{
|
||||
params: {
|
||||
deliveryId: payload.deliveryId,
|
||||
uploadToken: payload.uploadToken,
|
||||
},
|
||||
headers: {
|
||||
'Idempotency-Key': createIdempotencyKey(payload.idempotencyKey),
|
||||
},
|
||||
},
|
||||
)
|
||||
.then(unwrapApiResponse)
|
||||
}
|
||||
|
||||
51
patient-h5/src/api/types.ts
Normal file
51
patient-h5/src/api/types.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
export interface ApiResponse<T> {
|
||||
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
|
||||
}
|
||||
@@ -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<SignFlowState>({ ...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<SignFlowState['relation']>
|
||||
consentAccepted: boolean
|
||||
}): Promise<SignatureUploadResponse> {
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 }> = [
|
||||
|
||||
29
patient-h5/src/utils/api-response.ts
Normal file
29
patient-h5/src/utils/api-response.ts
Normal file
@@ -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<unknown>) {
|
||||
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<T>(response: ApiResponse<T>): T {
|
||||
if (!isSuccessCode(response.code)) {
|
||||
throw new ApiResponseError(response)
|
||||
}
|
||||
|
||||
if (response.data === null) {
|
||||
throw new Error(response.message || '签署服务未返回数据')
|
||||
}
|
||||
|
||||
return response.data
|
||||
}
|
||||
35
patient-h5/src/utils/request.ts
Normal file
35
patient-h5/src/utils/request.ts
Normal file
@@ -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<T = unknown>(config: AxiosRequestConfig): Promise<T> {
|
||||
return this.instance.request<T, AxiosResponse<T>>(config).then((response) => response.data)
|
||||
}
|
||||
|
||||
get<T = unknown>(url: string, config: AxiosRequestConfig = {}): Promise<T> {
|
||||
return this.request<T>({ ...config, url, method: 'GET' })
|
||||
}
|
||||
|
||||
post<T = unknown>(url: string, data?: unknown, config: AxiosRequestConfig = {}): Promise<T> {
|
||||
return this.request<T>({ ...config, url, data, method: 'POST' })
|
||||
}
|
||||
}
|
||||
|
||||
export const request = new Request()
|
||||
|
||||
export default request
|
||||
@@ -1,14 +1,45 @@
|
||||
<script setup lang="ts">
|
||||
import { useRouter } from 'vue-router'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
|
||||
import { resetSignFlow, signingState } from '@/composables/useSignFlow'
|
||||
import { initializeSignFlow, signingState } from '@/composables/useSignFlow'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const loading = ref(true)
|
||||
|
||||
const isRealFlow = computed(() => signingState.mode === 'real')
|
||||
const canStartSigning = computed(() => signingState.status === 'ready')
|
||||
|
||||
function getQueryValue(value: unknown) {
|
||||
if (Array.isArray(value)) {
|
||||
return value[0] ?? ''
|
||||
}
|
||||
|
||||
return typeof value === 'string' ? value : ''
|
||||
}
|
||||
|
||||
async function initialize() {
|
||||
const token =
|
||||
getQueryValue(route.query.token) ||
|
||||
getQueryValue(route.query.signToken) ||
|
||||
getQueryValue(route.query.t)
|
||||
|
||||
await initializeSignFlow(token)
|
||||
loading.value = false
|
||||
}
|
||||
|
||||
function startSigning() {
|
||||
resetSignFlow()
|
||||
if (!canStartSigning.value) {
|
||||
return
|
||||
}
|
||||
|
||||
void router.push('/consent')
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
void initialize()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -21,36 +52,170 @@ function startSigning() {
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section class="mobile-hero">
|
||||
<p class="eyebrow">待签署文书</p>
|
||||
<h1>{{ signingState.documentTitle }}</h1>
|
||||
<p>请确认以下任务信息,并在阅读文书后完成签署。</p>
|
||||
</section>
|
||||
<div v-if="loading" class="mobile-state-card">
|
||||
<p class="eyebrow">安全验证</p>
|
||||
<h1>正在验证签署链接</h1>
|
||||
<p>请稍候,系统正在确认本次签署凭证。</p>
|
||||
</div>
|
||||
|
||||
<section class="mobile-card patient-card">
|
||||
<div class="card-label">患者信息</div>
|
||||
<div class="patient-row">
|
||||
<div class="patient-avatar">{{ signingState.patientName.slice(0, 1) }}</div>
|
||||
<div>
|
||||
<strong>{{ signingState.patientName }}</strong>
|
||||
<span>{{ signingState.visitNo }}</span>
|
||||
<template v-else-if="signingState.status === 'error' || signingState.status === 'blocked'">
|
||||
<section class="mobile-state-card mobile-state-card--error">
|
||||
<p class="eyebrow">链接不可用</p>
|
||||
<h1>无法继续签署</h1>
|
||||
<p>{{ signingState.errorMessage }}</p>
|
||||
<p class="secure-note">请不要重复尝试或转发链接,如有疑问请联系现场医务人员。</p>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<section class="mobile-hero">
|
||||
<p class="eyebrow">待签署文书</p>
|
||||
<h1>{{ signingState.documentTitle }}</h1>
|
||||
<p v-if="isRealFlow">
|
||||
签署链接已验证。请先核对现场展示的正式文书内容,再继续确认签署人信息。
|
||||
</p>
|
||||
<p v-else>请确认以下任务信息,并在阅读文书后完成签署。</p>
|
||||
</section>
|
||||
|
||||
<section v-if="isRealFlow" class="mobile-card real-session-card">
|
||||
<div class="card-label">签署凭证</div>
|
||||
<div class="real-session-row">
|
||||
<span class="real-session-icon">✓</span>
|
||||
<div>
|
||||
<strong>一次性签署链接已验证</strong>
|
||||
<span>任务编号:{{ signingState.taskNo }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="task-number">任务编号:{{ signingState.taskNo }}</div>
|
||||
</section>
|
||||
</section>
|
||||
|
||||
<section class="mobile-card notice-card">
|
||||
<div class="notice-icon">i</div>
|
||||
<div>
|
||||
<strong>请使用实际签署人的信息</strong>
|
||||
<p>如果由家属或监护人代签,请在下一步选择与患者的关系。</p>
|
||||
</div>
|
||||
</section>
|
||||
<section v-else class="mobile-card patient-card">
|
||||
<div class="card-label">患者信息</div>
|
||||
<div class="patient-row">
|
||||
<div class="patient-avatar">{{ signingState.patientName.slice(0, 1) }}</div>
|
||||
<div>
|
||||
<strong>{{ signingState.patientName }}</strong>
|
||||
<span>{{ signingState.visitNo }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="task-number">任务编号:{{ signingState.taskNo }}</div>
|
||||
</section>
|
||||
|
||||
<button class="mobile-primary-button" type="button" @click="startSigning">
|
||||
开始阅读并签署
|
||||
</button>
|
||||
<section class="mobile-card notice-card">
|
||||
<div class="notice-icon">i</div>
|
||||
<div>
|
||||
<strong>请使用实际签署人的信息</strong>
|
||||
<p>如果由家属或监护人代签,请在下一步选择与患者的关系。</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<p class="secure-note">本页面仅用于本次知情同意签署,请勿转发签署链接。</p>
|
||||
<section v-if="isRealFlow" class="mobile-card integration-note">
|
||||
<strong>正式文书内容暂未由公开接口返回</strong>
|
||||
<p>
|
||||
当前后端公开 Token
|
||||
接口只返回一次性上传凭证,没有提供患者端读取任务文书的接口。为避免在未阅读正式内容的情况下签署,本版本不会提交真实签名。
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<button
|
||||
class="mobile-primary-button"
|
||||
type="button"
|
||||
:disabled="isRealFlow"
|
||||
@click="startSigning"
|
||||
>
|
||||
{{ isRealFlow ? '等待文书接口接入' : '开始阅读并签署' }}
|
||||
</button>
|
||||
|
||||
<p class="secure-note">本页面仅用于本次知情同意签署,请勿转发签署链接。</p>
|
||||
</template>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.mobile-state-card,
|
||||
.integration-note,
|
||||
.real-session-card {
|
||||
padding: 20px;
|
||||
border: 1px solid #e0eceb;
|
||||
border-radius: 18px;
|
||||
background: rgb(255 255 255 / 86%);
|
||||
box-shadow: 0 12px 30px rgb(33 79 92 / 5%);
|
||||
}
|
||||
|
||||
.mobile-state-card {
|
||||
margin-top: 20vh;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.mobile-state-card h1 {
|
||||
margin: 0;
|
||||
color: #173b56;
|
||||
font-size: 25px;
|
||||
}
|
||||
|
||||
.mobile-state-card p:not(.eyebrow) {
|
||||
margin: 12px 0 0;
|
||||
color: #788f9d;
|
||||
font-size: 14px;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.mobile-state-card--error {
|
||||
border-color: #f0d8d8;
|
||||
}
|
||||
|
||||
.real-session-row {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.real-session-icon {
|
||||
display: grid;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
color: #1f776f;
|
||||
font-weight: 800;
|
||||
background: #dff3ee;
|
||||
border-radius: 12px;
|
||||
place-items: center;
|
||||
}
|
||||
|
||||
.real-session-row strong,
|
||||
.real-session-row span {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.real-session-row strong {
|
||||
color: #31576c;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.real-session-row div span {
|
||||
margin-top: 5px;
|
||||
color: #8aa0aa;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.integration-note {
|
||||
margin-top: 14px;
|
||||
background: #fffaf2;
|
||||
border-color: #f0dfc1;
|
||||
}
|
||||
|
||||
.integration-note strong {
|
||||
color: #876126;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.integration-note p {
|
||||
margin: 7px 0 0;
|
||||
color: #927b58;
|
||||
font-size: 12px;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.mobile-primary-button:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.6;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,10 +1,38 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
import { resetSignFlow, signingState } from '@/composables/useSignFlow'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
const resultTitle = computed(() =>
|
||||
signingState.mode === 'real' && signingState.backendStatus === 'GENERATING'
|
||||
? '签名已提交,文书生成中'
|
||||
: '知情同意书已提交',
|
||||
)
|
||||
|
||||
const resultCopy = computed(() =>
|
||||
signingState.mode === 'real' && signingState.backendStatus === 'GENERATING'
|
||||
? '签名图片已安全提交,系统正在生成签署后文书。最终状态以医签通服务端为准。'
|
||||
: '本次签署结果已提交至医签通。请按照医务人员指引完成后续诊疗安排。',
|
||||
)
|
||||
|
||||
const statusLabel = computed(() => {
|
||||
if (signingState.mode !== 'real') {
|
||||
return '演示完成'
|
||||
}
|
||||
|
||||
const labels: Record<string, string> = {
|
||||
SIGNED: '已签署',
|
||||
GENERATING: '生成中',
|
||||
WAITING_SIGN: '待签署',
|
||||
FAILED: '处理失败',
|
||||
}
|
||||
|
||||
return labels[signingState.backendStatus] ?? (signingState.backendStatus || '已提交')
|
||||
})
|
||||
|
||||
function returnToEntry() {
|
||||
resetSignFlow()
|
||||
void router.replace('/entry')
|
||||
@@ -15,8 +43,8 @@ function returnToEntry() {
|
||||
<main class="mobile-shell result-shell">
|
||||
<div class="result-icon">✓</div>
|
||||
<p class="eyebrow">签署完成</p>
|
||||
<h1>知情同意书已提交</h1>
|
||||
<p class="result-copy">本次签署结果已提交至医签通。请按照医务人员指引完成后续诊疗安排。</p>
|
||||
<h1>{{ resultTitle }}</h1>
|
||||
<p class="result-copy">{{ resultCopy }}</p>
|
||||
|
||||
<section class="mobile-card result-card">
|
||||
<div>
|
||||
@@ -31,6 +59,14 @@ function returnToEntry() {
|
||||
<span>提交时间</span>
|
||||
<strong>{{ signingState.submittedAt }}</strong>
|
||||
</div>
|
||||
<div v-if="signingState.mode === 'real'">
|
||||
<span>服务端状态</span>
|
||||
<strong>{{ statusLabel }}</strong>
|
||||
</div>
|
||||
<div v-if="signingState.mode === 'real'">
|
||||
<span>任务编号</span>
|
||||
<strong>{{ signingState.taskNo }}</strong>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<p class="secure-note">请不要重复提交。如需修改,请联系现场医务人员。</p>
|
||||
|
||||
@@ -3,7 +3,7 @@ import { ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
import SignatureCanvas from '@/components/SignatureCanvas.vue'
|
||||
import { signingState } from '@/composables/useSignFlow'
|
||||
import { signingState, submitRealSignature } from '@/composables/useSignFlow'
|
||||
import { relationOptions, type SignerRelation } from '@/types/signing'
|
||||
|
||||
interface SignatureCanvasExpose {
|
||||
@@ -15,9 +15,20 @@ const router = useRouter()
|
||||
const signerName = ref('')
|
||||
const relation = ref<SignerRelation | ''>('')
|
||||
const errorMessage = ref('')
|
||||
const signatureDataUrl = ref('')
|
||||
const submitting = ref(false)
|
||||
const signatureCanvas = ref<SignatureCanvasExpose | null>(null)
|
||||
|
||||
function submitSigning() {
|
||||
function dataUrlToBlob(dataUrl: string) {
|
||||
const [header, encoded] = dataUrl.split(',')
|
||||
const mimeType = header?.match(/data:(.*?);base64/)?.[1] ?? 'image/png'
|
||||
const binary = atob(encoded ?? '')
|
||||
const bytes = Uint8Array.from(binary, (character) => character.charCodeAt(0))
|
||||
|
||||
return new Blob([bytes], { type: mimeType })
|
||||
}
|
||||
|
||||
async function submitSigning() {
|
||||
const trimmedName = signerName.value.trim()
|
||||
if (!trimmedName) {
|
||||
errorMessage.value = '请输入实际签署人姓名。'
|
||||
@@ -32,8 +43,39 @@ function submitSigning() {
|
||||
return
|
||||
}
|
||||
|
||||
const selectedRelation = relation.value
|
||||
|
||||
if (!selectedRelation) {
|
||||
return
|
||||
}
|
||||
|
||||
if (signingState.mode === 'real') {
|
||||
if (!signatureDataUrl.value) {
|
||||
errorMessage.value = '未读取到签名图片,请重新签名。'
|
||||
return
|
||||
}
|
||||
|
||||
submitting.value = true
|
||||
errorMessage.value = ''
|
||||
|
||||
try {
|
||||
await submitRealSignature({
|
||||
file: dataUrlToBlob(signatureDataUrl.value),
|
||||
signerName: trimmedName,
|
||||
relation: selectedRelation,
|
||||
consentAccepted: signingState.consentAccepted,
|
||||
})
|
||||
await router.push('/result')
|
||||
} catch (error) {
|
||||
errorMessage.value = error instanceof Error ? error.message : '签署提交失败,请稍后重试。'
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
signingState.signerName = trimmedName
|
||||
signingState.relation = relation.value
|
||||
signingState.relation = selectedRelation
|
||||
signingState.signatureCaptured = true
|
||||
signingState.submittedAt = new Date().toLocaleString('zh-CN')
|
||||
void router.push('/result')
|
||||
@@ -69,10 +111,17 @@ function submitSigning() {
|
||||
</select>
|
||||
</section>
|
||||
|
||||
<SignatureCanvas ref="signatureCanvas" />
|
||||
<SignatureCanvas ref="signatureCanvas" @update:data-url="signatureDataUrl = $event" />
|
||||
|
||||
<p v-if="errorMessage" class="form-error">{{ errorMessage }}</p>
|
||||
<button class="mobile-primary-button" type="button" @click="submitSigning">确认提交签署</button>
|
||||
<button
|
||||
class="mobile-primary-button"
|
||||
type="button"
|
||||
:disabled="submitting"
|
||||
@click="submitSigning"
|
||||
>
|
||||
{{ submitting ? '提交中…' : '确认提交签署' }}
|
||||
</button>
|
||||
<button class="mobile-text-button" type="button" @click="router.back()">返回修改</button>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
@@ -6,6 +6,15 @@ import { defineConfig } from 'vite'
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
server: {
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'https://ipad.shenynet.com',
|
||||
changeOrigin: true,
|
||||
secure: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': fileURLToPath(new URL('./src', import.meta.url)),
|
||||
|
||||
Reference in New Issue
Block a user