feat(patient-h5): 接入签署令牌与签名上传

This commit is contained in:
yelan
2026-09-01 10:13:45 +08:00
parent e467f03f23
commit 40cd313786
11 changed files with 614 additions and 42 deletions
+194 -29
View File
@@ -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>
+38 -2
View File
@@ -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>
+54 -5
View File
@@ -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>