From 40b0a5d04020c552c3a61ab7f3bbc226ec76a18a Mon Sep 17 00:00:00 2001 From: yelan Date: Mon, 31 Aug 2026 16:08:17 +0800 Subject: [PATCH] =?UTF-8?q?feat(signing):=20=E6=8E=A5=E5=85=A5=E6=82=A3?= =?UTF-8?q?=E8=80=85=E4=B8=8E=E4=BB=BB=E5=8A=A1=E7=9C=9F=E5=AE=9E=E6=8E=A5?= =?UTF-8?q?=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- clinical-web/src/api/workbench/signing.ts | 626 +++++++++++++++++- clinical-web/src/api/workbench/types.ts | 211 +++++- .../signing/NewSigningTaskDialog.vue | 33 +- .../src/composables/useSigningTaskForm.ts | 24 +- .../src/views/management/documents/index.vue | 2 + 5 files changed, 805 insertions(+), 91 deletions(-) diff --git a/clinical-web/src/api/workbench/signing.ts b/clinical-web/src/api/workbench/signing.ts index 05dec99..0e53075 100644 --- a/clinical-web/src/api/workbench/signing.ts +++ b/clinical-web/src/api/workbench/signing.ts @@ -1,14 +1,32 @@ +import axios from 'axios' + import { request } from '@/utils/request' import type { + ApiPageResponse, + ApiResponseOf, + AvailableTemplateVersionResponseDto, + BackendSigningMethod, + BackendSigningTaskStatus, + CreateSigningTaskInput, CreateSigningTaskRequest, PatientProfile, + PatientResponseDto, + PatientSex, + SignTaskActionRequest, + SignTaskEventResponseDto, + SignTaskResponseDto, + SignTaskSendPreparationResponseDto, + SigningApiOptions, SigningMethod, + SigningTaskEvent, + SigningTaskActionOptions, SigningTaskListResponse, SigningTaskQuery, SigningTaskRecord, SigningTaskStatus, SigningTemplate, + VisitResponseDto, VisitType, } from './types' @@ -117,8 +135,10 @@ const mockPatients: PatientProfile[] = [ const mockTemplates: SigningTemplate[] = [ { id: 'tpl-001', + versionId: 'tpl-001-v2', name: '住院患者知情同意书', code: 'DOC-HOS-001', + version: 'V2.0', department: '全院通用', category: '住院告知', description: '住院期间诊疗事项、风险与患者权利告知。', @@ -126,8 +146,10 @@ const mockTemplates: SigningTemplate[] = [ }, { id: 'tpl-002', + versionId: 'tpl-002-v1', name: '住院须知及告知书', code: 'DOC-HOS-002', + version: 'V1.0', department: '全院通用', category: '住院告知', description: '住院流程、陪护要求和患者须知确认。', @@ -135,8 +157,10 @@ const mockTemplates: SigningTemplate[] = [ }, { id: 'tpl-003', + versionId: 'tpl-003-v3', name: '内镜检查知情同意书', code: 'DOC-DIG-001', + version: 'V3.0', department: '消化内科', category: '检查告知', description: '内镜检查适应证、风险及替代方案告知。', @@ -144,8 +168,10 @@ const mockTemplates: SigningTemplate[] = [ }, { id: 'tpl-004', + versionId: 'tpl-004-v1', name: 'MRI 磁共振检查知情同意书', code: 'DOC-RAD-001', + version: 'V1.0', department: '放射科', category: '检查告知', description: '磁共振检查注意事项、禁忌证及风险告知。', @@ -153,8 +179,10 @@ const mockTemplates: SigningTemplate[] = [ }, { id: 'tpl-005', + versionId: 'tpl-005-v2', name: '心脏介入手术知情同意书', code: 'DOC-CARD-001', + version: 'V2.0', department: '心内科', category: '手术告知', description: '介入治疗方案、围术期风险及替代方案告知。', @@ -162,8 +190,10 @@ const mockTemplates: SigningTemplate[] = [ }, { id: 'tpl-006', + versionId: 'tpl-006-v1', name: '体检报告发放及知情同意书', code: 'DOC-PE-001', + version: 'V1.0', department: '健康管理中心', category: '体检告知', description: '体检报告领取方式、隐私保护和异常结果随访告知。', @@ -171,8 +201,10 @@ const mockTemplates: SigningTemplate[] = [ }, { id: 'tpl-007', + versionId: 'tpl-007-v1', name: '患者授权委托书', code: 'DOC-GEN-003', + version: 'V1.0', department: '医务处', category: '授权文书', description: '患者授权家属或代理人办理相关医疗事项。', @@ -183,6 +215,7 @@ const mockTemplates: SigningTemplate[] = [ const mockRecords: SigningTaskRecord[] = [ { id: 'task-001', + templateVersionId: 'tpl-001-v2', campus: '本部院区', patientId: 'P202610001', patientName: '李某某', @@ -203,6 +236,7 @@ const mockRecords: SigningTaskRecord[] = [ }, { id: 'task-002', + templateVersionId: 'tpl-004-v1', campus: '东院区', patientId: 'P202610002', patientName: '王某某', @@ -224,6 +258,7 @@ const mockRecords: SigningTaskRecord[] = [ }, { id: 'task-003', + templateVersionId: 'tpl-003-v3', campus: '本部院区', patientId: 'P202610003', patientName: '赵某某', @@ -244,6 +279,7 @@ const mockRecords: SigningTaskRecord[] = [ }, { id: 'task-004', + templateVersionId: 'tpl-002-v1', campus: '本部院区', patientId: 'P202610005', patientName: '陈志强', @@ -265,6 +301,7 @@ const mockRecords: SigningTaskRecord[] = [ }, { id: 'task-005', + templateVersionId: 'tpl-006-v1', campus: '东院区', patientId: 'P202610004', patientName: '周雅琴', @@ -329,11 +366,325 @@ function addHours(value: string, hours: number) { return formatMockDate(date) } -export function getSigningTasks(query: SigningTaskQuery): Promise { +export const isSigningMockEnabled = useMockData + +function isSuccessCode(code: number | string) { + return code === 0 || code === '0' +} + +function unwrapApiResponse(response: ApiResponseOf): T { + if (!isSuccessCode(response.code)) { + throw new Error(response.message || '接口请求失败') + } + + if (response.data === null) { + throw new Error(response.message || '接口未返回业务数据') + } + + return response.data +} + +function isNotFoundError(error: unknown) { + return axios.isAxiosError(error) && error.response?.status === 404 +} + +function formatApiDateTime(value: string | null | undefined) { + if (!value) { + return '' + } + + const date = new Date(value) + + if (Number.isNaN(date.getTime())) { + return value + .replace('T', ' ') + .replace(/\.\d+Z$/, '') + .replace(/Z$/, '') + } + + const pad = (part: number) => String(part).padStart(2, '0') + + return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad( + date.getHours(), + )}:${pad(date.getMinutes())}` +} + +function formatApiDate(value: string | null | undefined) { + return formatApiDateTime(value).slice(0, 10) +} + +function normalizePatientSex(value: string): PatientSex { + if (value === 'MALE' || value === '男') { + return '男' + } + + if (value === 'FEMALE' || value === '女') { + return '女' + } + + return '未知' +} + +function normalizeVisitType(value: string): VisitType { + if (value === 'OUTPATIENT' || value === '门诊') { + return '门诊' + } + + if (value === 'INPATIENT' || value === '住院') { + return '住院' + } + + if (value === 'CHECKUP' || value === '体检') { + return '体检' + } + + return '其他' +} + +function normalizeSigningMethod(value: BackendSigningMethod): SigningMethod { + return value === 'SMS' ? 'sms' : 'pad' +} + +function normalizeTaskStatus(value: BackendSigningTaskStatus): SigningTaskStatus { + switch (value) { + case 'SIGNED': + return 'signed' + case 'EXPIRED': + return 'expired' + case 'VOIDED': + return 'void' + case 'GENERATING': + return 'signing' + case 'FAILED': + return 'failed' + case 'CREATED': + case 'WAITING_SIGN': + default: + return 'pending' + } +} + +function mapPatient(dto: PatientResponseDto, visits: PatientProfile['visits']): PatientProfile { + return { + id: dto.id, + name: dto.name, + sex: normalizePatientSex(dto.sex), + age: dto.age, + idCard: dto.idCardMasked ?? '', + phone: dto.phoneMasked ?? '', + visits, + } +} + +function mapVisit(dto: VisitResponseDto): PatientProfile['visits'][number] { + return { + id: dto.id, + type: normalizeVisitType(dto.visitType), + visitNo: dto.visitNo, + department: dto.departmentName || '未指定科室', + departmentId: dto.departmentId, + doctorName: dto.doctorName ?? undefined, + visitDate: formatApiDate(dto.visitedAt), + visitedAt: dto.visitedAt, + campusId: dto.campusId, + sourceSystem: dto.sourceSystem, + } +} + +function mapAvailableTemplate(dto: AvailableTemplateVersionResponseDto): SigningTemplate { + return { + id: dto.templateId, + versionId: dto.templateVersionId, + name: dto.templateName, + code: dto.templateCode, + version: dto.versionNo, + department: dto.departmentId ? '指定科室' : '全院通用', + departmentId: dto.departmentId, + campusId: dto.campusId, + category: dto.category || '未分类', + description: `已发布版本 ${dto.versionNo}`, + // 可用模板接口没有返回通道 ACL;具体通道仍由后端在创建时校验。 + supportedMethods: ['pad', 'sms'], + } +} + +function findTemplate(templates: SigningTemplate[] | undefined, versionId: string) { + return templates?.find((template) => template.versionId === versionId) +} + +function mapSigningTask( + dto: SignTaskResponseDto, + options: SigningApiOptions = {}, +): SigningTaskRecord { + const template = findTemplate(options.templates, dto.templateVersionId) + + return { + id: dto.id, + templateVersionId: dto.templateVersionId, + campus: options.campus ?? '本部院区', + patientId: dto.patientSnapshot.patientId, + patientName: dto.patientSnapshot.name, + sex: normalizePatientSex(dto.patientSnapshot.sex), + age: dto.patientSnapshot.age, + visitNo: dto.visitSnapshot.visitNo, + visitType: normalizeVisitType(dto.visitSnapshot.visitType), + visitDate: formatApiDate(dto.visitSnapshot.visitedAt), + documentId: template?.id ?? dto.templateVersionId, + documentName: template?.name ?? `模板版本 ${dto.templateVersionNumber}`, + department: dto.visitSnapshot.departmentName || '未指定科室', + signerName: dto.patientSnapshot.name, + method: normalizeSigningMethod(dto.signMethod), + status: normalizeTaskStatus(dto.status), + source: dto.visitSnapshot.sourceSystem || dto.patientSnapshot.sourceSystem || '—', + expiresAt: formatApiDateTime(dto.expiredAt), + updatedAt: formatApiDateTime(dto.updatedAt), + rowVersion: dto.rowVersion, + backendStatus: dto.status, + signedAt: formatApiDateTime(dto.signedAt) || undefined, + voidReason: dto.voidReason ?? undefined, + } +} + +function createTemplateFromInput(input: CreateSigningTaskInput): SigningTemplate { + return { + id: input.documentId, + versionId: input.templateVersionId, + name: input.documentName, + code: input.documentId, + version: '—', + department: input.department, + category: '—', + description: '', + supportedMethods: ['pad', 'sms'], + } +} + +function toBackendTaskQuery(query: SigningTaskQuery, templates?: SigningTemplate[]) { + const params: Record = { + page: Math.max(query.page, 1), + size: Math.min(Math.max(query.pageSize, 1), 200), + } + + if (query.keyword?.trim()) { + params.keyword = query.keyword.trim() + } + + if (query.status && query.status !== 'all') { + const statusMap: Partial> = { + pending: 'WAITING_SIGN', + signing: 'GENERATING', + signed: 'SIGNED', + rejected: 'FAILED', + expired: 'EXPIRED', + void: 'VOIDED', + failed: 'FAILED', + } + const status = statusMap[query.status] + + if (status) { + params.status = status + } + } + + if (query.method && query.method !== 'all') { + params.signMethod = query.method === 'sms' ? 'SMS' : 'PAD' + } + + if (query.documentId) { + params.templateVersionId = + findTemplate(templates, query.documentId)?.versionId ?? query.documentId + } + + const now = new Date() + const start = new Date(now) + const end = new Date(now) + start.setHours(0, 0, 0, 0) + end.setHours(23, 59, 59, 999) + + if (query.dateRange === 'yesterday') { + start.setDate(start.getDate() - 1) + end.setDate(end.getDate() - 1) + } else if (query.dateRange === '3d') { + start.setDate(start.getDate() - 2) + } else if (query.dateRange === '7d') { + start.setDate(start.getDate() - 6) + } + + if (query.dateRange && query.dateRange !== 'all') { + params.createdFrom = start.toISOString() + params.createdTo = end.toISOString() + } + + // 院区、科室和就诊类型在页面中使用展示名称,不能直接当作 UUID 发送。 + // 后端会按当前登录用户的数据范围默认过滤,待院区/科室字典接入后再补充 ID 映射。 + return params +} + +function createIdempotencyKey() { + if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') { + return `clinical-web-${crypto.randomUUID()}` + } + + return `clinical-web-${Date.now()}-${Math.random().toString(36).slice(2)}` +} + +function getTaskActionBody(expectedRowVersion?: number): SignTaskActionRequest { + return expectedRowVersion === undefined ? {} : { expectedRowVersion } +} + +function mapEventText(event: SignTaskEventResponseDto) { + const actor = event.actorSubject || '系统' + const channel = event.channel === 'SMS' ? '短信' : event.channel === 'PAD' ? '手写板' : '' + + switch (event.eventType) { + case 'TASK_CREATED': + return `${actor}创建签署任务${channel ? `(${channel})` : ''}` + case 'SEND_PREPARED': + return `${actor}准备${channel || '签署'}发送` + case 'VOIDED': + return `任务已作废${event.reason ? `,原因:${event.reason}` : ''}` + case 'RESENT': + case 'RESEND_PREPARED': + return `${actor}重新发送${channel || '签署'}请求` + case 'REOPENED': + return `${actor}重新开启签署任务` + case 'SIGNED': + return `${actor}完成签名` + default: + return `${actor}:${event.eventType}` + } +} + +function mapSigningEvent(event: SignTaskEventResponseDto): SigningTaskEvent { + return { + id: event.id, + time: formatApiDateTime(event.eventTime), + text: mapEventText(event), + eventType: event.eventType, + actorSubject: event.actorSubject ?? undefined, + reason: event.reason ?? undefined, + channel: event.channel ?? undefined, + attemptNo: event.attemptNo ?? undefined, + } +} + +export async function getSigningTasks( + query: SigningTaskQuery, + options: SigningApiOptions = {}, +): Promise { if (!useMockData) { - return request.get('/workbench/signing/tasks', { - params: query, - }) + const response = await request.get>>( + '/v1/sign-tasks', + { params: toBackendTaskQuery(query, options.templates) }, + ) + const page = unwrapApiResponse(response) + + return { + records: page.records.map((task) => mapSigningTask(task, options)), + total: page.total, + page: page.page, + pageSize: page.size, + } } const keyword = query.keyword?.trim().toLowerCase() @@ -386,9 +737,23 @@ export function getSigningTasks(query: SigningTaskQuery): Promise { +export async function getSigningTaskDetail( + id: string, + options: SigningApiOptions = {}, +): Promise { if (!useMockData) { - return request.get('/workbench/signing/tasks/' + id) + try { + const response = await request.get>( + `/v1/sign-tasks/${encodeURIComponent(id)}`, + ) + return mapSigningTask(unwrapApiResponse(response), options) + } catch (error) { + if (isNotFoundError(error)) { + return null + } + + throw error + } } return Promise.resolve(mockRecords.find((record) => record.id === id) ?? null).then((task) => @@ -396,9 +761,79 @@ export function getSigningTaskDetail(id: string): Promise { +async function getPatientVisits(patientId: string): Promise { + const response = await request.get>>( + `/v1/patients/${encodeURIComponent(patientId)}/visits`, + { params: { page: 1, size: 200 } }, + ) + const page = unwrapApiResponse(response) + + return page.records.map(mapVisit) +} + +async function getPatientById(id: string): Promise { + try { + const [patientResponse, visits] = await Promise.all([ + request.get>(`/v1/patients/${encodeURIComponent(id)}`), + getPatientVisits(id), + ]) + + return mapPatient(unwrapApiResponse(patientResponse), visits) + } catch (error) { + if (isNotFoundError(error)) { + return null + } + + throw error + } +} + +async function getPatientByVisitNo(visitNo: string): Promise { + const response = await request.get>>( + '/v1/visits', + { + params: { page: 1, size: 20, visitNo }, + }, + ) + const page = unwrapApiResponse(response) + const visit = page.records[0] + + return visit ? getPatientById(visit.patientId) : null +} + +function isUuid(value: string) { + return /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(value) +} + +export async function getPatientProfile(keyword: string): Promise { if (!useMockData) { - return request.get('/patients/' + encodeURIComponent(keyword)) + const normalizedKeyword = keyword.trim() + + if (isUuid(normalizedKeyword)) { + return getPatientById(normalizedKeyword) + } + + const patientResponse = await request.get>>( + '/v1/patients', + { + params: { page: 1, size: 20, keyword: normalizedKeyword }, + }, + ) + const patients = unwrapApiResponse(patientResponse).records + const matchedPatient = patients.find( + (patient) => + patient.externalPatientId === normalizedKeyword || patient.name === normalizedKeyword, + ) + + if (matchedPatient) { + return getPatientById(matchedPatient.id) + } + + if (patients.length) { + return getPatientById(patients[0].id) + } + + return getPatientByVisitNo(normalizedKeyword) } const normalizedKeyword = keyword.trim() @@ -411,40 +846,91 @@ export function getPatientProfile(keyword: string): Promise { +export async function getSigningTemplates( + query: { + campusId?: string + departmentId?: string + } = {}, +): Promise { if (!useMockData) { - return request.get('/workbench/signing/templates') + const response = await request.get>( + '/v1/templates/available-versions', + { params: query }, + ) + + return unwrapApiResponse(response).map(mapAvailableTemplate) } return Promise.resolve(mockTemplates.map((template) => ({ ...template }))) } -export function createSigningTask(payload: CreateSigningTaskRequest): Promise { +export async function prepareSigningTask( + id: string, + expectedRowVersion?: number, +): Promise { + const response = await request.post>( + `/v1/sign-tasks/${encodeURIComponent(id)}/prepare-send`, + getTaskActionBody(expectedRowVersion), + ) + + return unwrapApiResponse(response) +} + +export async function createSigningTask(input: CreateSigningTaskInput): Promise { if (!useMockData) { - return request.post('/workbench/signing/tasks', payload) + const payload: CreateSigningTaskRequest = { + patientId: input.patientId, + visitId: input.visitId, + templateVersionId: input.templateVersionId, + signMethod: input.method === 'sms' ? 'SMS' : 'PAD', + idempotencyKey: createIdempotencyKey(), + } + const response = await request.post>( + '/v1/sign-tasks', + payload, + ) + const createdTask = unwrapApiResponse(response) + const options: SigningApiOptions = { + campus: input.campus, + templates: [createTemplateFromInput(input)], + } + + if (createdTask.status === 'CREATED') { + await prepareSigningTask(createdTask.id, createdTask.rowVersion) + const preparedTask = await getSigningTaskDetail(createdTask.id, options) + + if (preparedTask) { + return preparedTask + } + } + + return mapSigningTask(createdTask, options) } - const patient = mockPatients.find((item) => item.id === payload.patientId) + const patient = mockPatients.find((item) => item.id === input.patientId) const task: SigningTaskRecord = { id: `task-${Date.now()}`, - campus: payload.campus, - patientId: payload.patientId, + templateVersionId: input.templateVersionId, + campus: input.campus, + patientId: input.patientId, patientName: patient?.name ?? '演示患者', sex: patient?.sex ?? '男', age: patient?.age ?? 40, - visitNo: payload.visitNo, - visitType: payload.visitType, + visitNo: input.visitNo, + visitType: input.visitType, visitDate: - patient?.visits.find((visit) => visit.id === payload.visitId)?.visitDate ?? '2026-08-27', - documentId: payload.documentId, - documentName: payload.documentName, - department: payload.department, + patient?.visits.find((visit) => visit.id === input.visitId)?.visitDate ?? '2026-08-27', + documentId: input.documentId, + documentName: input.documentName, + department: input.department, signerName: patient?.name ?? '演示患者', - method: payload.method, + method: input.method, status: 'pending', source: '工作台发起', expiresAt: addHours('2026-08-27 12:00', 48), updatedAt: formatMockDate(), + rowVersion: 1, + backendStatus: 'WAITING_SIGN', } mockRecords.unshift(task) @@ -456,7 +942,7 @@ export function updateSigningTaskMethod( method: SigningMethod, ): Promise { if (!useMockData) { - return request.put('/workbench/signing/tasks/' + id + '/method', { method }) + throw new Error('当前后端未提供切换签署方式接口,请作废后重新创建任务') } const task = mockRecords.find((record) => record.id === id) @@ -475,9 +961,7 @@ export function completeSigningTask( signatureDataUrl: string, ): Promise { if (!useMockData) { - return request.post('/workbench/signing/tasks/' + id + '/complete', { - signatureDataUrl, - }) + throw new Error('真实签署需要通过签署投递与文件上传接口完成') } const task = mockRecords.find((record) => record.id === id) @@ -493,9 +977,16 @@ export function completeSigningTask( return Promise.resolve(cloneTask(task)) } -export function reopenSigningTask(id: string): Promise { +export async function reopenSigningTask( + id: string, + options: SigningTaskActionOptions = {}, +): Promise { if (!useMockData) { - return request.post('/workbench/signing/tasks/' + id + '/reopen') + const response = await request.post>( + `/v1/sign-tasks/${encodeURIComponent(id)}/reopen`, + getTaskActionBody(options.expectedRowVersion), + ) + return mapSigningTask(unwrapApiResponse(response), options) } const task = mockRecords.find((record) => record.id === id) @@ -511,9 +1002,17 @@ export function reopenSigningTask(id: string): Promise return Promise.resolve(cloneTask(task)) } -export function voidSigningTask(id: string, reason: string): Promise { +export async function voidSigningTask( + id: string, + reason: string, + options: SigningTaskActionOptions = {}, +): Promise { if (!useMockData) { - return request.post('/workbench/signing/tasks/' + id + '/void', { reason }) + const response = await request.post>( + `/v1/sign-tasks/${encodeURIComponent(id)}/void`, + { ...getTaskActionBody(options.expectedRowVersion), reason }, + ) + return mapSigningTask(unwrapApiResponse(response), options) } const task = mockRecords.find((record) => record.id === id) @@ -529,9 +1028,17 @@ export function voidSigningTask(id: string, reason: string): Promise { +export async function resendSigningSms( + id: string, + options: SigningTaskActionOptions = {}, +): Promise { if (!useMockData) { - return request.post('/workbench/signing/tasks/' + id + '/resend-sms') + const response = await request.post>( + `/v1/sign-tasks/${encodeURIComponent(id)}/resend`, + getTaskActionBody(options.expectedRowVersion), + ) + unwrapApiResponse(response) + return getSigningTaskDetail(id, options) } const task = mockRecords.find((record) => record.id === id) @@ -547,6 +1054,60 @@ export function resendSigningSms(id: string): Promise return Promise.resolve(cloneTask(task)) } +export async function getSigningTaskEvents(id: string): Promise { + if (!useMockData) { + const response = await request.get>( + `/v1/sign-tasks/${encodeURIComponent(id)}/events`, + ) + + return unwrapApiResponse(response).map(mapSigningEvent) + } + + const task = mockRecords.find((record) => record.id === id) + + if (!task) { + return [] + } + + const events: SigningTaskEvent[] = [ + { + id: `${task.id}-created`, + time: task.updatedAt, + text: `张文静发起签署任务(${task.method === 'pad' ? '手写板' : '线上短信'})`, + eventType: 'TASK_CREATED', + }, + ] + + if (task.status === 'signing') { + events.push({ + id: `${task.id}-signing`, + time: task.updatedAt, + text: '已打开签名采集,等待患者完成签名', + eventType: 'SIGNING_STARTED', + }) + } + + if (task.signedAt) { + events.push({ + id: `${task.id}-signed`, + time: task.signedAt, + text: `患者 ${task.patientName} 完成签名,系统自动合成并双留存`, + eventType: 'SIGNED', + }) + } + + if (task.status === 'void') { + events.push({ + id: `${task.id}-voided`, + time: task.updatedAt, + text: `任务已作废${task.voidReason ? `,原因:${task.voidReason}` : ''},全程可审计`, + eventType: 'VOIDED', + }) + } + + return events +} + export function getSigningStatusLabel(status: SigningTaskStatus) { const labels: Record = { pending: '待签署', @@ -555,6 +1116,7 @@ export function getSigningStatusLabel(status: SigningTaskStatus) { rejected: '已拒签', expired: '已超时', void: '已作废', + failed: '处理失败', } return labels[status] diff --git a/clinical-web/src/api/workbench/types.ts b/clinical-web/src/api/workbench/types.ts index 2295f42..5b93a8e 100644 --- a/clinical-web/src/api/workbench/types.ts +++ b/clinical-web/src/api/workbench/types.ts @@ -1,4 +1,4 @@ -import type { PageQuery, PageResult } from '@/types/common' +import type { ApiResponse, PageQuery, PageResult } from '@/types/common' export type WorkbenchCampus = '本部院区' | '东院区' | '西院区' @@ -9,23 +9,31 @@ export interface WorkbenchOverviewQuery { rankingPeriod: HomeRankingPeriod } -export type SigningTaskStatus = 'pending' | 'signing' | 'signed' | 'rejected' | 'expired' | 'void' +export type SigningTaskStatus = + 'pending' | 'signing' | 'signed' | 'rejected' | 'expired' | 'void' | 'failed' -export type VisitType = '门诊' | '住院' | '体检' +export type PatientSex = '男' | '女' | '未知' + +export type VisitType = '门诊' | '住院' | '体检' | '其他' export interface PatientVisit { id: string type: VisitType visitNo: string department: string + departmentId?: string | null + doctorName?: string visitDate: string + visitedAt?: string + campusId?: string + sourceSystem?: string isCurrent?: boolean } export interface PatientProfile { id: string name: string - sex: '男' | '女' + sex: PatientSex age: number idCard: string phone: string @@ -34,9 +42,13 @@ export interface PatientProfile { export interface SigningTemplate { id: string + versionId: string name: string code: string + version: string department: string + departmentId?: string | null + campusId?: string category: string description: string supportedMethods: SigningMethod[] @@ -108,9 +120,10 @@ export interface SigningTaskQuery extends PageQuery { export type SigningDateRange = 'today' | 'yesterday' | '3d' | '7d' | 'all' export interface SigningTaskRecord extends WorkbenchTask { + templateVersionId: string campus: WorkbenchCampus patientId: string - sex: '男' | '女' + sex: PatientSex age: number documentId: string signerName: string @@ -119,6 +132,8 @@ export interface SigningTaskRecord extends WorkbenchTask { expiresAt: string visitType: VisitType visitDate: string + rowVersion?: number + backendStatus?: BackendSigningTaskStatus signatureDataUrl?: string signedAt?: string voidReason?: string @@ -126,15 +141,197 @@ export interface SigningTaskRecord extends WorkbenchTask { export type SigningTaskListResponse = PageResult -export interface CreateSigningTaskRequest { +/** 页面提交模型。真实接口请求会在 API 边界转换为 CreateSigningTaskRequest。 */ +export interface CreateSigningTaskInput { campus: WorkbenchCampus patientId: string visitId: string documentId: string documentName: string + templateVersionId: string method: SigningMethod visitType: VisitType visitNo: string department: string - phone?: string } + +/** MEDISIGN 创建签署任务请求 DTO。不要向后端提交页面快照字段。 */ +export interface CreateSigningTaskRequest { + patientId: string + visitId: string + templateVersionId: string + signMethod: BackendSigningMethod + idempotencyKey?: string +} + +export interface SigningApiOptions { + templates?: SigningTemplate[] + campus?: WorkbenchCampus +} + +export interface SigningTaskActionOptions extends SigningApiOptions { + expectedRowVersion?: number +} + +export type BackendSigningTaskStatus = + 'CREATED' | 'WAITING_SIGN' | 'SIGNED' | 'EXPIRED' | 'VOIDED' | 'GENERATING' | 'FAILED' + +export type BackendSigningMethod = 'PAD' | 'SMS' + +export type BackendVisitType = 'OUTPATIENT' | 'INPATIENT' | 'CHECKUP' | string + +export interface ApiPageResponse { + records: T[] + page: number + size: number + total: number + pages: number +} + +export interface PatientResponseDto { + id: string + externalPatientId: string + name: string + sex: string + birthDate: string | null + age: number + idCardMasked: string | null + phoneMasked: string | null + sourceSystem: string + campusId: string + createdAt: string + updatedAt: string +} + +export interface VisitResponseDto { + id: string + patientId: string + visitNo: string + visitType: BackendVisitType + departmentId: string | null + departmentName: string + doctorName: string | null + visitedAt: string + campusId: string + sourceSystem: string + createdAt: string + updatedAt: string +} + +export interface AvailableTemplateVersionResponseDto { + templateId: string + templateVersionId: string + templateCode: string + templateName: string + category: string + campusId: string + departmentId: string | null + versionNo: string + contentSha256: string + effectiveAt: string +} + +export interface SignTaskPatientSnapshotDto { + patientId: string + name: string + sex: string + age: number + birthDate: string | null + idCardMasked: string | null + phoneMasked: string | null + sourceSystem: string + capturedAt: string + schemaVersion: string + contentHash: string +} + +export interface SignTaskVisitSnapshotDto { + visitNo: string + visitType: BackendVisitType + departmentName: string + doctorName: string | null + visitedAt: string + sourceSystem: string + capturedAt: string + schemaVersion: string + contentHash: string +} + +export interface SignTaskResponseDto { + id: string + templateVersionId: string + templateVersionNumber: number + signMethod: BackendSigningMethod + status: BackendSigningTaskStatus + patientSnapshot: SignTaskPatientSnapshotDto + visitSnapshot: SignTaskVisitSnapshotDto + campusId: string + departmentId: string | null + patientSnapshotSchemaVersion: string + patientSnapshotCapturedAt: string + patientSnapshotHash: string + visitSnapshotSchemaVersion: string + visitSnapshotCapturedAt: string + visitSnapshotHash: string + templateContentSha256: string + templateFileSha256: string + smsMobileMasked: string | null + signedAt: string | null + expiredAt: string | null + voidedAt: string | null + voidReason: string | null + createdAt: string + createdBy: string + updatedAt: string + updatedBy: string + rowVersion: number +} + +export interface SignTaskActionRequest { + reason?: string + expectedRowVersion?: number +} + +export interface SignTaskSendPreparationResponseDto { + taskId: string + status: BackendSigningTaskStatus + signMethod: BackendSigningMethod + intentCreated: boolean + preparedAt: string + rowVersion: number +} + +export interface SignTaskEventResponseDto { + id: string + taskId: string + eventType: string + fromStatus: string | null + toStatus: string | null + actorType: string | null + actorSubject: string | null + operatorId: string | null + eventTime: string + clientIp: string | null + userAgent: string | null + traceId: string | null + requestId: string | null + channel: string | null + attemptNo: number | null + reason: string | null + fromRowVersion: number | null + toRowVersion: number | null + detail: Record | null +} + +export interface SigningTaskEvent { + id: string + time: string + text: string + eventType: string + actorSubject?: string + reason?: string + channel?: string + attemptNo?: number +} + +export type ApiResponseOf = ApiResponse diff --git a/clinical-web/src/components/signing/NewSigningTaskDialog.vue b/clinical-web/src/components/signing/NewSigningTaskDialog.vue index 33fab08..55bff1b 100644 --- a/clinical-web/src/components/signing/NewSigningTaskDialog.vue +++ b/clinical-web/src/components/signing/NewSigningTaskDialog.vue @@ -32,7 +32,6 @@ const { method, patient, patientId, - patientPhoneRevealed, resetForNextTask, resetForm, selectMethod, @@ -42,10 +41,8 @@ const { selectedTemplateId, selectedVisit, selectedVisitId, - smsPhone, submit: submitForm, submitting, - togglePhone, visits, } = useSigningTaskForm({ campus: computed(() => appStore.selectedCampus), @@ -61,7 +58,7 @@ async function submitTask(keepOpen: boolean) { } emit('created', task) - ElMessage.success(`已创建 1 个签署任务${task.method === 'sms' ? ',短信已发送' : ''}`) + ElMessage.success(`已创建 1 个签署任务${task.method === 'sms' ? ',已生成短信投递意图' : ''}`) if (keepOpen) { resetForNextTask() @@ -141,10 +138,7 @@ watch( 身份证号{{ patient.idCard }} 手机号 - {{ patientPhoneRevealed ? patient.phone : maskPhone(patient.phone) }} - + {{ maskPhone(patient.phone) }} 患者 ID{{ patient.id }} 在院 @@ -289,15 +283,9 @@ watch(
📱 短信将发送至 - + {{ patient ? maskPhone(patient.phone) : '定位患者后显示' }}
-

可修改为患者其他号码,修改将记录留痕

-

{{ errors.phone }}

+

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

@@ -537,15 +525,6 @@ watch( font-size: 11px; } -.inline-button { - padding: 2px 5px; - color: var(--brand); - font-size: 11px; - background: transparent; - border: 1px solid var(--brand); - border-radius: 4px; -} - .in-hospital { padding: 1px 7px; color: #fff; @@ -840,10 +819,6 @@ watch( font-size: 16px; } -.sms-row input { - width: 170px; -} - .sms-confirm p { padding-left: 28px; margin: 6px 0 0; diff --git a/clinical-web/src/composables/useSigningTaskForm.ts b/clinical-web/src/composables/useSigningTaskForm.ts index c542116..5047090 100644 --- a/clinical-web/src/composables/useSigningTaskForm.ts +++ b/clinical-web/src/composables/useSigningTaskForm.ts @@ -35,8 +35,6 @@ export function useSigningTaskForm(options: UseSigningTaskFormOptions) { const selectedTemplateId = ref('') const selectedVisitId = ref('') const method = ref('pad') - const smsPhone = ref('') - const patientPhoneRevealed = ref(false) const loadingTemplates = ref(false) const locating = ref(false) const submitting = ref(false) @@ -45,7 +43,6 @@ export function useSigningTaskForm(options: UseSigningTaskFormOptions) { patient: '', visit: '', document: '', - phone: '', }) const selectedVisit = computed( @@ -107,7 +104,6 @@ export function useSigningTaskForm(options: UseSigningTaskFormOptions) { errors.patient = '' errors.visit = '' errors.document = '' - errors.phone = '' } function resetForm() { @@ -120,8 +116,6 @@ export function useSigningTaskForm(options: UseSigningTaskFormOptions) { selectedTemplateId.value = initialTemplate?.id ?? '' selectedVisitId.value = '' method.value = initialTemplate?.supportedMethods[0] ?? 'pad' - smsPhone.value = '' - patientPhoneRevealed.value = false clearErrors() } @@ -171,7 +165,6 @@ export function useSigningTaskForm(options: UseSigningTaskFormOptions) { if (!profile) { errors.patient = '未找到该患者,请核对输入内容' - smsPhone.value = '' return } @@ -179,7 +172,6 @@ export function useSigningTaskForm(options: UseSigningTaskFormOptions) { visits.value = profile.visits selectedVisitId.value = profile.visits.find((visit) => visit.isCurrent)?.id ?? profile.visits[0]?.id ?? '' - smsPhone.value = profile.phone } catch { errors.patient = '患者信息查询失败,请稍后重试' } finally { @@ -207,7 +199,6 @@ export function useSigningTaskForm(options: UseSigningTaskFormOptions) { } method.value = nextMethod - errors.phone = '' } function maskPhone(value: string) { @@ -218,11 +209,6 @@ export function useSigningTaskForm(options: UseSigningTaskFormOptions) { return `${value.slice(0, 3)}****${value.slice(-4)}` } - function togglePhone() { - patientPhoneRevealed.value = !patientPhoneRevealed.value - ElMessage.info('明文查看已记录审计日志') - } - function validate() { clearErrors() let valid = true @@ -242,11 +228,6 @@ export function useSigningTaskForm(options: UseSigningTaskFormOptions) { valid = false } - if (method.value === 'sms' && !smsPhone.value.trim()) { - errors.phone = '请确认短信接收手机号' - valid = false - } - return valid } @@ -264,11 +245,11 @@ export function useSigningTaskForm(options: UseSigningTaskFormOptions) { visitId: selectedVisit.value.id, documentId: selectedTemplate.value.id, documentName: selectedTemplate.value.name, + templateVersionId: selectedTemplate.value.versionId, method: method.value, visitType: selectedVisit.value.type, visitNo: selectedVisit.value.visitNo, department: selectedVisit.value.department, - phone: method.value === 'sms' ? smsPhone.value.trim() : undefined, }) } finally { submitting.value = false @@ -287,7 +268,6 @@ export function useSigningTaskForm(options: UseSigningTaskFormOptions) { method, patient, patientId, - patientPhoneRevealed, resetForNextTask, resetForm, selectMethod, @@ -297,11 +277,9 @@ export function useSigningTaskForm(options: UseSigningTaskFormOptions) { selectedTemplateId, selectedVisit, selectedVisitId, - smsPhone, submit, submitting, templates, - togglePhone, visits, } } diff --git a/clinical-web/src/views/management/documents/index.vue b/clinical-web/src/views/management/documents/index.vue index bd84593..219e5fc 100644 --- a/clinical-web/src/views/management/documents/index.vue +++ b/clinical-web/src/views/management/documents/index.vue @@ -104,8 +104,10 @@ function showEditMessage(template: DocumentTemplate) { function toSigningTemplate(template: DocumentTemplate): SigningTemplate { return { id: template.id, + versionId: template.id, name: template.name, code: template.code, + version: template.version, department: template.department, category: template.category, description: template.description,