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) : '定位患者后显示' }}
-
使用患者权威记录中的手机号,具体投递结果以服务端返回为准。
+
+ 使用患者权威记录中的手机号,具体投递结果以服务端返回为准。
+
+
+
+
+ 患者接口仅返回脱敏手机号,真实联调时由当前操作人员确认投递地址;服务端不会回显完整号码。
+
+ {{ errors.sms }}
+
@@ -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 @@
+
+
+
+
+
+
+
签署文件
+
原始文档、签名原图和签署后文档均由服务端留存。
+
+
{{ artifacts.length }} 个文件
+
+
+ 正在加载文件索引…
+ 文件索引加载失败,请稍后重试。
+ 当前任务暂无可下载的签署文件。
+
+
+
+
+
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<{
转为短信发送
- 手写板设备签署接口待接入
+
+
+
+ 会话状态:{{ padSessionStatus }};等待设备适配器读取 challenge
+
+ 创建会话后仍需接入厂商设备适配器完成签名采集
+
@@ -120,6 +140,12 @@ 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