diff --git a/clinical-web/src/components/signing/NewSigningTaskDialog.vue b/clinical-web/src/components/signing/NewSigningTaskDialog.vue index 55bff1b..6964e14 100644 --- a/clinical-web/src/components/signing/NewSigningTaskDialog.vue +++ b/clinical-web/src/components/signing/NewSigningTaskDialog.vue @@ -2,6 +2,7 @@ import { computed, nextTick, ref, watch } from 'vue' import { ElMessage } from 'element-plus' +import { isSigningMockEnabled } from '@/api/workbench/signing' import type { SigningTaskRecord, SigningTemplate } from '@/api/workbench/types' import { useSigningTaskForm } from '@/composables/useSigningTaskForm' import { useAppStore } from '@/stores/app' @@ -41,6 +42,7 @@ const { selectedTemplateId, selectedVisit, selectedVisitId, + smsDestination, submit: submitForm, submitting, visits, @@ -58,7 +60,9 @@ async function submitTask(keepOpen: boolean) { } emit('created', task) - ElMessage.success(`已创建 1 个签署任务${task.method === 'sms' ? ',已生成短信投递意图' : ''}`) + const smsMessage = + task.method === 'sms' ? (isSigningMockEnabled ? ',已生成短信投递意图' : ',短信已发送') : '' + ElMessage.success(`已创建 1 个签署任务${smsMessage}`) if (keepOpen) { resetForNextTask() @@ -285,7 +289,25 @@ watch( 短信将发送至 {{ patient ? maskPhone(patient.phone) : '定位患者后显示' }} -

使用患者权威记录中的手机号,具体投递结果以服务端返回为准。

+ + @@ -826,6 +848,42 @@ watch( font-size: 11.5px; } +.sms-destination-field { + display: flex; + gap: 8px; + align-items: center; + padding-left: 28px; + margin-top: 9px; +} + +.sms-destination-field span { + flex-shrink: 0; + color: var(--brand-d); + font-size: 12px; + font-weight: 700; +} + +.sms-destination-field input { + min-width: 0; + flex: 1; + padding: 6px 9px; + color: var(--ink); + font: inherit; + font-size: 12px; + background: #fff; + border: 1px solid var(--line); + border-radius: 6px; +} + +.sms-destination-field input:focus { + outline: none; + border-color: var(--brand); +} + +.sms-confirm .field-error { + padding-left: 28px; +} + .dialog-footer { display: flex; gap: 10px; @@ -894,5 +952,14 @@ watch( .dialog-footer > span:first-child { width: 100%; } + + .sms-destination-field { + align-items: flex-start; + flex-direction: column; + } + + .sms-destination-field input { + width: 100%; + } } diff --git a/clinical-web/src/composables/useSigningTaskForm.ts b/clinical-web/src/composables/useSigningTaskForm.ts index 5047090..49cffc9 100644 --- a/clinical-web/src/composables/useSigningTaskForm.ts +++ b/clinical-web/src/composables/useSigningTaskForm.ts @@ -1,7 +1,13 @@ import { computed, reactive, ref, toValue, type MaybeRefOrGetter } from 'vue' import { ElMessage } from 'element-plus' -import { createSigningTask, getPatientProfile, getSigningTemplates } from '@/api/workbench/signing' +import { sendSigningSms } from '@/api/workbench/deliveries' +import { + createSigningTask, + getPatientProfile, + getSigningTemplates, + isSigningMockEnabled, +} from '@/api/workbench/signing' import type { PatientProfile, PatientVisit, @@ -35,6 +41,7 @@ export function useSigningTaskForm(options: UseSigningTaskFormOptions) { const selectedTemplateId = ref('') const selectedVisitId = ref('') const method = ref('pad') + const smsDestination = ref('') const loadingTemplates = ref(false) const locating = ref(false) const submitting = ref(false) @@ -43,6 +50,7 @@ export function useSigningTaskForm(options: UseSigningTaskFormOptions) { patient: '', visit: '', document: '', + sms: '', }) const selectedVisit = computed( @@ -104,6 +112,7 @@ export function useSigningTaskForm(options: UseSigningTaskFormOptions) { errors.patient = '' errors.visit = '' errors.document = '' + errors.sms = '' } function resetForm() { @@ -116,6 +125,7 @@ export function useSigningTaskForm(options: UseSigningTaskFormOptions) { selectedTemplateId.value = initialTemplate?.id ?? '' selectedVisitId.value = '' method.value = initialTemplate?.supportedMethods[0] ?? 'pad' + smsDestination.value = '' clearErrors() } @@ -125,7 +135,9 @@ export function useSigningTaskForm(options: UseSigningTaskFormOptions) { documentSearch.value = '' selectedTemplateId.value = initialTemplate?.id ?? '' method.value = initialTemplate?.supportedMethods[0] ?? method.value + smsDestination.value = '' errors.document = '' + errors.sms = '' } async function loadTemplates() { @@ -228,6 +240,15 @@ export function useSigningTaskForm(options: UseSigningTaskFormOptions) { valid = false } + if (!isSigningMockEnabled && method.value === 'sms') { + const destination = smsDestination.value.trim().replace(/\s+/g, '') + + if (!/^\+?[1-9]\d{6,14}$/.test(destination)) { + errors.sms = '请输入完整的手机号或 E.164 地址,才能发送短信' + valid = false + } + } + return valid } @@ -239,7 +260,7 @@ export function useSigningTaskForm(options: UseSigningTaskFormOptions) { submitting.value = true try { - return await createSigningTask({ + const task = await createSigningTask({ campus: toValue(options.campus), patientId: patient.value.id, visitId: selectedVisit.value.id, @@ -251,6 +272,15 @@ export function useSigningTaskForm(options: UseSigningTaskFormOptions) { visitNo: selectedVisit.value.visitNo, department: selectedVisit.value.department, }) + + if (!isSigningMockEnabled && method.value === 'sms') { + await sendSigningSms(task.id, { + destination: smsDestination.value.trim().replace(/\s+/g, ''), + templateCode: 'SIGN_LINK', + }) + } + + return task } finally { submitting.value = false } @@ -277,6 +307,7 @@ export function useSigningTaskForm(options: UseSigningTaskFormOptions) { selectedTemplateId, selectedVisit, selectedVisitId, + smsDestination, submit, submitting, templates, diff --git a/clinical-web/src/views/workbench/signing/components/SigningArtifactList.vue b/clinical-web/src/views/workbench/signing/components/SigningArtifactList.vue new file mode 100644 index 0000000..2b7db98 --- /dev/null +++ b/clinical-web/src/views/workbench/signing/components/SigningArtifactList.vue @@ -0,0 +1,182 @@ + + + + + diff --git a/clinical-web/src/views/workbench/signing/components/SigningTaskDetail.vue b/clinical-web/src/views/workbench/signing/components/SigningTaskDetail.vue index 100c8a8..24bbcd4 100644 --- a/clinical-web/src/views/workbench/signing/components/SigningTaskDetail.vue +++ b/clinical-web/src/views/workbench/signing/components/SigningTaskDetail.vue @@ -1,7 +1,8 @@ @@ -43,7 +50,20 @@ const emit = defineEmits<{ 转为短信发送 - 手写板设备签署接口待接入 +
diff --git a/clinical-web/src/views/workbench/signing/index.vue b/clinical-web/src/views/workbench/signing/index.vue index eaa5fed..0806baa 100644 --- a/clinical-web/src/views/workbench/signing/index.vue +++ b/clinical-web/src/views/workbench/signing/index.vue @@ -3,6 +3,9 @@ import { computed, onMounted, reactive, ref, watch } from 'vue' import { ElMessage } from 'element-plus' import { useRoute } from 'vue-router' +import { getCampuses, getDepartments } from '@/api/management/organization' +import { downloadSigningArtifact, getSigningArtifacts } from '@/api/workbench/artifacts' +import { createPadSigningSession } from '@/api/workbench/deliveries' import { completeSigningTask, getSigningTaskDetail, @@ -18,6 +21,8 @@ import { import type { SigningDateRange, SigningMethod, + SigningArtifact, + SignDeliveryResponseDto, SigningTaskEvent, SigningTaskRecord, SigningTaskStatus, @@ -76,11 +81,19 @@ const total = ref(0) const selectedTaskId = ref(null) const selectedTask = ref(null) const taskEvents = ref([]) +const artifacts = ref([]) const templates = ref([]) const loading = ref(true) const detailLoading = ref(false) const eventsLoading = ref(false) const eventsError = ref(false) +const artifactsLoading = ref(false) +const artifactsError = ref(false) +const padSessionStatus = ref(null) +const padSessionLoading = ref(false) +const campusIds = ref>({}) +const campusNames = ref>({}) +const departmentIds = ref>({}) const newDialogVisible = ref(false) const signatureDialogVisible = ref(false) const onlineDialogVisible = ref(false) @@ -131,6 +144,9 @@ const documentOptions = computed(() => [ const signingApiOptions = computed(() => ({ campus: appStore.selectedCampus, + campusId: campusIds.value[appStore.selectedCampus], + campusNames: campusNames.value, + departmentIds: departmentIds.value, templates: templates.value, })) @@ -162,27 +178,67 @@ async function loadTemplates() { } } +async function loadOrganizationOptions() { + if (isSigningMockEnabled) { + return + } + + const [campusesResult, departmentsResult] = await Promise.allSettled([ + getCampuses(), + getDepartments(), + ]) + + if (campusesResult.status === 'fulfilled') { + campusIds.value = Object.fromEntries( + campusesResult.value.map((campus) => [campus.name, campus.id]), + ) + campusNames.value = Object.fromEntries( + campusesResult.value + .filter( + (campus): campus is typeof campus & { name: WorkbenchCampus } => + campus.name === '本部院区' || campus.name === '东院区' || campus.name === '西院区', + ) + .map((campus) => [campus.id, campus.name]), + ) + } + + if (departmentsResult.status === 'fulfilled') { + departmentIds.value = Object.fromEntries( + departmentsResult.value.map((department) => [department.name, department.id]), + ) + } +} + async function loadTaskDetail(id: string | null) { const requestId = ++detailRequestId if (!id) { selectedTask.value = null taskEvents.value = [] + artifacts.value = [] detailLoading.value = false eventsLoading.value = false eventsError.value = false + artifactsLoading.value = false + artifactsError.value = false + padSessionStatus.value = null return } detailLoading.value = true eventsLoading.value = true eventsError.value = false + artifactsLoading.value = true + artifactsError.value = false taskEvents.value = [] + artifacts.value = [] + padSessionStatus.value = null try { - const [detailResult, eventsResult] = await Promise.allSettled([ + const [detailResult, eventsResult, artifactsResult] = await Promise.allSettled([ getSigningTaskDetail(id, signingApiOptions.value), getSigningTaskEvents(id), + getSigningArtifacts(id), ]) if (requestId === detailRequestId) { @@ -197,6 +253,12 @@ async function loadTaskDetail(id: string | null) { } else { eventsError.value = true } + + if (artifactsResult.status === 'fulfilled') { + artifacts.value = artifactsResult.value + } else { + artifactsError.value = true + } } } catch { if (requestId === detailRequestId) { @@ -208,6 +270,7 @@ async function loadTaskDetail(id: string | null) { if (requestId === detailRequestId) { detailLoading.value = false eventsLoading.value = false + artifactsLoading.value = false } } } @@ -295,6 +358,22 @@ async function handleTaskAction(action: SigningTaskAction) { return } + if (action === 'create-pad-session') { + padSessionLoading.value = true + + try { + const session: SignDeliveryResponseDto = await createPadSigningSession(task.id) + padSessionStatus.value = session.status + ElMessage.success('手写板签署会话已创建,请由设备适配器继续采集签名') + } catch { + ElMessage.error('手写板签署会话创建失败,请稍后重试') + } finally { + padSessionLoading.value = false + } + + return + } + if (action === 'online-sign') { if (!isSigningMockEnabled) { ElMessage.info('线上签署页面接口待接入') @@ -311,12 +390,26 @@ async function handleTaskAction(action: SigningTaskAction) { } if (action === 'download-pdf') { - ElMessage.info('原型演示:PDF 原件下载接口待接入') + const artifact = + artifacts.value.find((item) => item.artifactType === 'SIGNED_PDF') ?? + artifacts.value.find((item) => item.artifactType === 'ORIGINAL_PDF') + + if (artifact) { + await downloadArtifact(artifact) + } else { + ElMessage.info('当前任务暂无 PDF 文件') + } return } if (action === 'download-signature') { - ElMessage.info('原型演示:签名原图下载接口待接入') + const artifact = artifacts.value.find((item) => item.artifactType === 'SIGNATURE_IMAGE') + + if (artifact) { + await downloadArtifact(artifact) + } else { + ElMessage.info('当前任务暂无签名原图') + } return } @@ -365,6 +458,21 @@ async function handleTaskAction(action: SigningTaskAction) { } } +async function downloadArtifact(artifact: SigningArtifact) { + try { + const blob = await downloadSigningArtifact(artifact.id) + const url = URL.createObjectURL(blob) + const link = document.createElement('a') + link.href = url + link.download = artifact.fileName + link.click() + window.setTimeout(() => URL.revokeObjectURL(url), 0) + ElMessage.success(`${artifact.label}下载已开始`) + } catch { + ElMessage.error(`${artifact.label}下载失败,请稍后重试`) + } +} + async function confirmPadSignature(signatureDataUrl: string) { const task = selectedTask.value @@ -421,6 +529,7 @@ async function handleOnlineResend() { } async function initializePage() { + await loadOrganizationOptions() await loadTemplates() await loadTasks() } @@ -494,7 +603,13 @@ onMounted(() => { :audit-loading="eventsLoading" :audit-error="eventsError" :allow-local-signing="isSigningMockEnabled" + :artifacts="artifacts" + :artifacts-loading="artifactsLoading" + :artifacts-error="artifactsError" + :pad-session-status="padSessionStatus" + :pad-session-loading="padSessionLoading" @action="handleTaskAction" + @download-artifact="downloadArtifact" />
diff --git a/clinical-web/src/views/workbench/signing/types.ts b/clinical-web/src/views/workbench/signing/types.ts index 3b10aa1..cf627b6 100644 --- a/clinical-web/src/views/workbench/signing/types.ts +++ b/clinical-web/src/views/workbench/signing/types.ts @@ -1,6 +1,7 @@ import type { PatientProfile, PatientVisit, + SignDeliveryStatus, SigningDateRange, SigningMethod, SigningTaskRecord, @@ -40,6 +41,7 @@ export interface NewSigningTaskState { export type SigningTaskAction = | 'pad-sign' + | 'create-pad-session' | 'online-sign' | 'resend-sms' | 'switch-method' @@ -48,3 +50,5 @@ export type SigningTaskAction = | 'download-pdf' | 'download-signature' | 'print' + +export type SigningPadSessionStatus = SignDeliveryStatus | null