feat(workbench): 接入签署投递与产物管理

This commit is contained in:
yelan
2026-09-01 10:12:22 +08:00
parent 2e2d2250dd
commit beeb98d18f
6 changed files with 434 additions and 9 deletions

View File

@@ -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(
<strong>短信将发送至</strong>
<span>{{ patient ? maskPhone(patient.phone) : '定位患者后显示' }}</span>
</div>
<p>使用患者权威记录中的手机号具体投递结果以服务端返回为准</p>
<template v-if="isSigningMockEnabled">
<p>使用患者权威记录中的手机号具体投递结果以服务端返回为准</p>
</template>
<template v-else>
<label class="sms-destination-field">
<span>真实投递手机号</span>
<input
v-model="smsDestination"
inputmode="tel"
autocomplete="tel"
placeholder="请输入完整手机号或 E.164 地址"
@input="errors.sms = ''"
/>
</label>
<p>
患者接口仅返回脱敏手机号真实联调时由当前操作人员确认投递地址服务端不会回显完整号码
</p>
<p v-if="errors.sms" class="field-error">{{ errors.sms }}</p>
</template>
</div>
</div>
</section>
@@ -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%;
}
}
</style>

View File

@@ -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<SigningMethod>('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,

View File

@@ -0,0 +1,182 @@
<script setup lang="ts">
import type { SigningArtifact } from '@/api/workbench/types'
defineProps<{
artifacts: SigningArtifact[]
loading: boolean
error: boolean
}>()
const emit = defineEmits<{
download: [artifact: SigningArtifact]
}>()
function formatFileSize(size: number) {
if (!size) {
return '大小未知'
}
if (size < 1024) {
return `${size} B`
}
if (size < 1024 * 1024) {
return `${(size / 1024).toFixed(1)} KB`
}
return `${(size / (1024 * 1024)).toFixed(1)} MB`
}
</script>
<template>
<section class="artifact-card" aria-labelledby="artifact-title">
<div class="artifact-heading">
<div>
<h3 id="artifact-title">签署文件</h3>
<p>原始文档签名原图和签署后文档均由服务端留存</p>
</div>
<span class="artifact-count">{{ artifacts.length }} 个文件</span>
</div>
<div v-if="loading" class="artifact-loading">正在加载文件索引</div>
<div v-else-if="error" class="artifact-empty">文件索引加载失败请稍后重试</div>
<div v-else-if="!artifacts.length" class="artifact-empty">当前任务暂无可下载的签署文件</div>
<ul v-else class="artifact-list">
<li v-for="artifact in artifacts" :key="artifact.id" class="artifact-row">
<div class="artifact-icon" aria-hidden="true">
{{ artifact.mimeType === 'application/pdf' ? 'PDF' : '图' }}
</div>
<div class="artifact-info">
<strong>{{ artifact.label }}</strong>
<span>{{ artifact.fileName }} · {{ formatFileSize(artifact.size) }}</span>
<small v-if="artifact.createdAt">生成于 {{ artifact.createdAt }}</small>
</div>
<button type="button" class="artifact-download" @click="emit('download', artifact)">
下载
</button>
</li>
</ul>
</section>
</template>
<style scoped>
.artifact-card {
padding: 16px 18px;
margin-top: 14px;
background: var(--card);
border: 1px solid var(--line);
border-radius: var(--r);
box-shadow: var(--sh);
}
.artifact-heading {
display: flex;
gap: 12px;
align-items: flex-start;
justify-content: space-between;
margin-bottom: 12px;
}
.artifact-heading h3 {
margin: 0;
color: var(--ink);
font-size: 14px;
}
.artifact-heading p {
margin: 4px 0 0;
color: var(--mut);
font-size: 11.5px;
}
.artifact-count {
flex-shrink: 0;
padding: 3px 8px;
color: var(--brand);
font-size: 11px;
background: var(--brand-l);
border-radius: 12px;
}
.artifact-loading,
.artifact-empty {
padding: 14px 0 2px;
color: var(--mut);
font-size: 12px;
}
.artifact-list {
display: flex;
flex-direction: column;
gap: 8px;
padding: 0;
margin: 0;
list-style: none;
}
.artifact-row {
display: flex;
gap: 10px;
align-items: center;
padding: 9px 10px;
background: #f8fbfc;
border: 1px solid #edf3f5;
border-radius: 8px;
}
.artifact-icon {
display: grid;
width: 34px;
height: 34px;
flex-shrink: 0;
color: var(--brand);
font-size: 10px;
font-weight: 800;
background: var(--brand-l);
border-radius: 7px;
place-items: center;
}
.artifact-info {
display: flex;
min-width: 0;
flex: 1;
flex-direction: column;
gap: 2px;
}
.artifact-info strong {
overflow: hidden;
color: var(--ink);
font-size: 12.5px;
text-overflow: ellipsis;
white-space: nowrap;
}
.artifact-info span,
.artifact-info small {
overflow: hidden;
color: var(--mut);
font-size: 11px;
text-overflow: ellipsis;
white-space: nowrap;
}
.artifact-info small {
color: #9aabb3;
}
.artifact-download {
flex-shrink: 0;
padding: 5px 10px;
color: var(--brand);
font-size: 12px;
background: #fff;
border: 1px solid var(--brand);
border-radius: 5px;
}
.artifact-download:hover {
background: var(--brand-l);
}
</style>

View File

@@ -1,7 +1,8 @@
<script setup lang="ts">
import type { SigningTaskEvent, SigningTaskRecord } from '@/api/workbench/types'
import type { SigningArtifact, SigningTaskEvent, SigningTaskRecord } from '@/api/workbench/types'
import SigningDocumentPreview from './SigningDocumentPreview.vue'
import SigningArtifactList from './SigningArtifactList.vue'
import TaskAuditTimeline from './TaskAuditTimeline.vue'
import type { SigningTaskAction } from '../types'
@@ -12,10 +13,16 @@ defineProps<{
auditLoading: boolean
auditError: boolean
allowLocalSigning: boolean
artifacts: SigningArtifact[]
artifactsLoading: boolean
artifactsError: boolean
padSessionStatus: string | null
padSessionLoading: boolean
}>()
const emit = defineEmits<{
action: [action: SigningTaskAction]
downloadArtifact: [artifact: SigningArtifact]
}>()
</script>
@@ -43,7 +50,20 @@ const emit = defineEmits<{
转为短信发送
</button>
</template>
<span v-else class="action-hint">手写板设备签署接口待接入</span>
<template v-else>
<button
type="button"
class="action-button"
:disabled="padSessionLoading"
@click="emit('action', 'create-pad-session')"
>
{{ padSessionLoading ? '创建会话中…' : '创建手写板会话' }}
</button>
<span v-if="padSessionStatus" class="action-hint">
会话状态{{ padSessionStatus }}等待设备适配器读取 challenge
</span>
<span v-else class="action-hint">创建会话后仍需接入厂商设备适配器完成签名采集</span>
</template>
</template>
<template v-else-if="task.status === 'pending' && task.method === 'sms'">
@@ -120,6 +140,12 @@ const emit = defineEmits<{
<SigningDocumentPreview :task="task" />
<TaskAuditTimeline :events="auditEvents" :loading="auditLoading" :error="auditError" />
<SigningArtifactList
:artifacts="artifacts"
:loading="artifactsLoading"
:error="artifactsError"
@download="emit('downloadArtifact', $event)"
/>
</template>
<div v-else class="detail-empty">

View File

@@ -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<string | null>(null)
const selectedTask = ref<SigningTaskRecord | null>(null)
const taskEvents = ref<SigningTaskEvent[]>([])
const artifacts = ref<SigningArtifact[]>([])
const templates = ref<SigningTemplate[]>([])
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<string | null>(null)
const padSessionLoading = ref(false)
const campusIds = ref<Record<string, string>>({})
const campusNames = ref<Record<string, WorkbenchCampus>>({})
const departmentIds = ref<Record<string, string>>({})
const newDialogVisible = ref(false)
const signatureDialogVisible = ref(false)
const onlineDialogVisible = ref(false)
@@ -131,6 +144,9 @@ const documentOptions = computed<SigningFilterOption[]>(() => [
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"
/>
</div>

View File

@@ -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