diff --git a/clinical-web/src/api/workbench/signing.ts b/clinical-web/src/api/workbench/signing.ts index eb383dc..05dec99 100644 --- a/clinical-web/src/api/workbench/signing.ts +++ b/clinical-web/src/api/workbench/signing.ts @@ -1,48 +1,334 @@ import { request } from '@/utils/request' -import type { SigningTaskListResponse, SigningTaskQuery, SigningTaskRecord } from './types' +import type { + CreateSigningTaskRequest, + PatientProfile, + SigningMethod, + SigningTaskListResponse, + SigningTaskQuery, + SigningTaskRecord, + SigningTaskStatus, + SigningTemplate, + VisitType, +} from './types' const useMockData = import.meta.env.VITE_USE_MOCK !== 'false' +const mockPatients: PatientProfile[] = [ + { + id: 'P202610001', + name: '李某某', + sex: '男', + age: 46, + idCard: '310***********1234', + phone: '13800005678', + visits: [ + { + id: 'visit-001', + type: '住院', + visitNo: 'ZY20260827001', + department: '消化内科', + visitDate: '2026-08-27', + isCurrent: true, + }, + { + id: 'visit-002', + type: '门诊', + visitNo: 'MZ20260812018', + department: '消化内科', + visitDate: '2026-08-12', + }, + ], + }, + { + id: 'P202610002', + name: '王某某', + sex: '女', + age: 37, + idCard: '310***********6721', + phone: '13900006789', + visits: [ + { + id: 'visit-003', + type: '门诊', + visitNo: 'JZ20260827018', + department: '儿科急诊', + visitDate: '2026-08-27', + isCurrent: true, + }, + ], + }, + { + id: 'P202610003', + name: '赵某某', + sex: '男', + age: 52, + idCard: '310***********2846', + phone: '13700007890', + visits: [ + { + id: 'visit-004', + type: '住院', + visitNo: 'ZY20260826027', + department: '消化内科', + visitDate: '2026-08-26', + isCurrent: true, + }, + ], + }, + { + id: 'P202610004', + name: '周雅琴', + sex: '女', + age: 29, + idCard: '310***********9152', + phone: '13600008901', + visits: [ + { + id: 'visit-005', + type: '体检', + visitNo: 'TJ20260825006', + department: '健康管理中心', + visitDate: '2026-08-25', + isCurrent: true, + }, + ], + }, + { + id: 'P202610005', + name: '陈志强', + sex: '男', + age: 61, + idCard: '310***********3488', + phone: '13500009012', + visits: [ + { + id: 'visit-006', + type: '住院', + visitNo: 'ZY20260826011', + department: '普外科', + visitDate: '2026-08-26', + isCurrent: true, + }, + ], + }, +] + +const mockTemplates: SigningTemplate[] = [ + { + id: 'tpl-001', + name: '住院患者知情同意书', + code: 'DOC-HOS-001', + department: '全院通用', + category: '住院告知', + description: '住院期间诊疗事项、风险与患者权利告知。', + supportedMethods: ['pad', 'sms'], + }, + { + id: 'tpl-002', + name: '住院须知及告知书', + code: 'DOC-HOS-002', + department: '全院通用', + category: '住院告知', + description: '住院流程、陪护要求和患者须知确认。', + supportedMethods: ['pad', 'sms'], + }, + { + id: 'tpl-003', + name: '内镜检查知情同意书', + code: 'DOC-DIG-001', + department: '消化内科', + category: '检查告知', + description: '内镜检查适应证、风险及替代方案告知。', + supportedMethods: ['pad', 'sms'], + }, + { + id: 'tpl-004', + name: 'MRI 磁共振检查知情同意书', + code: 'DOC-RAD-001', + department: '放射科', + category: '检查告知', + description: '磁共振检查注意事项、禁忌证及风险告知。', + supportedMethods: ['pad', 'sms'], + }, + { + id: 'tpl-005', + name: '心脏介入手术知情同意书', + code: 'DOC-CARD-001', + department: '心内科', + category: '手术告知', + description: '介入治疗方案、围术期风险及替代方案告知。', + supportedMethods: ['pad'], + }, + { + id: 'tpl-006', + name: '体检报告发放及知情同意书', + code: 'DOC-PE-001', + department: '健康管理中心', + category: '体检告知', + description: '体检报告领取方式、隐私保护和异常结果随访告知。', + supportedMethods: ['pad', 'sms'], + }, + { + id: 'tpl-007', + name: '患者授权委托书', + code: 'DOC-GEN-003', + department: '医务处', + category: '授权文书', + description: '患者授权家属或代理人办理相关医疗事项。', + supportedMethods: ['pad', 'sms'], + }, +] + const mockRecords: SigningTaskRecord[] = [ { id: 'task-001', + campus: '本部院区', + patientId: 'P202610001', patientName: '李某某', + sex: '男', + age: 46, visitNo: 'ZY20260827001', + visitType: '住院', + visitDate: '2026-08-27', + documentId: 'tpl-001', documentName: '住院患者知情同意书', department: '消化内科', status: 'pending', signerName: '李某某', + method: 'pad', source: '电子病历', - expiresAt: '2026-08-28 09:30', + expiresAt: '2026-08-30 09:30', updatedAt: '2026-08-27 09:30', }, { id: 'task-002', + campus: '东院区', + patientId: 'P202610002', patientName: '王某某', + sex: '女', + age: 37, visitNo: 'JZ20260827018', - documentName: '急诊留观知情同意书', - department: '儿科急诊', + visitType: '门诊', + visitDate: '2026-08-27', + documentId: 'tpl-004', + documentName: 'MRI 磁共振检查知情同意书', + department: '放射科', status: 'signed', signerName: '王某某家属', + method: 'sms', source: '工作台发起', - expiresAt: '2026-08-27 18:00', + expiresAt: '2026-08-29 18:00', updatedAt: '2026-08-27 09:12', + signedAt: '2026-08-27 09:12', }, { id: 'task-003', + campus: '本部院区', + patientId: 'P202610003', patientName: '赵某某', + sex: '男', + age: 52, visitNo: 'ZY20260826027', + visitType: '住院', + visitDate: '2026-08-26', + documentId: 'tpl-003', documentName: '内镜检查知情同意书', department: '消化内科', - status: 'rejected', + status: 'expired', signerName: '赵某某家属', + method: 'sms', source: '电子病历', expiresAt: '2026-08-27 16:00', updatedAt: '2026-08-27 08:45', }, + { + id: 'task-004', + campus: '本部院区', + patientId: 'P202610005', + patientName: '陈志强', + sex: '男', + age: 61, + visitNo: 'ZY20260826011', + visitType: '住院', + visitDate: '2026-08-26', + documentId: 'tpl-002', + documentName: '住院须知及告知书', + department: '普外科', + status: 'void', + signerName: '陈志强', + method: 'pad', + source: '电子病历', + expiresAt: '2026-08-29 11:00', + updatedAt: '2026-08-26 11:20', + voidReason: '文档选择错误', + }, + { + id: 'task-005', + campus: '东院区', + patientId: 'P202610004', + patientName: '周雅琴', + sex: '女', + age: 29, + visitNo: 'TJ20260825006', + visitType: '体检', + visitDate: '2026-08-25', + documentId: 'tpl-006', + documentName: '体检报告发放及知情同意书', + department: '健康管理中心', + status: 'signing', + signerName: '周雅琴', + method: 'pad', + source: '工作台发起', + expiresAt: '2026-08-30 14:20', + updatedAt: '2026-08-27 08:20', + }, ] +function clonePatient(patient: PatientProfile): PatientProfile { + return { + ...patient, + visits: patient.visits.map((visit) => ({ ...visit })), + } +} + +function cloneTask(task: SigningTaskRecord): SigningTaskRecord { + return { ...task } +} + +function getRangeStart(range: SigningTaskQuery['dateRange']) { + if (!range || range === 'all') { + return null + } + + const date = new Date('2026-08-27T00:00:00') + + if (range === 'yesterday') { + date.setDate(date.getDate() - 1) + } else if (range === '3d') { + date.setDate(date.getDate() - 2) + } else if (range === '7d') { + date.setDate(date.getDate() - 6) + } + + return date +} + +function parseMockDate(value: string) { + return new Date(value.replace(' ', 'T')) +} + +function formatMockDate(date = new Date('2026-08-27T12:00:00')) { + const pad = (value: number) => String(value).padStart(2, '0') + return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}` +} + +function addHours(value: string, hours: number) { + const date = parseMockDate(value) + date.setHours(date.getHours() + hours) + return formatMockDate(date) +} + export function getSigningTasks(query: SigningTaskQuery): Promise { if (!useMockData) { return request.get('/workbench/signing/tasks', { @@ -51,23 +337,49 @@ export function getSigningTasks(query: SigningTaskQuery): Promise { - const matchesKeyword = - !keyword || - [record.patientName, record.visitNo, record.documentName] - .join(' ') - .toLowerCase() - .includes(keyword) - const matchesStatus = !query.status || query.status === 'all' || record.status === query.status + const rangeStart = getRangeStart(query.dateRange) + const filteredRecords = mockRecords + .filter((record) => { + const matchesKeyword = + !keyword || + [record.patientName, record.patientId, record.visitNo, record.documentName, record.id] + .join(' ') + .toLowerCase() + .includes(keyword) + const matchesStatus = + !query.status || query.status === 'all' || record.status === query.status + const matchesCampus = + !query.campus || query.campus === 'all' || record.campus === query.campus + const matchesMethod = + !query.method || query.method === 'all' || record.method === query.method + const matchesDepartment = !query.department || record.department === query.department + const matchesDocument = !query.documentId || record.documentId === query.documentId + const matchesVisitType = + !query.visitType || query.visitType === 'all' || record.visitType === query.visitType + const matchesDate = !rangeStart || parseMockDate(record.updatedAt) >= rangeStart + + return ( + matchesKeyword && + matchesStatus && + matchesCampus && + matchesMethod && + matchesDepartment && + matchesDocument && + matchesVisitType && + matchesDate + ) + }) + .sort( + (left, right) => + parseMockDate(right.updatedAt).getTime() - parseMockDate(left.updatedAt).getTime(), + ) - return matchesKeyword && matchesStatus - }) const page = Math.max(query.page, 1) const pageSize = Math.max(query.pageSize, 1) const start = (page - 1) * pageSize return Promise.resolve({ - records: filteredRecords.slice(start, start + pageSize), + records: filteredRecords.slice(start, start + pageSize).map(cloneTask), total: filteredRecords.length, page, pageSize, @@ -79,5 +391,175 @@ export function getSigningTaskDetail(id: string): Promise('/workbench/signing/tasks/' + id) } - return Promise.resolve(mockRecords.find((record) => record.id === id) ?? null) + return Promise.resolve(mockRecords.find((record) => record.id === id) ?? null).then((task) => + task ? cloneTask(task) : null, + ) +} + +export function getPatientProfile(keyword: string): Promise { + if (!useMockData) { + return request.get('/patients/' + encodeURIComponent(keyword)) + } + + const normalizedKeyword = keyword.trim() + const patient = mockPatients.find( + (item) => + item.id === normalizedKeyword || + item.visits.some((visit) => visit.visitNo === normalizedKeyword), + ) + + return Promise.resolve(patient ? clonePatient(patient) : null) +} + +export function getSigningTemplates(): Promise { + if (!useMockData) { + return request.get('/workbench/signing/templates') + } + + return Promise.resolve(mockTemplates.map((template) => ({ ...template }))) +} + +export function createSigningTask(payload: CreateSigningTaskRequest): Promise { + if (!useMockData) { + return request.post('/workbench/signing/tasks', payload) + } + + const patient = mockPatients.find((item) => item.id === payload.patientId) + const task: SigningTaskRecord = { + id: `task-${Date.now()}`, + campus: payload.campus, + patientId: payload.patientId, + patientName: patient?.name ?? '演示患者', + sex: patient?.sex ?? '男', + age: patient?.age ?? 40, + visitNo: payload.visitNo, + visitType: payload.visitType, + visitDate: + patient?.visits.find((visit) => visit.id === payload.visitId)?.visitDate ?? '2026-08-27', + documentId: payload.documentId, + documentName: payload.documentName, + department: payload.department, + signerName: patient?.name ?? '演示患者', + method: payload.method, + status: 'pending', + source: '工作台发起', + expiresAt: addHours('2026-08-27 12:00', 48), + updatedAt: formatMockDate(), + } + + mockRecords.unshift(task) + return Promise.resolve(cloneTask(task)) +} + +export function updateSigningTaskMethod( + id: string, + method: SigningMethod, +): Promise { + if (!useMockData) { + return request.put('/workbench/signing/tasks/' + id + '/method', { method }) + } + + const task = mockRecords.find((record) => record.id === id) + + if (!task) { + return Promise.resolve(null) + } + + task.method = method + task.updatedAt = formatMockDate() + return Promise.resolve(cloneTask(task)) +} + +export function completeSigningTask( + id: string, + signatureDataUrl: string, +): Promise { + if (!useMockData) { + return request.post('/workbench/signing/tasks/' + id + '/complete', { + signatureDataUrl, + }) + } + + const task = mockRecords.find((record) => record.id === id) + + if (!task) { + return Promise.resolve(null) + } + + task.status = 'signed' + task.signatureDataUrl = signatureDataUrl + task.signedAt = formatMockDate() + task.updatedAt = task.signedAt + return Promise.resolve(cloneTask(task)) +} + +export function reopenSigningTask(id: string): Promise { + if (!useMockData) { + return request.post('/workbench/signing/tasks/' + id + '/reopen') + } + + const task = mockRecords.find((record) => record.id === id) + + if (!task) { + return Promise.resolve(null) + } + + task.status = 'pending' + task.signatureDataUrl = undefined + task.signedAt = undefined + task.updatedAt = formatMockDate() + return Promise.resolve(cloneTask(task)) +} + +export function voidSigningTask(id: string, reason: string): Promise { + if (!useMockData) { + return request.post('/workbench/signing/tasks/' + id + '/void', { reason }) + } + + const task = mockRecords.find((record) => record.id === id) + + if (!task) { + return Promise.resolve(null) + } + + task.status = 'void' + task.voidReason = reason + task.signatureDataUrl = undefined + task.updatedAt = formatMockDate() + return Promise.resolve(cloneTask(task)) +} + +export function resendSigningSms(id: string): Promise { + if (!useMockData) { + return request.post('/workbench/signing/tasks/' + id + '/resend-sms') + } + + const task = mockRecords.find((record) => record.id === id) + + if (!task) { + return Promise.resolve(null) + } + + task.method = 'sms' + task.status = 'pending' + task.expiresAt = addHours('2026-08-27 12:00', 48) + task.updatedAt = formatMockDate() + return Promise.resolve(cloneTask(task)) +} + +export function getSigningStatusLabel(status: SigningTaskStatus) { + const labels: Record = { + pending: '待签署', + signing: '签署中', + signed: '已签署', + rejected: '已拒签', + expired: '已超时', + void: '已作废', + } + + return labels[status] +} + +export function getVisitTypeLabel(type: VisitType) { + return type } diff --git a/clinical-web/src/api/workbench/types.ts b/clinical-web/src/api/workbench/types.ts index 50e2791..2295f42 100644 --- a/clinical-web/src/api/workbench/types.ts +++ b/clinical-web/src/api/workbench/types.ts @@ -9,7 +9,38 @@ export interface WorkbenchOverviewQuery { rankingPeriod: HomeRankingPeriod } -export type SigningTaskStatus = 'pending' | 'signing' | 'signed' | 'rejected' | 'expired' +export type SigningTaskStatus = 'pending' | 'signing' | 'signed' | 'rejected' | 'expired' | 'void' + +export type VisitType = '门诊' | '住院' | '体检' + +export interface PatientVisit { + id: string + type: VisitType + visitNo: string + department: string + visitDate: string + isCurrent?: boolean +} + +export interface PatientProfile { + id: string + name: string + sex: '男' | '女' + age: number + idCard: string + phone: string + visits: PatientVisit[] +} + +export interface SigningTemplate { + id: string + name: string + code: string + department: string + category: string + description: string + supportedMethods: SigningMethod[] +} export interface WorkbenchSummary { todaySigned: number @@ -66,12 +97,44 @@ export interface WorkbenchOverviewResponse { export interface SigningTaskQuery extends PageQuery { keyword?: string status?: SigningTaskStatus | 'all' + dateRange?: SigningDateRange + campus?: WorkbenchCampus | 'all' + method?: SigningMethod | 'all' + department?: string + documentId?: string + visitType?: VisitType | 'all' } +export type SigningDateRange = 'today' | 'yesterday' | '3d' | '7d' | 'all' + export interface SigningTaskRecord extends WorkbenchTask { + campus: WorkbenchCampus + patientId: string + sex: '男' | '女' + age: number + documentId: string signerName: string + method: SigningMethod source: string expiresAt: string + visitType: VisitType + visitDate: string + signatureDataUrl?: string + signedAt?: string + voidReason?: string } export type SigningTaskListResponse = PageResult + +export interface CreateSigningTaskRequest { + campus: WorkbenchCampus + patientId: string + visitId: string + documentId: string + documentName: string + method: SigningMethod + visitType: VisitType + visitNo: string + department: string + phone?: string +} diff --git a/clinical-web/src/views/workbench/signing/components/NewSigningTaskDialog.vue b/clinical-web/src/views/workbench/signing/components/NewSigningTaskDialog.vue new file mode 100644 index 0000000..e5c3b53 --- /dev/null +++ b/clinical-web/src/views/workbench/signing/components/NewSigningTaskDialog.vue @@ -0,0 +1,1113 @@ + + + + + diff --git a/clinical-web/src/views/workbench/signing/components/OnlineSigningDialog.vue b/clinical-web/src/views/workbench/signing/components/OnlineSigningDialog.vue new file mode 100644 index 0000000..94b1cf3 --- /dev/null +++ b/clinical-web/src/views/workbench/signing/components/OnlineSigningDialog.vue @@ -0,0 +1,378 @@ + + + + + diff --git a/clinical-web/src/views/workbench/signing/components/SignatureCanvas.vue b/clinical-web/src/views/workbench/signing/components/SignatureCanvas.vue new file mode 100644 index 0000000..43ca89a --- /dev/null +++ b/clinical-web/src/views/workbench/signing/components/SignatureCanvas.vue @@ -0,0 +1,191 @@ + + + + + diff --git a/clinical-web/src/views/workbench/signing/components/SignaturePadDialog.vue b/clinical-web/src/views/workbench/signing/components/SignaturePadDialog.vue new file mode 100644 index 0000000..e376663 --- /dev/null +++ b/clinical-web/src/views/workbench/signing/components/SignaturePadDialog.vue @@ -0,0 +1,203 @@ + + + + + diff --git a/clinical-web/src/views/workbench/signing/components/SigningDocumentPreview.vue b/clinical-web/src/views/workbench/signing/components/SigningDocumentPreview.vue new file mode 100644 index 0000000..8bef87c --- /dev/null +++ b/clinical-web/src/views/workbench/signing/components/SigningDocumentPreview.vue @@ -0,0 +1,192 @@ + + + + + diff --git a/clinical-web/src/views/workbench/signing/components/SigningFilterBar.vue b/clinical-web/src/views/workbench/signing/components/SigningFilterBar.vue new file mode 100644 index 0000000..8b7840b --- /dev/null +++ b/clinical-web/src/views/workbench/signing/components/SigningFilterBar.vue @@ -0,0 +1,198 @@ + + + + + diff --git a/clinical-web/src/views/workbench/signing/components/SigningTaskDetail.vue b/clinical-web/src/views/workbench/signing/components/SigningTaskDetail.vue new file mode 100644 index 0000000..067343b --- /dev/null +++ b/clinical-web/src/views/workbench/signing/components/SigningTaskDetail.vue @@ -0,0 +1,233 @@ + + + + + diff --git a/clinical-web/src/views/workbench/signing/components/SigningTaskList.vue b/clinical-web/src/views/workbench/signing/components/SigningTaskList.vue new file mode 100644 index 0000000..196ecce --- /dev/null +++ b/clinical-web/src/views/workbench/signing/components/SigningTaskList.vue @@ -0,0 +1,344 @@ + + + + + diff --git a/clinical-web/src/views/workbench/signing/components/TaskAuditTimeline.vue b/clinical-web/src/views/workbench/signing/components/TaskAuditTimeline.vue new file mode 100644 index 0000000..e25de2f --- /dev/null +++ b/clinical-web/src/views/workbench/signing/components/TaskAuditTimeline.vue @@ -0,0 +1,86 @@ + + + + + diff --git a/clinical-web/src/views/workbench/signing/components/VoidTaskDialog.vue b/clinical-web/src/views/workbench/signing/components/VoidTaskDialog.vue new file mode 100644 index 0000000..fa3a5e7 --- /dev/null +++ b/clinical-web/src/views/workbench/signing/components/VoidTaskDialog.vue @@ -0,0 +1,227 @@ + + + + + diff --git a/clinical-web/src/views/workbench/signing/index.vue b/clinical-web/src/views/workbench/signing/index.vue index d233180..099d5cf 100644 --- a/clinical-web/src/views/workbench/signing/index.vue +++ b/clinical-web/src/views/workbench/signing/index.vue @@ -1,12 +1,520 @@ + + diff --git a/clinical-web/src/views/workbench/signing/types.ts b/clinical-web/src/views/workbench/signing/types.ts index 83d8da2..3b10aa1 100644 --- a/clinical-web/src/views/workbench/signing/types.ts +++ b/clinical-web/src/views/workbench/signing/types.ts @@ -1,18 +1,50 @@ -export type SigningFilterStatus = 'all' | 'pending' | 'signing' | 'signed' | 'rejected' | 'expired' +import type { + PatientProfile, + PatientVisit, + SigningDateRange, + SigningMethod, + SigningTaskRecord, + SigningTaskStatus, + SigningTemplate, + VisitType, + WorkbenchCampus, +} from '@/api/workbench/types' + +export type SigningFilterStatus = 'all' | SigningTaskStatus export interface SigningFilterForm { keyword: string status: SigningFilterStatus - dateRange: [string, string] | null + dateRange: SigningDateRange + campus: WorkbenchCampus | 'all' + method: SigningMethod | 'all' + department: string + documentId: string + visitType: VisitType | 'all' } -export interface SigningTableRow { - id: string - patientName: string - visitNo: string - documentName: string - department: string - signerName: string - status: SigningFilterStatus - updatedAt: string +export type SigningTableRow = SigningTaskRecord + +export interface SigningFilterOption { + label: string + value: T } + +export interface NewSigningTaskState { + patient: PatientProfile | null + selectedVisit: PatientVisit | null + selectedTemplate: SigningTemplate | null + method: SigningMethod + phone: string +} + +export type SigningTaskAction = + | 'pad-sign' + | 'online-sign' + | 'resend-sms' + | 'switch-method' + | 'reopen' + | 'void' + | 'download-pdf' + | 'download-signature' + | 'print'