feat(workbench): 扩展签署任务管理功能
This commit is contained in:
@@ -1,48 +1,334 @@
|
|||||||
import { request } from '@/utils/request'
|
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 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[] = [
|
const mockRecords: SigningTaskRecord[] = [
|
||||||
{
|
{
|
||||||
id: 'task-001',
|
id: 'task-001',
|
||||||
|
campus: '本部院区',
|
||||||
|
patientId: 'P202610001',
|
||||||
patientName: '李某某',
|
patientName: '李某某',
|
||||||
|
sex: '男',
|
||||||
|
age: 46,
|
||||||
visitNo: 'ZY20260827001',
|
visitNo: 'ZY20260827001',
|
||||||
|
visitType: '住院',
|
||||||
|
visitDate: '2026-08-27',
|
||||||
|
documentId: 'tpl-001',
|
||||||
documentName: '住院患者知情同意书',
|
documentName: '住院患者知情同意书',
|
||||||
department: '消化内科',
|
department: '消化内科',
|
||||||
status: 'pending',
|
status: 'pending',
|
||||||
signerName: '李某某',
|
signerName: '李某某',
|
||||||
|
method: 'pad',
|
||||||
source: '电子病历',
|
source: '电子病历',
|
||||||
expiresAt: '2026-08-28 09:30',
|
expiresAt: '2026-08-30 09:30',
|
||||||
updatedAt: '2026-08-27 09:30',
|
updatedAt: '2026-08-27 09:30',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'task-002',
|
id: 'task-002',
|
||||||
|
campus: '东院区',
|
||||||
|
patientId: 'P202610002',
|
||||||
patientName: '王某某',
|
patientName: '王某某',
|
||||||
|
sex: '女',
|
||||||
|
age: 37,
|
||||||
visitNo: 'JZ20260827018',
|
visitNo: 'JZ20260827018',
|
||||||
documentName: '急诊留观知情同意书',
|
visitType: '门诊',
|
||||||
department: '儿科急诊',
|
visitDate: '2026-08-27',
|
||||||
|
documentId: 'tpl-004',
|
||||||
|
documentName: 'MRI 磁共振检查知情同意书',
|
||||||
|
department: '放射科',
|
||||||
status: 'signed',
|
status: 'signed',
|
||||||
signerName: '王某某家属',
|
signerName: '王某某家属',
|
||||||
|
method: 'sms',
|
||||||
source: '工作台发起',
|
source: '工作台发起',
|
||||||
expiresAt: '2026-08-27 18:00',
|
expiresAt: '2026-08-29 18:00',
|
||||||
updatedAt: '2026-08-27 09:12',
|
updatedAt: '2026-08-27 09:12',
|
||||||
|
signedAt: '2026-08-27 09:12',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'task-003',
|
id: 'task-003',
|
||||||
|
campus: '本部院区',
|
||||||
|
patientId: 'P202610003',
|
||||||
patientName: '赵某某',
|
patientName: '赵某某',
|
||||||
|
sex: '男',
|
||||||
|
age: 52,
|
||||||
visitNo: 'ZY20260826027',
|
visitNo: 'ZY20260826027',
|
||||||
|
visitType: '住院',
|
||||||
|
visitDate: '2026-08-26',
|
||||||
|
documentId: 'tpl-003',
|
||||||
documentName: '内镜检查知情同意书',
|
documentName: '内镜检查知情同意书',
|
||||||
department: '消化内科',
|
department: '消化内科',
|
||||||
status: 'rejected',
|
status: 'expired',
|
||||||
signerName: '赵某某家属',
|
signerName: '赵某某家属',
|
||||||
|
method: 'sms',
|
||||||
source: '电子病历',
|
source: '电子病历',
|
||||||
expiresAt: '2026-08-27 16:00',
|
expiresAt: '2026-08-27 16:00',
|
||||||
updatedAt: '2026-08-27 08:45',
|
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<SigningTaskListResponse> {
|
export function getSigningTasks(query: SigningTaskQuery): Promise<SigningTaskListResponse> {
|
||||||
if (!useMockData) {
|
if (!useMockData) {
|
||||||
return request.get<SigningTaskListResponse>('/workbench/signing/tasks', {
|
return request.get<SigningTaskListResponse>('/workbench/signing/tasks', {
|
||||||
@@ -51,23 +337,49 @@ export function getSigningTasks(query: SigningTaskQuery): Promise<SigningTaskLis
|
|||||||
}
|
}
|
||||||
|
|
||||||
const keyword = query.keyword?.trim().toLowerCase()
|
const keyword = query.keyword?.trim().toLowerCase()
|
||||||
const filteredRecords = mockRecords.filter((record) => {
|
const rangeStart = getRangeStart(query.dateRange)
|
||||||
const matchesKeyword =
|
const filteredRecords = mockRecords
|
||||||
!keyword ||
|
.filter((record) => {
|
||||||
[record.patientName, record.visitNo, record.documentName]
|
const matchesKeyword =
|
||||||
.join(' ')
|
!keyword ||
|
||||||
.toLowerCase()
|
[record.patientName, record.patientId, record.visitNo, record.documentName, record.id]
|
||||||
.includes(keyword)
|
.join(' ')
|
||||||
const matchesStatus = !query.status || query.status === 'all' || record.status === query.status
|
.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 page = Math.max(query.page, 1)
|
||||||
const pageSize = Math.max(query.pageSize, 1)
|
const pageSize = Math.max(query.pageSize, 1)
|
||||||
const start = (page - 1) * pageSize
|
const start = (page - 1) * pageSize
|
||||||
|
|
||||||
return Promise.resolve({
|
return Promise.resolve({
|
||||||
records: filteredRecords.slice(start, start + pageSize),
|
records: filteredRecords.slice(start, start + pageSize).map(cloneTask),
|
||||||
total: filteredRecords.length,
|
total: filteredRecords.length,
|
||||||
page,
|
page,
|
||||||
pageSize,
|
pageSize,
|
||||||
@@ -79,5 +391,175 @@ export function getSigningTaskDetail(id: string): Promise<SigningTaskRecord | nu
|
|||||||
return request.get<SigningTaskRecord>('/workbench/signing/tasks/' + id)
|
return request.get<SigningTaskRecord>('/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<PatientProfile | null> {
|
||||||
|
if (!useMockData) {
|
||||||
|
return request.get<PatientProfile>('/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<SigningTemplate[]> {
|
||||||
|
if (!useMockData) {
|
||||||
|
return request.get<SigningTemplate[]>('/workbench/signing/templates')
|
||||||
|
}
|
||||||
|
|
||||||
|
return Promise.resolve(mockTemplates.map((template) => ({ ...template })))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createSigningTask(payload: CreateSigningTaskRequest): Promise<SigningTaskRecord> {
|
||||||
|
if (!useMockData) {
|
||||||
|
return request.post<SigningTaskRecord>('/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<SigningTaskRecord | null> {
|
||||||
|
if (!useMockData) {
|
||||||
|
return request.put<SigningTaskRecord>('/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<SigningTaskRecord | null> {
|
||||||
|
if (!useMockData) {
|
||||||
|
return request.post<SigningTaskRecord>('/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<SigningTaskRecord | null> {
|
||||||
|
if (!useMockData) {
|
||||||
|
return request.post<SigningTaskRecord>('/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<SigningTaskRecord | null> {
|
||||||
|
if (!useMockData) {
|
||||||
|
return request.post<SigningTaskRecord>('/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<SigningTaskRecord | null> {
|
||||||
|
if (!useMockData) {
|
||||||
|
return request.post<SigningTaskRecord>('/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<SigningTaskStatus, string> = {
|
||||||
|
pending: '待签署',
|
||||||
|
signing: '签署中',
|
||||||
|
signed: '已签署',
|
||||||
|
rejected: '已拒签',
|
||||||
|
expired: '已超时',
|
||||||
|
void: '已作废',
|
||||||
|
}
|
||||||
|
|
||||||
|
return labels[status]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getVisitTypeLabel(type: VisitType) {
|
||||||
|
return type
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,38 @@ export interface WorkbenchOverviewQuery {
|
|||||||
rankingPeriod: HomeRankingPeriod
|
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 {
|
export interface WorkbenchSummary {
|
||||||
todaySigned: number
|
todaySigned: number
|
||||||
@@ -66,12 +97,44 @@ export interface WorkbenchOverviewResponse {
|
|||||||
export interface SigningTaskQuery extends PageQuery {
|
export interface SigningTaskQuery extends PageQuery {
|
||||||
keyword?: string
|
keyword?: string
|
||||||
status?: SigningTaskStatus | 'all'
|
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 {
|
export interface SigningTaskRecord extends WorkbenchTask {
|
||||||
|
campus: WorkbenchCampus
|
||||||
|
patientId: string
|
||||||
|
sex: '男' | '女'
|
||||||
|
age: number
|
||||||
|
documentId: string
|
||||||
signerName: string
|
signerName: string
|
||||||
|
method: SigningMethod
|
||||||
source: string
|
source: string
|
||||||
expiresAt: string
|
expiresAt: string
|
||||||
|
visitType: VisitType
|
||||||
|
visitDate: string
|
||||||
|
signatureDataUrl?: string
|
||||||
|
signedAt?: string
|
||||||
|
voidReason?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export type SigningTaskListResponse = PageResult<SigningTaskRecord>
|
export type SigningTaskListResponse = PageResult<SigningTaskRecord>
|
||||||
|
|
||||||
|
export interface CreateSigningTaskRequest {
|
||||||
|
campus: WorkbenchCampus
|
||||||
|
patientId: string
|
||||||
|
visitId: string
|
||||||
|
documentId: string
|
||||||
|
documentName: string
|
||||||
|
method: SigningMethod
|
||||||
|
visitType: VisitType
|
||||||
|
visitNo: string
|
||||||
|
department: string
|
||||||
|
phone?: string
|
||||||
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,378 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, watch } from 'vue'
|
||||||
|
|
||||||
|
import type { SigningTaskRecord } from '@/api/workbench/types'
|
||||||
|
|
||||||
|
import SignatureCanvas from './SignatureCanvas.vue'
|
||||||
|
|
||||||
|
type OnlineStage = 'message' | 'signing'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
visible: boolean
|
||||||
|
task: SigningTaskRecord | null
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
close: []
|
||||||
|
resend: []
|
||||||
|
complete: [signatureDataUrl: string]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const stage = ref<OnlineStage>('message')
|
||||||
|
const signatureDataUrl = ref<string | null>(null)
|
||||||
|
const signatureCanvas = ref<{ clear: () => void } | null>(null)
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.visible,
|
||||||
|
(visible) => {
|
||||||
|
if (visible) {
|
||||||
|
stage.value = 'message'
|
||||||
|
signatureDataUrl.value = null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
function openLink() {
|
||||||
|
stage.value = 'signing'
|
||||||
|
}
|
||||||
|
|
||||||
|
function complete() {
|
||||||
|
if (signatureDataUrl.value) {
|
||||||
|
emit('complete', signatureDataUrl.value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearSignature() {
|
||||||
|
signatureCanvas.value?.clear()
|
||||||
|
signatureDataUrl.value = null
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Teleport to="body">
|
||||||
|
<div v-if="visible && task" class="dialog-mask" @click.self="emit('close')">
|
||||||
|
<section
|
||||||
|
class="dialog-card online-dialog"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby="online-title"
|
||||||
|
>
|
||||||
|
<header class="dialog-header">
|
||||||
|
<strong id="online-title">线上签署 · 患者手机模拟</strong>
|
||||||
|
<span>{{ task.patientName }} · {{ task.documentName }}</span>
|
||||||
|
<button type="button" class="dialog-close" aria-label="关闭" @click="emit('close')">
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="dialog-body">
|
||||||
|
<div class="phone-frame">
|
||||||
|
<div class="phone-screen">
|
||||||
|
<div class="phone-status"><span>9:41</span><span>5G ▮▮▮</span></div>
|
||||||
|
<div class="phone-header">{{ stage === 'message' ? '短信' : '患者签署' }}</div>
|
||||||
|
<div class="phone-body">
|
||||||
|
<template v-if="stage === 'message'">
|
||||||
|
<div class="message-bubble">
|
||||||
|
<strong>【xx医院】</strong>
|
||||||
|
<p>
|
||||||
|
尊敬的{{ task.patientName }},您有一份《{{
|
||||||
|
task.documentName
|
||||||
|
}}》待签署,请点击下方链接完成签署,链接 48 小时内有效。
|
||||||
|
</p>
|
||||||
|
<span class="sign-link">https://s.hosp.cn/s/{{ task.id }}</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<div class="mobile-document">
|
||||||
|
<strong>{{ task.documentName }}</strong>
|
||||||
|
<span>{{ task.patientName }} · {{ task.patientId }}</span>
|
||||||
|
<p>
|
||||||
|
本人已充分理解本检查/治疗的医疗风险、替代方案及可能的不良后果,自愿接受并签署确认。签名图片与文档原件一并存档。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="mobile-signature">
|
||||||
|
<span>请手写签名:</span>
|
||||||
|
<SignatureCanvas
|
||||||
|
ref="signatureCanvas"
|
||||||
|
:height="140"
|
||||||
|
placeholder="请在此签名"
|
||||||
|
@update:data-url="signatureDataUrl = $event"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
<div v-if="stage === 'signing'" class="phone-footer">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="phone-button phone-button--light"
|
||||||
|
@click="clearSignature"
|
||||||
|
>
|
||||||
|
清空
|
||||||
|
</button>
|
||||||
|
<button type="button" class="phone-button" @click="complete">确认签署</button>
|
||||||
|
</div>
|
||||||
|
<div v-else class="phone-footer phone-footer--hint">↑ 患者收到短信</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<footer class="dialog-footer">
|
||||||
|
<span>模拟患者收到短信 → 打开链接 → 查看文档 → 手写签署的全过程</span>
|
||||||
|
<span class="footer-spacer" />
|
||||||
|
<button type="button" class="action-button action-button--ghost" @click="emit('resend')">
|
||||||
|
重发短信
|
||||||
|
</button>
|
||||||
|
<button v-if="stage === 'message'" type="button" class="action-button" @click="openLink">
|
||||||
|
模拟打开链接
|
||||||
|
</button>
|
||||||
|
</footer>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</Teleport>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.dialog-mask {
|
||||||
|
position: fixed;
|
||||||
|
z-index: 50;
|
||||||
|
inset: 0;
|
||||||
|
display: flex;
|
||||||
|
padding: 16px;
|
||||||
|
overflow-y: auto;
|
||||||
|
background: rgb(9 40 52 / 45%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-card {
|
||||||
|
width: 680px;
|
||||||
|
max-width: 96vw;
|
||||||
|
max-height: calc(100vh - 32px);
|
||||||
|
margin: auto;
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 14px;
|
||||||
|
box-shadow: var(--sh-modal);
|
||||||
|
animation: dialog-pop 0.18s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.online-dialog {
|
||||||
|
width: 390px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-header {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
padding: 12px 18px;
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-header strong {
|
||||||
|
overflow: hidden;
|
||||||
|
font-size: 15px;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-header span {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-close {
|
||||||
|
flex-shrink: 0;
|
||||||
|
padding: 0;
|
||||||
|
margin-left: auto;
|
||||||
|
color: var(--mut);
|
||||||
|
font-size: 20px;
|
||||||
|
line-height: 1;
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-body {
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.phone-frame {
|
||||||
|
width: 330px;
|
||||||
|
padding: 12px 10px;
|
||||||
|
margin: 0 auto;
|
||||||
|
background: var(--side);
|
||||||
|
border-radius: 34px;
|
||||||
|
box-shadow: 0 10px 30px rgb(0 0 0 / 25%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.phone-screen {
|
||||||
|
display: flex;
|
||||||
|
height: 540px;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
background: #f4f7f9;
|
||||||
|
border-radius: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.phone-status {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 6px 14px;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 11px;
|
||||||
|
background: var(--brand);
|
||||||
|
}
|
||||||
|
|
||||||
|
.phone-header {
|
||||||
|
padding: 12px 14px;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 700;
|
||||||
|
text-align: center;
|
||||||
|
background: var(--brand);
|
||||||
|
}
|
||||||
|
|
||||||
|
.phone-body {
|
||||||
|
flex: 1;
|
||||||
|
padding: 12px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-bubble,
|
||||||
|
.mobile-document,
|
||||||
|
.mobile-signature {
|
||||||
|
padding: 11px 13px;
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 10px;
|
||||||
|
box-shadow: var(--sh);
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-bubble {
|
||||||
|
margin-top: 14px;
|
||||||
|
color: var(--ink);
|
||||||
|
font-size: 12.5px;
|
||||||
|
line-height: 1.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-bubble p {
|
||||||
|
margin: 3px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sign-link {
|
||||||
|
display: block;
|
||||||
|
color: var(--brand);
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-document {
|
||||||
|
color: var(--ink);
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.75;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-document strong,
|
||||||
|
.mobile-document span {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-document strong {
|
||||||
|
font-size: 13.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-document span {
|
||||||
|
margin: 6px 0;
|
||||||
|
color: var(--mut);
|
||||||
|
font-size: 11.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-document p {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-signature {
|
||||||
|
padding: 10px;
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-signature > span {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
color: var(--mut);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-signature :deep(.signature-canvas) {
|
||||||
|
border-style: dashed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.phone-footer {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
background: #fff;
|
||||||
|
border-top: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.phone-footer--hint {
|
||||||
|
justify-content: center;
|
||||||
|
color: var(--mut);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.phone-button {
|
||||||
|
flex: 1;
|
||||||
|
padding: 9px 0;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 13.5px;
|
||||||
|
font-weight: 700;
|
||||||
|
background: var(--brand);
|
||||||
|
border-radius: 22px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.phone-button--light {
|
||||||
|
color: var(--mut);
|
||||||
|
background: #eef2f4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-footer {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
align-items: center;
|
||||||
|
padding: 10px 18px;
|
||||||
|
color: var(--mut);
|
||||||
|
font-size: 12px;
|
||||||
|
background: #f8fbfc;
|
||||||
|
border-top: 1px solid var(--line);
|
||||||
|
border-radius: 0 0 14px 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-spacer {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-button {
|
||||||
|
flex-shrink: 0;
|
||||||
|
padding: 8px 16px;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
background: var(--brand);
|
||||||
|
border-radius: 7px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-button--ghost {
|
||||||
|
color: var(--brand);
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid var(--brand);
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-button--ghost:hover {
|
||||||
|
background: var(--brand-l);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes dialog-pop {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: scale(0.96) translateY(8px);
|
||||||
|
}
|
||||||
|
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,191 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||||
|
|
||||||
|
const props = withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
height?: number
|
||||||
|
placeholder?: string
|
||||||
|
}>(),
|
||||||
|
{
|
||||||
|
height: 300,
|
||||||
|
placeholder: '请 在 此 签 名',
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'update:dataUrl': [value: string | null]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const canvas = ref<HTMLCanvasElement | null>(null)
|
||||||
|
const hasInk = ref(false)
|
||||||
|
|
||||||
|
let context: CanvasRenderingContext2D | null = null
|
||||||
|
let pixelRatio = 1
|
||||||
|
let drawing = false
|
||||||
|
let resizeObserver: ResizeObserver | null = null
|
||||||
|
|
||||||
|
function setupCanvas() {
|
||||||
|
const element = canvas.value
|
||||||
|
|
||||||
|
if (!element) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const rect = element.getBoundingClientRect()
|
||||||
|
pixelRatio = Math.min(window.devicePixelRatio || 1, 2)
|
||||||
|
element.width = Math.max(Math.round(rect.width * pixelRatio), 1)
|
||||||
|
element.height = Math.max(Math.round(props.height * pixelRatio), 1)
|
||||||
|
|
||||||
|
context = element.getContext('2d')
|
||||||
|
|
||||||
|
if (!context) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
context.scale(pixelRatio, pixelRatio)
|
||||||
|
context.lineWidth = 4.2
|
||||||
|
context.lineCap = 'round'
|
||||||
|
context.lineJoin = 'round'
|
||||||
|
context.strokeStyle = '#123a5a'
|
||||||
|
hasInk.value = false
|
||||||
|
emit('update:dataUrl', null)
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPoint(event: PointerEvent) {
|
||||||
|
const element = canvas.value
|
||||||
|
|
||||||
|
if (!element) {
|
||||||
|
return { x: 0, y: 0 }
|
||||||
|
}
|
||||||
|
|
||||||
|
const rect = element.getBoundingClientRect()
|
||||||
|
return {
|
||||||
|
x: event.clientX - rect.left,
|
||||||
|
y: event.clientY - rect.top,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function startDrawing(event: PointerEvent) {
|
||||||
|
if (!context || !canvas.value) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const point = getPoint(event)
|
||||||
|
drawing = true
|
||||||
|
hasInk.value = true
|
||||||
|
context.beginPath()
|
||||||
|
context.moveTo(point.x, point.y)
|
||||||
|
canvas.value.setPointerCapture(event.pointerId)
|
||||||
|
event.preventDefault()
|
||||||
|
}
|
||||||
|
|
||||||
|
function draw(event: PointerEvent) {
|
||||||
|
if (!drawing || !context) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const point = getPoint(event)
|
||||||
|
context.lineTo(point.x, point.y)
|
||||||
|
context.stroke()
|
||||||
|
event.preventDefault()
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopDrawing() {
|
||||||
|
if (!drawing) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
drawing = false
|
||||||
|
emit('update:dataUrl', getDataUrl())
|
||||||
|
}
|
||||||
|
|
||||||
|
function clear() {
|
||||||
|
const element = canvas.value
|
||||||
|
|
||||||
|
if (!element || !context) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
context.clearRect(0, 0, element.width / pixelRatio, element.height / pixelRatio)
|
||||||
|
hasInk.value = false
|
||||||
|
emit('update:dataUrl', null)
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDataUrl() {
|
||||||
|
return canvas.value?.toDataURL('image/png') ?? null
|
||||||
|
}
|
||||||
|
|
||||||
|
defineExpose({
|
||||||
|
clear,
|
||||||
|
getDataUrl,
|
||||||
|
})
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await nextTick()
|
||||||
|
setupCanvas()
|
||||||
|
|
||||||
|
if (typeof ResizeObserver !== 'undefined' && canvas.value) {
|
||||||
|
resizeObserver = new ResizeObserver(() => {
|
||||||
|
if (!hasInk.value) {
|
||||||
|
setupCanvas()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
resizeObserver.observe(canvas.value)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.height,
|
||||||
|
() => {
|
||||||
|
void nextTick(setupCanvas)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
resizeObserver?.disconnect()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="signature-canvas" :style="{ height: `${height}px` }">
|
||||||
|
<canvas
|
||||||
|
ref="canvas"
|
||||||
|
@pointercancel="stopDrawing"
|
||||||
|
@pointerdown="startDrawing"
|
||||||
|
@pointerleave="stopDrawing"
|
||||||
|
@pointermove="draw"
|
||||||
|
@pointerup="stopDrawing"
|
||||||
|
/>
|
||||||
|
<span v-if="!hasInk" class="signature-placeholder">{{ placeholder }}</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.signature-canvas {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.signature-canvas canvas {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
touch-action: none;
|
||||||
|
cursor: crosshair;
|
||||||
|
}
|
||||||
|
|
||||||
|
.signature-placeholder {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
display: grid;
|
||||||
|
color: #c9d8de;
|
||||||
|
font-size: 15px;
|
||||||
|
letter-spacing: 2px;
|
||||||
|
pointer-events: none;
|
||||||
|
place-items: center;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,203 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { nextTick, ref, watch } from 'vue'
|
||||||
|
import { ElMessage } from 'element-plus'
|
||||||
|
|
||||||
|
import type { SigningTaskRecord } from '@/api/workbench/types'
|
||||||
|
|
||||||
|
import SignatureCanvas from './SignatureCanvas.vue'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
visible: boolean
|
||||||
|
task: SigningTaskRecord | null
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
close: []
|
||||||
|
confirm: [signatureDataUrl: string]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const signatureDataUrl = ref<string | null>(null)
|
||||||
|
const signatureCanvas = ref<{ clear: () => void } | null>(null)
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.visible,
|
||||||
|
async (visible) => {
|
||||||
|
if (visible) {
|
||||||
|
signatureDataUrl.value = null
|
||||||
|
await nextTick()
|
||||||
|
signatureCanvas.value?.clear()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
function clearSignature() {
|
||||||
|
signatureCanvas.value?.clear()
|
||||||
|
signatureDataUrl.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
function confirmSignature() {
|
||||||
|
if (!signatureDataUrl.value) {
|
||||||
|
ElMessage.warning('请先完成患者签名')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
emit('confirm', signatureDataUrl.value)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Teleport to="body">
|
||||||
|
<div v-if="visible && task" class="dialog-mask" @click.self="emit('close')">
|
||||||
|
<section
|
||||||
|
class="dialog-card signature-dialog"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby="pad-title"
|
||||||
|
>
|
||||||
|
<header class="dialog-header">
|
||||||
|
<strong id="pad-title">手写板签署</strong>
|
||||||
|
<span>{{ task.patientName }} · {{ task.documentName }}</span>
|
||||||
|
<button type="button" class="dialog-close" aria-label="关闭" @click="emit('close')">
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
<div class="dialog-body">
|
||||||
|
<p class="dialog-hint">
|
||||||
|
请患者在下框内签名(当前为 Canvas 演示,真实手写板接入走设备适配器):
|
||||||
|
</p>
|
||||||
|
<SignatureCanvas
|
||||||
|
ref="signatureCanvas"
|
||||||
|
:height="300"
|
||||||
|
@update:data-url="signatureDataUrl = $event"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<footer class="dialog-footer">
|
||||||
|
<span>签名将实时合成到文档签名域</span>
|
||||||
|
<span class="footer-spacer" />
|
||||||
|
<button type="button" class="action-button action-button--ghost" @click="clearSignature">
|
||||||
|
清空重写
|
||||||
|
</button>
|
||||||
|
<button type="button" class="action-button" @click="confirmSignature">确认签署</button>
|
||||||
|
</footer>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</Teleport>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.dialog-mask {
|
||||||
|
position: fixed;
|
||||||
|
z-index: 50;
|
||||||
|
inset: 0;
|
||||||
|
display: flex;
|
||||||
|
padding: 16px;
|
||||||
|
overflow-y: auto;
|
||||||
|
background: rgb(9 40 52 / 45%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-card {
|
||||||
|
width: 680px;
|
||||||
|
max-width: 96vw;
|
||||||
|
max-height: calc(100vh - 32px);
|
||||||
|
margin: auto;
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 14px;
|
||||||
|
box-shadow: var(--sh-modal);
|
||||||
|
animation: dialog-pop 0.18s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.signature-dialog {
|
||||||
|
width: 760px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-header {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
align-items: center;
|
||||||
|
padding: 12px 18px;
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-header strong {
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-header span {
|
||||||
|
overflow: hidden;
|
||||||
|
color: var(--mut);
|
||||||
|
font-size: 12px;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-close {
|
||||||
|
flex-shrink: 0;
|
||||||
|
padding: 0;
|
||||||
|
margin-left: auto;
|
||||||
|
color: var(--mut);
|
||||||
|
font-size: 20px;
|
||||||
|
line-height: 1;
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-body {
|
||||||
|
padding: 12px 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-hint {
|
||||||
|
margin: 0 0 8px;
|
||||||
|
color: var(--mut);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-footer {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
align-items: center;
|
||||||
|
padding: 10px 18px;
|
||||||
|
color: var(--mut);
|
||||||
|
font-size: 12px;
|
||||||
|
background: #f8fbfc;
|
||||||
|
border-top: 1px solid var(--line);
|
||||||
|
border-radius: 0 0 14px 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-spacer {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-button {
|
||||||
|
padding: 8px 16px;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
background: var(--brand);
|
||||||
|
border-radius: 7px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-button:hover {
|
||||||
|
background: var(--brand-d);
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-button--ghost {
|
||||||
|
color: var(--brand);
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid var(--brand);
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-button--ghost:hover {
|
||||||
|
background: var(--brand-l);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes dialog-pop {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: scale(0.96) translateY(8px);
|
||||||
|
}
|
||||||
|
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,192 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { SigningTaskRecord } from '@/api/workbench/types'
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
task: SigningTaskRecord
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<article class="paper-preview">
|
||||||
|
<div v-if="task.status === 'void'" class="void-stamp">已 作 废</div>
|
||||||
|
<h2>{{ task.documentName }}</h2>
|
||||||
|
<p class="paper-number">编号:{{ task.id }} · 就诊号:{{ task.visitNo }}</p>
|
||||||
|
|
||||||
|
<p class="paper-meta">
|
||||||
|
患者:<strong>{{ task.patientName }}</strong
|
||||||
|
>({{ task.sex }},{{ task.age }}岁) · 患者 ID:{{ task.patientId }}
|
||||||
|
</p>
|
||||||
|
<p class="paper-meta">
|
||||||
|
就诊信息:{{ task.visitType }} · {{ task.department }} · {{ task.visitDate }}
|
||||||
|
<span v-if="task.visitType === '住院'" class="current-badge">在院</span>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
医务人员已就本项检查/治疗的适应证、禁忌证、医疗风险、替代方案及可能出现的不良后果向本人(或授权委托人)充分说明,本人已完全理解上述内容。
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
本人自愿接受上述检查/治疗,并同意在相关医疗文书中签署确认。所签署文件将作为医疗过程留痕依据,签署过程采用电子方式完成,签名图片与文档原件一并存档。
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="notice-block">
|
||||||
|
<strong>签署须知:</strong>
|
||||||
|
请仔细阅读全部内容后签名;签名图片将合成至本文档签名域;文档与签名原图双留存,可随时查验;如对内容有疑问,请先向医务人员咨询。
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="signature-grid">
|
||||||
|
<div class="signature-field">
|
||||||
|
<span>患者 / 授权委托人签名:</span>
|
||||||
|
<div class="signature-box">
|
||||||
|
<img v-if="task.signatureDataUrl" :src="task.signatureDataUrl" alt="患者签名" />
|
||||||
|
<span v-else>{{ task.status === 'void' ? '—' : '未签署' }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="signature-field">
|
||||||
|
<span>日期:</span>
|
||||||
|
<div class="signature-box signature-box--date">
|
||||||
|
<span>{{ task.signedAt ?? '—' }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="signature-field">
|
||||||
|
<span>经治医师:</span>
|
||||||
|
<div class="signature-box signature-box--doctor">
|
||||||
|
<span>刘明辉</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.paper-preview {
|
||||||
|
position: relative;
|
||||||
|
min-height: 560px;
|
||||||
|
padding: 34px 44px;
|
||||||
|
color: var(--ink);
|
||||||
|
font-size: 13.5px;
|
||||||
|
line-height: 1.9;
|
||||||
|
background: #fff;
|
||||||
|
border-radius: var(--r);
|
||||||
|
box-shadow: var(--sh);
|
||||||
|
}
|
||||||
|
|
||||||
|
.paper-preview h2 {
|
||||||
|
margin: 0 0 6px;
|
||||||
|
font-size: 19px;
|
||||||
|
letter-spacing: 2px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.paper-number {
|
||||||
|
margin: 0 0 18px;
|
||||||
|
color: var(--mut);
|
||||||
|
font-size: 12px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.paper-preview p {
|
||||||
|
margin: 0 0 6px;
|
||||||
|
text-align: justify;
|
||||||
|
text-indent: 2em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.paper-preview .paper-meta {
|
||||||
|
color: var(--mut);
|
||||||
|
text-indent: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.paper-meta strong {
|
||||||
|
color: var(--ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
.current-badge {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 1px 7px;
|
||||||
|
margin-left: 4px;
|
||||||
|
color: var(--err);
|
||||||
|
font-size: 10px;
|
||||||
|
line-height: 1.5;
|
||||||
|
background: var(--err-l);
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notice-block {
|
||||||
|
padding: 10px 14px;
|
||||||
|
margin: 10px 0;
|
||||||
|
font-size: 12.5px;
|
||||||
|
background: #fafcfd;
|
||||||
|
border: 1px dashed var(--line);
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notice-block strong {
|
||||||
|
font-size: 12.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.signature-grid {
|
||||||
|
display: flex;
|
||||||
|
gap: 40px;
|
||||||
|
align-items: flex-end;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-top: 22px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.signature-field > span {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
color: var(--mut);
|
||||||
|
font-size: 12.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.signature-box {
|
||||||
|
display: flex;
|
||||||
|
min-width: 200px;
|
||||||
|
min-height: 64px;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-bottom: 2px solid var(--ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
.signature-box img {
|
||||||
|
max-width: 190px;
|
||||||
|
max-height: 60px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.signature-box span {
|
||||||
|
color: var(--mut);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.signature-box--date {
|
||||||
|
min-width: 150px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.signature-box--doctor {
|
||||||
|
min-width: 120px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.void-stamp {
|
||||||
|
position: absolute;
|
||||||
|
top: 34px;
|
||||||
|
right: 44px;
|
||||||
|
padding: 4px 12px;
|
||||||
|
color: rgb(208 83 79 / 70%);
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 4px;
|
||||||
|
border: 2.5px solid rgb(208 83 79 / 55%);
|
||||||
|
border-radius: 8px;
|
||||||
|
transform: rotate(-14deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 700px) {
|
||||||
|
.paper-preview {
|
||||||
|
padding: 26px 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.void-stamp {
|
||||||
|
top: 24px;
|
||||||
|
right: 24px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,198 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { reactive, watch } from 'vue'
|
||||||
|
|
||||||
|
import type { WorkbenchCampus, VisitType } from '@/api/workbench/types'
|
||||||
|
|
||||||
|
import type { SigningFilterForm, SigningFilterOption } from '../types'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
filter: SigningFilterForm
|
||||||
|
campuses: SigningFilterOption<WorkbenchCampus | 'all'>[]
|
||||||
|
statuses: SigningFilterOption<SigningFilterForm['status']>[]
|
||||||
|
methods: SigningFilterOption<SigningFilterForm['method']>[]
|
||||||
|
departments: SigningFilterOption[]
|
||||||
|
documents: SigningFilterOption[]
|
||||||
|
visitTypes: SigningFilterOption<VisitType | 'all'>[]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
search: [filter: SigningFilterForm]
|
||||||
|
add: []
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const draft = reactive<SigningFilterForm>({ ...props.filter })
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.filter,
|
||||||
|
(value) => {
|
||||||
|
Object.assign(draft, value)
|
||||||
|
},
|
||||||
|
{ deep: true },
|
||||||
|
)
|
||||||
|
|
||||||
|
function submit() {
|
||||||
|
emit('search', { ...draft })
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<form class="filter-card" @submit.prevent="submit">
|
||||||
|
<label class="filter-field">
|
||||||
|
<span>日期</span>
|
||||||
|
<select v-model="draft.dateRange" aria-label="筛选日期">
|
||||||
|
<option value="today">今日</option>
|
||||||
|
<option value="yesterday">昨日</option>
|
||||||
|
<option value="3d">近3天</option>
|
||||||
|
<option value="7d">近7天</option>
|
||||||
|
<option value="all">全部</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="filter-field">
|
||||||
|
<span>状态</span>
|
||||||
|
<select v-model="draft.status" aria-label="筛选状态">
|
||||||
|
<option v-for="option in statuses" :key="option.value" :value="option.value">
|
||||||
|
{{ option.label }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="filter-field">
|
||||||
|
<span>院区</span>
|
||||||
|
<select v-model="draft.campus" aria-label="筛选院区">
|
||||||
|
<option v-for="option in campuses" :key="option.value" :value="option.value">
|
||||||
|
{{ option.label }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="filter-field">
|
||||||
|
<span>方式</span>
|
||||||
|
<select v-model="draft.method" aria-label="筛选签署方式">
|
||||||
|
<option v-for="option in methods" :key="option.value" :value="option.value">
|
||||||
|
{{ option.label }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="filter-field">
|
||||||
|
<span>科室</span>
|
||||||
|
<select v-model="draft.department" aria-label="筛选科室">
|
||||||
|
<option v-for="option in departments" :key="option.value" :value="option.value">
|
||||||
|
{{ option.label }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="filter-field">
|
||||||
|
<span>就诊类型</span>
|
||||||
|
<select v-model="draft.visitType" aria-label="筛选就诊类型">
|
||||||
|
<option v-for="option in visitTypes" :key="option.value" :value="option.value">
|
||||||
|
{{ option.label }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="filter-field filter-field--document">
|
||||||
|
<span>文档</span>
|
||||||
|
<select v-model="draft.documentId" aria-label="筛选文档">
|
||||||
|
<option v-for="option in documents" :key="option.value" :value="option.value">
|
||||||
|
{{ option.label }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="filter-field filter-field--keyword">
|
||||||
|
<span>关键词</span>
|
||||||
|
<input v-model="draft.keyword" placeholder="患者姓名 / ID / 文档名" aria-label="关键词" />
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<span class="filter-spacer" />
|
||||||
|
<button type="submit" class="action-button action-button--ghost">查询</button>
|
||||||
|
<button type="button" class="action-button" @click="emit('add')">+ 新增签署任务</button>
|
||||||
|
</form>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.filter-card {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
padding: 14px 18px;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
background: var(--card);
|
||||||
|
border-radius: var(--r);
|
||||||
|
box-shadow: var(--sh);
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-field {
|
||||||
|
display: flex;
|
||||||
|
min-width: 104px;
|
||||||
|
gap: 6px;
|
||||||
|
align-items: center;
|
||||||
|
color: var(--mut);
|
||||||
|
font-size: 12.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-field select,
|
||||||
|
.filter-field input {
|
||||||
|
width: auto;
|
||||||
|
min-width: 82px;
|
||||||
|
padding: 7px 9px;
|
||||||
|
color: var(--ink);
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-field select:focus,
|
||||||
|
.filter-field input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--brand);
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-field--document select {
|
||||||
|
max-width: 150px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-field--keyword input {
|
||||||
|
width: 190px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-spacer {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-button {
|
||||||
|
flex-shrink: 0;
|
||||||
|
padding: 8px 16px;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
background: var(--brand);
|
||||||
|
border-radius: 7px;
|
||||||
|
transition: background 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-button:hover {
|
||||||
|
background: var(--brand-d);
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-button--ghost {
|
||||||
|
color: var(--brand);
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid var(--brand);
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-button--ghost:hover {
|
||||||
|
background: var(--brand-l);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1200px) {
|
||||||
|
.filter-spacer {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,233 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { SigningTaskRecord } from '@/api/workbench/types'
|
||||||
|
|
||||||
|
import SigningDocumentPreview from './SigningDocumentPreview.vue'
|
||||||
|
import TaskAuditTimeline from './TaskAuditTimeline.vue'
|
||||||
|
import type { SigningTaskAction } from '../types'
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
task: SigningTaskRecord | null
|
||||||
|
loading: boolean
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
action: [action: SigningTaskAction]
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section class="detail-panel">
|
||||||
|
<div v-if="loading" class="detail-loading" aria-label="正在加载任务详情">
|
||||||
|
<span class="detail-loading__actions" />
|
||||||
|
<span class="detail-loading__paper" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template v-else-if="task">
|
||||||
|
<div class="task-actions">
|
||||||
|
<span class="task-action-meta">任务 {{ task.id }} · {{ task.patientName }}</span>
|
||||||
|
|
||||||
|
<template v-if="task.status === 'pending' && task.method === 'pad'">
|
||||||
|
<button type="button" class="action-button" @click="emit('action', 'pad-sign')">
|
||||||
|
✍ 手写板签署
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="action-button action-button--ghost"
|
||||||
|
@click="emit('action', 'switch-method')"
|
||||||
|
>
|
||||||
|
转为短信发送
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-else-if="task.status === 'pending' && task.method === 'sms'">
|
||||||
|
<button type="button" class="action-button" @click="emit('action', 'online-sign')">
|
||||||
|
📱 查看线上签署
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="action-button action-button--ghost"
|
||||||
|
@click="emit('action', 'switch-method')"
|
||||||
|
>
|
||||||
|
转手写板签署
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="action-button action-button--ghost"
|
||||||
|
@click="emit('action', 'resend-sms')"
|
||||||
|
>
|
||||||
|
重发短信
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-else-if="task.status === 'expired'">
|
||||||
|
<button type="button" class="action-button" @click="emit('action', 'reopen')">
|
||||||
|
↻ 重新发起
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-else-if="task.status === 'signed'">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="action-button action-button--ghost"
|
||||||
|
@click="emit('action', 'download-pdf')"
|
||||||
|
>
|
||||||
|
下载 PDF 原件
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="action-button action-button--ghost"
|
||||||
|
@click="emit('action', 'download-signature')"
|
||||||
|
>
|
||||||
|
签名原图
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="action-button action-button--ghost"
|
||||||
|
@click="emit('action', 'print')"
|
||||||
|
>
|
||||||
|
打印
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<button
|
||||||
|
v-if="task.status === 'pending' || task.status === 'expired' || task.status === 'signing'"
|
||||||
|
type="button"
|
||||||
|
class="action-button action-button--danger"
|
||||||
|
@click="emit('action', 'void')"
|
||||||
|
>
|
||||||
|
作废
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<SigningDocumentPreview :task="task" />
|
||||||
|
<TaskAuditTimeline :task="task" />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<div v-else class="detail-empty">
|
||||||
|
<span aria-hidden="true">📋</span>
|
||||||
|
<p>请从左侧列表选择一个签署任务</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.detail-panel {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-action-meta {
|
||||||
|
align-self: center;
|
||||||
|
margin-right: auto;
|
||||||
|
color: var(--mut);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-button {
|
||||||
|
flex-shrink: 0;
|
||||||
|
padding: 8px 16px;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
background: var(--brand);
|
||||||
|
border-radius: 7px;
|
||||||
|
transition: background 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-button:hover {
|
||||||
|
background: var(--brand-d);
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-button--ghost {
|
||||||
|
color: var(--brand);
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid var(--brand);
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-button--ghost:hover {
|
||||||
|
background: var(--brand-l);
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-button--danger {
|
||||||
|
color: var(--err);
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid var(--err);
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-button--danger:hover {
|
||||||
|
background: var(--err-l);
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-empty {
|
||||||
|
display: grid;
|
||||||
|
min-height: 560px;
|
||||||
|
padding: 40px 24px;
|
||||||
|
color: var(--mut);
|
||||||
|
text-align: center;
|
||||||
|
background: var(--card);
|
||||||
|
border-radius: var(--r);
|
||||||
|
box-shadow: var(--sh);
|
||||||
|
place-items: center;
|
||||||
|
align-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-empty span {
|
||||||
|
margin-bottom: 8px;
|
||||||
|
font-size: 34px;
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-empty p {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-loading {
|
||||||
|
display: flex;
|
||||||
|
min-height: 680px;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-loading span {
|
||||||
|
display: block;
|
||||||
|
background: linear-gradient(90deg, #edf3f5 25%, #f7fafb 37%, #edf3f5 63%);
|
||||||
|
background-size: 400% 100%;
|
||||||
|
border-radius: var(--r);
|
||||||
|
box-shadow: var(--sh);
|
||||||
|
animation: skeleton-shimmer 1.4s ease infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-loading__actions {
|
||||||
|
height: 38px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-loading__paper {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 560px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes skeleton-shimmer {
|
||||||
|
0% {
|
||||||
|
background-position: 100% 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
100% {
|
||||||
|
background-position: -100% 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 700px) {
|
||||||
|
.task-action-meta {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,344 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { SigningTaskRecord } from '@/api/workbench/types'
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
tasks: SigningTaskRecord[]
|
||||||
|
selectedId: string | null
|
||||||
|
total: number
|
||||||
|
loading: boolean
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
select: [id: string]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const statusLabels: Record<SigningTaskRecord['status'], string> = {
|
||||||
|
pending: '待签署',
|
||||||
|
signing: '签署中',
|
||||||
|
signed: '已签署',
|
||||||
|
rejected: '已拒签',
|
||||||
|
expired: '已超时',
|
||||||
|
void: '已作废',
|
||||||
|
}
|
||||||
|
|
||||||
|
function getStatusClass(task: SigningTaskRecord) {
|
||||||
|
if (task.status === 'pending') {
|
||||||
|
return task.method === 'pad' ? 'task-status--pad' : 'task-status--sms'
|
||||||
|
}
|
||||||
|
|
||||||
|
return `task-status--${task.status}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function getMethodLabel(task: SigningTaskRecord) {
|
||||||
|
return task.method === 'pad' ? '✍ 手写板' : '📱 短信'
|
||||||
|
}
|
||||||
|
|
||||||
|
function getVisitClass(type: SigningTaskRecord['visitType']) {
|
||||||
|
return type === '住院'
|
||||||
|
? 'visit-tag--hospital'
|
||||||
|
: type === '体检'
|
||||||
|
? 'visit-tag--check'
|
||||||
|
: 'visit-tag--outpatient'
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section class="task-list-panel">
|
||||||
|
<div class="task-list-heading">
|
||||||
|
<strong>签署任务列表</strong>
|
||||||
|
<span>共 {{ total }} 条</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="loading" class="task-items task-items--loading" aria-label="正在加载任务">
|
||||||
|
<div v-for="index in 4" :key="index" class="task-skeleton">
|
||||||
|
<span class="task-skeleton__title" />
|
||||||
|
<span />
|
||||||
|
<span />
|
||||||
|
<span class="task-skeleton__short" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="tasks.length" class="task-items">
|
||||||
|
<button
|
||||||
|
v-for="task in tasks"
|
||||||
|
:key="task.id"
|
||||||
|
type="button"
|
||||||
|
class="task-item"
|
||||||
|
:class="{ 'task-item--active': selectedId === task.id }"
|
||||||
|
@click="emit('select', task.id)"
|
||||||
|
>
|
||||||
|
<span class="task-row">
|
||||||
|
<strong>{{ task.patientName }}</strong>
|
||||||
|
<span class="task-basic">{{ task.sex }} · {{ task.age }}岁</span>
|
||||||
|
<span class="task-spacer" />
|
||||||
|
<span class="task-status" :class="getStatusClass(task)">
|
||||||
|
{{
|
||||||
|
task.status === 'pending'
|
||||||
|
? task.method === 'pad'
|
||||||
|
? '待签署·手写板'
|
||||||
|
: '待签署·短信'
|
||||||
|
: statusLabels[task.status]
|
||||||
|
}}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<span class="task-row task-row--document">📄 {{ task.documentName }}</span>
|
||||||
|
<span class="task-row task-row--meta">
|
||||||
|
<span>{{ task.patientId }}</span>
|
||||||
|
<span>{{ task.updatedAt }}</span>
|
||||||
|
<span>{{ getMethodLabel(task) }}</span>
|
||||||
|
</span>
|
||||||
|
<span class="task-row task-row--visit">
|
||||||
|
<span class="visit-tag" :class="getVisitClass(task.visitType)">{{ task.visitType }}</span>
|
||||||
|
<span>{{ task.department }}</span>
|
||||||
|
<span>({{ task.visitNo }})</span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="task-empty">
|
||||||
|
<span aria-hidden="true">🔍</span>
|
||||||
|
<p>没有符合条件的任务</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.task-list-panel {
|
||||||
|
width: 400px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-list-heading {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 10px 14px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
background: var(--card);
|
||||||
|
border-radius: var(--r);
|
||||||
|
box-shadow: var(--sh);
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-list-heading strong {
|
||||||
|
font-size: 13.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-list-heading span {
|
||||||
|
margin-left: auto;
|
||||||
|
color: var(--mut);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-items {
|
||||||
|
max-height: calc(100vh - 270px);
|
||||||
|
padding-right: 3px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-item {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
padding: 11px 13px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
color: var(--ink);
|
||||||
|
text-align: left;
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 10px;
|
||||||
|
transition:
|
||||||
|
border-color 0.15s ease,
|
||||||
|
background 0.15s ease,
|
||||||
|
box-shadow 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-item:hover {
|
||||||
|
border-color: var(--brand);
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-item--active {
|
||||||
|
background: var(--brand-l);
|
||||||
|
border-color: var(--brand);
|
||||||
|
box-shadow: 0 0 0 1px var(--brand);
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-row {
|
||||||
|
display: flex;
|
||||||
|
min-width: 0;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-row strong {
|
||||||
|
flex-shrink: 0;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-basic,
|
||||||
|
.task-row--meta,
|
||||||
|
.task-row--visit {
|
||||||
|
color: var(--mut);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-spacer {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-status {
|
||||||
|
flex-shrink: 0;
|
||||||
|
padding: 2px 9px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
border-radius: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-status--pad {
|
||||||
|
color: var(--brand);
|
||||||
|
background: var(--brand-l);
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-status--sms {
|
||||||
|
color: #7a4dbb;
|
||||||
|
background: #f3ecfb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-status--signing {
|
||||||
|
color: var(--info);
|
||||||
|
background: var(--info-l);
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-status--signed {
|
||||||
|
color: var(--ok);
|
||||||
|
background: var(--ok-l);
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-status--rejected,
|
||||||
|
.task-status--expired {
|
||||||
|
color: var(--err);
|
||||||
|
background: var(--err-l);
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-status--void {
|
||||||
|
color: #8a9aa3;
|
||||||
|
background: #eceef0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-row--document {
|
||||||
|
margin-top: 5px;
|
||||||
|
overflow: hidden;
|
||||||
|
color: var(--ink);
|
||||||
|
font-size: 12.5px;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-row--meta {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-top: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-row--visit {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-top: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.visit-tag {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 1px 6px;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1.5;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.visit-tag--outpatient {
|
||||||
|
color: var(--ok);
|
||||||
|
background: var(--ok-l);
|
||||||
|
}
|
||||||
|
|
||||||
|
.visit-tag--hospital {
|
||||||
|
color: var(--err);
|
||||||
|
background: var(--err-l);
|
||||||
|
}
|
||||||
|
|
||||||
|
.visit-tag--check {
|
||||||
|
color: var(--warn);
|
||||||
|
background: var(--warn-l);
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-empty {
|
||||||
|
padding: 40px 0;
|
||||||
|
color: var(--mut);
|
||||||
|
text-align: center;
|
||||||
|
background: var(--card);
|
||||||
|
border-radius: var(--r);
|
||||||
|
box-shadow: var(--sh);
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-empty span {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
font-size: 34px;
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-empty p {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-items--loading {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-skeleton {
|
||||||
|
display: flex;
|
||||||
|
min-height: 144px;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 14px;
|
||||||
|
background: var(--card);
|
||||||
|
border-radius: var(--r);
|
||||||
|
box-shadow: var(--sh);
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-skeleton span {
|
||||||
|
display: block;
|
||||||
|
width: 88%;
|
||||||
|
height: 11px;
|
||||||
|
background: linear-gradient(90deg, #edf3f5 25%, #f7fafb 37%, #edf3f5 63%);
|
||||||
|
background-size: 400% 100%;
|
||||||
|
border-radius: 4px;
|
||||||
|
animation: skeleton-shimmer 1.4s ease infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-skeleton__title {
|
||||||
|
width: 65% !important;
|
||||||
|
height: 16px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-skeleton__short {
|
||||||
|
width: 48% !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes skeleton-shimmer {
|
||||||
|
0% {
|
||||||
|
background-position: 100% 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
100% {
|
||||||
|
background-position: -100% 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1200px) {
|
||||||
|
.task-list-panel {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-items {
|
||||||
|
max-height: 420px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
|
||||||
|
import type { SigningTaskRecord } from '@/api/workbench/types'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
task: SigningTaskRecord
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const events = computed(() => {
|
||||||
|
const result = [
|
||||||
|
{
|
||||||
|
time: props.task.updatedAt,
|
||||||
|
text: `张文静发起签署任务(${props.task.method === 'pad' ? '手写板' : '线上短信'})`,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
if (props.task.status === 'signing') {
|
||||||
|
result.push({ time: props.task.updatedAt, text: '已打开签名采集,等待患者完成签名' })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (props.task.signedAt) {
|
||||||
|
result.push({
|
||||||
|
time: props.task.signedAt,
|
||||||
|
text: `患者 ${props.task.patientName} 完成签名,系统自动合成并双留存`,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (props.task.status === 'void') {
|
||||||
|
result.push({
|
||||||
|
time: props.task.updatedAt,
|
||||||
|
text: `任务已作废${props.task.voidReason ? `,原因:${props.task.voidReason}` : ''},全程可审计`,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section class="audit-card">
|
||||||
|
<strong>操作留痕</strong>
|
||||||
|
<ol class="audit-list">
|
||||||
|
<li v-for="event in events" :key="`${event.time}-${event.text}`">
|
||||||
|
<time>{{ event.time }}</time>
|
||||||
|
<span>{{ event.text }}</span>
|
||||||
|
</li>
|
||||||
|
</ol>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.audit-card {
|
||||||
|
padding: 12px 16px;
|
||||||
|
margin-top: 14px;
|
||||||
|
background: var(--card);
|
||||||
|
border-radius: var(--r);
|
||||||
|
box-shadow: var(--sh);
|
||||||
|
}
|
||||||
|
|
||||||
|
.audit-card > strong {
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.audit-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 5px;
|
||||||
|
padding: 0;
|
||||||
|
margin: 6px 0 0;
|
||||||
|
color: var(--mut);
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.65;
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.audit-list li {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.audit-list time {
|
||||||
|
flex-shrink: 0;
|
||||||
|
color: var(--mut);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,227 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, watch } from 'vue'
|
||||||
|
|
||||||
|
import type { SigningTaskRecord } from '@/api/workbench/types'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
visible: boolean
|
||||||
|
task: SigningTaskRecord | null
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
close: []
|
||||||
|
confirm: [reason: string]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const reason = ref('患者信息录入有误')
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.visible,
|
||||||
|
(visible) => {
|
||||||
|
if (visible) {
|
||||||
|
reason.value = '患者信息录入有误'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Teleport to="body">
|
||||||
|
<div v-if="visible && task" class="dialog-mask" @click.self="emit('close')">
|
||||||
|
<section
|
||||||
|
class="dialog-card void-dialog"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby="void-title"
|
||||||
|
>
|
||||||
|
<header class="dialog-header">
|
||||||
|
<strong id="void-title">作废确认</strong>
|
||||||
|
<button type="button" class="dialog-close" aria-label="关闭" @click="emit('close')">
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
<div class="dialog-body">
|
||||||
|
<div class="void-summary">
|
||||||
|
<span class="void-icon" aria-hidden="true">⚠</span>
|
||||||
|
<div>
|
||||||
|
<p>
|
||||||
|
确定作废任务 <strong>{{ task.id }}</strong
|
||||||
|
>({{ task.documentName }})吗?
|
||||||
|
</p>
|
||||||
|
<span>作废后不可恢复,将记录操作人、时间与原因,全程留痕。</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<label class="reason-field">
|
||||||
|
<span>作废原因(必填)</span>
|
||||||
|
<select v-model="reason" aria-label="作废原因">
|
||||||
|
<option>患者信息录入有误</option>
|
||||||
|
<option>文档选择错误</option>
|
||||||
|
<option>患者拒绝签署</option>
|
||||||
|
<option>重复发起</option>
|
||||||
|
<option>其他</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<footer class="dialog-footer">
|
||||||
|
<span class="footer-spacer" />
|
||||||
|
<button type="button" class="action-button action-button--ghost" @click="emit('close')">
|
||||||
|
取消
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="action-button action-button--danger"
|
||||||
|
@click="emit('confirm', reason)"
|
||||||
|
>
|
||||||
|
确认作废
|
||||||
|
</button>
|
||||||
|
</footer>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</Teleport>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.dialog-mask {
|
||||||
|
position: fixed;
|
||||||
|
z-index: 50;
|
||||||
|
inset: 0;
|
||||||
|
display: flex;
|
||||||
|
padding: 16px;
|
||||||
|
overflow-y: auto;
|
||||||
|
background: rgb(9 40 52 / 45%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-card {
|
||||||
|
width: 440px;
|
||||||
|
max-width: 96vw;
|
||||||
|
margin: auto;
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 14px;
|
||||||
|
box-shadow: var(--sh-modal);
|
||||||
|
animation: dialog-pop 0.18s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 12px 18px;
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-header strong {
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-close {
|
||||||
|
padding: 0;
|
||||||
|
margin-left: auto;
|
||||||
|
color: var(--mut);
|
||||||
|
font-size: 20px;
|
||||||
|
line-height: 1;
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-body {
|
||||||
|
padding: 16px 18px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.void-summary {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.void-icon {
|
||||||
|
font-size: 30px;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.void-summary p {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--ink);
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.void-summary > div > span {
|
||||||
|
display: block;
|
||||||
|
margin-top: 6px;
|
||||||
|
color: var(--mut);
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reason-field {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
flex-direction: column;
|
||||||
|
margin-top: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reason-field span {
|
||||||
|
color: var(--mut);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reason-field select {
|
||||||
|
width: 100%;
|
||||||
|
padding: 7px 10px;
|
||||||
|
color: var(--ink);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-footer {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
align-items: center;
|
||||||
|
padding: 10px 18px;
|
||||||
|
background: #f8fbfc;
|
||||||
|
border-top: 1px solid var(--line);
|
||||||
|
border-radius: 0 0 14px 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-spacer {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-button {
|
||||||
|
padding: 8px 16px;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
background: var(--brand);
|
||||||
|
border-radius: 7px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-button--ghost {
|
||||||
|
color: var(--brand);
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid var(--brand);
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-button--ghost:hover {
|
||||||
|
background: var(--brand-l);
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-button--danger {
|
||||||
|
color: var(--err);
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid var(--err);
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-button--danger:hover {
|
||||||
|
background: var(--err-l);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes dialog-pop {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: scale(0.96) translateY(8px);
|
||||||
|
}
|
||||||
|
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,12 +1,520 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import PagePlaceholder from '@/components/PagePlaceholder.vue'
|
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
||||||
|
import { ElMessage } from 'element-plus'
|
||||||
|
import { useRoute } from 'vue-router'
|
||||||
|
|
||||||
|
import {
|
||||||
|
completeSigningTask,
|
||||||
|
getSigningTaskDetail,
|
||||||
|
getSigningTasks,
|
||||||
|
getSigningTemplates,
|
||||||
|
reopenSigningTask,
|
||||||
|
resendSigningSms,
|
||||||
|
updateSigningTaskMethod,
|
||||||
|
voidSigningTask,
|
||||||
|
} from '@/api/workbench/signing'
|
||||||
|
import type {
|
||||||
|
SigningDateRange,
|
||||||
|
SigningMethod,
|
||||||
|
SigningTaskRecord,
|
||||||
|
SigningTaskStatus,
|
||||||
|
SigningTemplate,
|
||||||
|
VisitType,
|
||||||
|
WorkbenchCampus,
|
||||||
|
} from '@/api/workbench/types'
|
||||||
|
import { useAppStore } from '@/stores/app'
|
||||||
|
|
||||||
|
import NewSigningTaskDialog from './components/NewSigningTaskDialog.vue'
|
||||||
|
import OnlineSigningDialog from './components/OnlineSigningDialog.vue'
|
||||||
|
import SignaturePadDialog from './components/SignaturePadDialog.vue'
|
||||||
|
import SigningFilterBar from './components/SigningFilterBar.vue'
|
||||||
|
import SigningTaskDetail from './components/SigningTaskDetail.vue'
|
||||||
|
import SigningTaskList from './components/SigningTaskList.vue'
|
||||||
|
import VoidTaskDialog from './components/VoidTaskDialog.vue'
|
||||||
|
import type { SigningFilterForm, SigningFilterOption, SigningTaskAction } from './types'
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const appStore = useAppStore()
|
||||||
|
|
||||||
|
const STATUS_VALUES: SigningTaskStatus[] = [
|
||||||
|
'pending',
|
||||||
|
'signing',
|
||||||
|
'signed',
|
||||||
|
'rejected',
|
||||||
|
'expired',
|
||||||
|
'void',
|
||||||
|
]
|
||||||
|
const DATE_RANGE_VALUES: SigningDateRange[] = ['today', 'yesterday', '3d', '7d', 'all']
|
||||||
|
|
||||||
|
const statusLabels: Record<SigningTaskStatus, string> = {
|
||||||
|
pending: '待签署',
|
||||||
|
signing: '签署中',
|
||||||
|
signed: '已签署',
|
||||||
|
rejected: '已拒签',
|
||||||
|
expired: '已超时',
|
||||||
|
void: '已作废',
|
||||||
|
}
|
||||||
|
|
||||||
|
const filter = reactive<SigningFilterForm>({
|
||||||
|
keyword: '',
|
||||||
|
status: 'all',
|
||||||
|
dateRange: 'all',
|
||||||
|
campus: 'all',
|
||||||
|
method: 'all',
|
||||||
|
department: '',
|
||||||
|
documentId: '',
|
||||||
|
visitType: 'all',
|
||||||
|
})
|
||||||
|
|
||||||
|
const tasks = ref<SigningTaskRecord[]>([])
|
||||||
|
const total = ref(0)
|
||||||
|
const selectedTaskId = ref<string | null>(null)
|
||||||
|
const selectedTask = ref<SigningTaskRecord | null>(null)
|
||||||
|
const templates = ref<SigningTemplate[]>([])
|
||||||
|
const loading = ref(true)
|
||||||
|
const detailLoading = ref(false)
|
||||||
|
const newDialogVisible = ref(false)
|
||||||
|
const signatureDialogVisible = ref(false)
|
||||||
|
const onlineDialogVisible = ref(false)
|
||||||
|
const voidDialogVisible = ref(false)
|
||||||
|
|
||||||
|
let listRequestId = 0
|
||||||
|
let detailRequestId = 0
|
||||||
|
|
||||||
|
const campusOptions = computed<SigningFilterOption<WorkbenchCampus | 'all'>[]>(() => [
|
||||||
|
{ label: '全部院区', value: 'all' },
|
||||||
|
...appStore.campuses.map((campus) => ({ label: campus, value: campus })),
|
||||||
|
])
|
||||||
|
|
||||||
|
const statusOptions: SigningFilterOption<SigningFilterForm['status']>[] = [
|
||||||
|
{ label: '全部状态', value: 'all' },
|
||||||
|
...STATUS_VALUES.map((status) => ({ label: statusLabels[status], value: status })),
|
||||||
|
]
|
||||||
|
|
||||||
|
const methodOptions: SigningFilterOption<SigningFilterForm['method']>[] = [
|
||||||
|
{ label: '全部方式', value: 'all' },
|
||||||
|
{ label: '手写板', value: 'pad' },
|
||||||
|
{ label: '短信线上', value: 'sms' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const visitTypeOptions: SigningFilterOption<VisitType | 'all'>[] = [
|
||||||
|
{ label: '全部类型', value: 'all' },
|
||||||
|
{ label: '门诊', value: '门诊' },
|
||||||
|
{ label: '住院', value: '住院' },
|
||||||
|
{ label: '体检', value: '体检' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const departmentOptions = computed<SigningFilterOption[]>(() => {
|
||||||
|
const departments = new Set([
|
||||||
|
...templates.value.map((template) => template.department),
|
||||||
|
...tasks.value.map((task) => task.department),
|
||||||
|
])
|
||||||
|
|
||||||
|
return [
|
||||||
|
{ label: '全部科室', value: '' },
|
||||||
|
...[...departments].sort().map((department) => ({ label: department, value: department })),
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
|
const documentOptions = computed<SigningFilterOption[]>(() => [
|
||||||
|
{ label: '全部文档', value: '' },
|
||||||
|
...templates.value.map((template) => ({ label: template.name, value: template.id })),
|
||||||
|
])
|
||||||
|
|
||||||
|
function getQueryString(value: unknown) {
|
||||||
|
return typeof value === 'string' ? value : ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSigningStatus(value: string): value is SigningTaskStatus {
|
||||||
|
return STATUS_VALUES.includes(value as SigningTaskStatus)
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSigningDateRange(value: string): value is SigningDateRange {
|
||||||
|
return DATE_RANGE_VALUES.includes(value as SigningDateRange)
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncFilterFromRoute() {
|
||||||
|
const status = getQueryString(route.query.status)
|
||||||
|
const range = getQueryString(route.query.range)
|
||||||
|
|
||||||
|
filter.status = status === 'all' || isSigningStatus(status) ? status : 'all'
|
||||||
|
filter.dateRange = isSigningDateRange(range) ? range : 'all'
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadTemplates() {
|
||||||
|
try {
|
||||||
|
templates.value = await getSigningTemplates()
|
||||||
|
} catch {
|
||||||
|
ElMessage.error('文档模板加载失败,请稍后重试')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadTaskDetail(id: string | null) {
|
||||||
|
const requestId = ++detailRequestId
|
||||||
|
|
||||||
|
if (!id) {
|
||||||
|
selectedTask.value = null
|
||||||
|
detailLoading.value = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
detailLoading.value = true
|
||||||
|
|
||||||
|
try {
|
||||||
|
const detail = await getSigningTaskDetail(id)
|
||||||
|
|
||||||
|
if (requestId === detailRequestId) {
|
||||||
|
selectedTask.value = detail
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
if (requestId === detailRequestId) {
|
||||||
|
selectedTask.value = null
|
||||||
|
ElMessage.error('签署任务详情加载失败,请稍后重试')
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (requestId === detailRequestId) {
|
||||||
|
detailLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadTasks(preferredTaskId?: string) {
|
||||||
|
const requestId = ++listRequestId
|
||||||
|
loading.value = true
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await getSigningTasks({
|
||||||
|
...filter,
|
||||||
|
page: 1,
|
||||||
|
pageSize: 50,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (requestId !== listRequestId) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
tasks.value = response.records
|
||||||
|
total.value = response.total
|
||||||
|
|
||||||
|
const taskIds = new Set(response.records.map((task) => task.id))
|
||||||
|
const routeTaskId = getQueryString(route.query.taskId)
|
||||||
|
const nextTaskId =
|
||||||
|
[preferredTaskId, selectedTaskId.value, routeTaskId].find((id) => id && taskIds.has(id)) ??
|
||||||
|
response.records[0]?.id ??
|
||||||
|
null
|
||||||
|
|
||||||
|
selectedTaskId.value = nextTaskId
|
||||||
|
await loadTaskDetail(nextTaskId)
|
||||||
|
} catch {
|
||||||
|
if (requestId === listRequestId) {
|
||||||
|
tasks.value = []
|
||||||
|
total.value = 0
|
||||||
|
selectedTaskId.value = null
|
||||||
|
selectedTask.value = null
|
||||||
|
ElMessage.error('签署任务加载失败,请稍后重试')
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (requestId === listRequestId) {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSearch(nextFilter: SigningFilterForm) {
|
||||||
|
Object.assign(filter, nextFilter)
|
||||||
|
selectedTaskId.value = null
|
||||||
|
void loadTasks()
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectTask(id: string) {
|
||||||
|
selectedTaskId.value = id
|
||||||
|
void loadTaskDetail(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshTask(updatedTask: SigningTaskRecord | null, successMessage: string) {
|
||||||
|
if (!updatedTask) {
|
||||||
|
ElMessage.error('签署任务不存在或已被删除')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
await loadTasks(updatedTask.id)
|
||||||
|
ElMessage.success(successMessage)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleTaskAction(action: SigningTaskAction) {
|
||||||
|
const task = selectedTask.value
|
||||||
|
|
||||||
|
if (!task) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (action === 'pad-sign') {
|
||||||
|
signatureDialogVisible.value = true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (action === 'online-sign') {
|
||||||
|
onlineDialogVisible.value = true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (action === 'void') {
|
||||||
|
voidDialogVisible.value = true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (action === 'download-pdf') {
|
||||||
|
ElMessage.info('原型演示:PDF 原件下载接口待接入')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (action === 'download-signature') {
|
||||||
|
ElMessage.info('原型演示:签名原图下载接口待接入')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (action === 'print') {
|
||||||
|
ElMessage.info('原型演示:打印服务待接入')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (action === 'switch-method') {
|
||||||
|
const method: SigningMethod = task.method === 'pad' ? 'sms' : 'pad'
|
||||||
|
await refreshTask(
|
||||||
|
await updateSigningTaskMethod(task.id, method),
|
||||||
|
method === 'sms' ? '已切换为短信线上签署' : '已切换为手写板签署',
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (action === 'resend-sms') {
|
||||||
|
await refreshTask(await resendSigningSms(task.id), '短信已重新发送')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (action === 'reopen') {
|
||||||
|
await refreshTask(await reopenSigningTask(task.id), '签署任务已重新发起')
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
ElMessage.error('任务操作失败,请稍后重试')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirmPadSignature(signatureDataUrl: string) {
|
||||||
|
const task = selectedTask.value
|
||||||
|
|
||||||
|
if (!task) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await refreshTask(await completeSigningTask(task.id, signatureDataUrl), '手写签署已完成')
|
||||||
|
signatureDialogVisible.value = false
|
||||||
|
} catch {
|
||||||
|
ElMessage.error('手写签署失败,请稍后重试')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function completeOnlineSignature(signatureDataUrl: string) {
|
||||||
|
const task = selectedTask.value
|
||||||
|
|
||||||
|
if (!task) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await refreshTask(await completeSigningTask(task.id, signatureDataUrl), '线上签署已完成')
|
||||||
|
onlineDialogVisible.value = false
|
||||||
|
} catch {
|
||||||
|
ElMessage.error('线上签署失败,请稍后重试')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirmVoidTask(reason: string) {
|
||||||
|
const task = selectedTask.value
|
||||||
|
|
||||||
|
if (!task) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await refreshTask(await voidSigningTask(task.id, reason), '签署任务已作废')
|
||||||
|
voidDialogVisible.value = false
|
||||||
|
} catch {
|
||||||
|
ElMessage.error('任务作废失败,请稍后重试')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleOnlineResend() {
|
||||||
|
await handleTaskAction('resend-sms')
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleTaskCreated(task: SigningTaskRecord) {
|
||||||
|
await loadTasks(task.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => [route.query.status, route.query.range, route.query.taskId],
|
||||||
|
() => {
|
||||||
|
syncFilterFromRoute()
|
||||||
|
selectedTaskId.value = null
|
||||||
|
void loadTasks()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => appStore.selectedCampus,
|
||||||
|
(campus) => {
|
||||||
|
if (filter.campus !== 'all') {
|
||||||
|
filter.campus = campus
|
||||||
|
}
|
||||||
|
void loadTasks()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
syncFilterFromRoute()
|
||||||
|
void Promise.all([loadTemplates(), loadTasks()])
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<PagePlaceholder
|
<section class="signing-page">
|
||||||
group="工作台"
|
<div class="page-intro">
|
||||||
title="签署工作台"
|
<div>
|
||||||
icon="✍"
|
<h1>签署任务</h1>
|
||||||
description="签署工作台页面占位,待根据 Web 端需求实现。"
|
<p>集中处理患者知情同意文书的发起、签署与全过程留痕。</p>
|
||||||
/>
|
</div>
|
||||||
|
<div class="page-intro__meta">
|
||||||
|
<span>当前院区:{{ appStore.selectedCampus }}</span>
|
||||||
|
<span>共 {{ total }} 条任务</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<SigningFilterBar
|
||||||
|
:filter="filter"
|
||||||
|
:campuses="campusOptions"
|
||||||
|
:statuses="statusOptions"
|
||||||
|
:methods="methodOptions"
|
||||||
|
:departments="departmentOptions"
|
||||||
|
:documents="documentOptions"
|
||||||
|
:visit-types="visitTypeOptions"
|
||||||
|
@add="newDialogVisible = true"
|
||||||
|
@search="handleSearch"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div class="task-workspace">
|
||||||
|
<SigningTaskList
|
||||||
|
:tasks="tasks"
|
||||||
|
:selected-id="selectedTaskId"
|
||||||
|
:total="total"
|
||||||
|
:loading="loading"
|
||||||
|
@select="selectTask"
|
||||||
|
/>
|
||||||
|
<SigningTaskDetail :task="selectedTask" :loading="detailLoading" @action="handleTaskAction" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<NewSigningTaskDialog
|
||||||
|
:visible="newDialogVisible"
|
||||||
|
@close="newDialogVisible = false"
|
||||||
|
@created="handleTaskCreated"
|
||||||
|
/>
|
||||||
|
<SignaturePadDialog
|
||||||
|
:visible="signatureDialogVisible"
|
||||||
|
:task="selectedTask"
|
||||||
|
@close="signatureDialogVisible = false"
|
||||||
|
@confirm="confirmPadSignature"
|
||||||
|
/>
|
||||||
|
<OnlineSigningDialog
|
||||||
|
:visible="onlineDialogVisible"
|
||||||
|
:task="selectedTask"
|
||||||
|
@close="onlineDialogVisible = false"
|
||||||
|
@complete="completeOnlineSignature"
|
||||||
|
@resend="handleOnlineResend"
|
||||||
|
/>
|
||||||
|
<VoidTaskDialog
|
||||||
|
:visible="voidDialogVisible"
|
||||||
|
:task="selectedTask"
|
||||||
|
@close="voidDialogVisible = false"
|
||||||
|
@confirm="confirmVoidTask"
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.signing-page {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 1600px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-intro {
|
||||||
|
display: flex;
|
||||||
|
gap: 20px;
|
||||||
|
align-items: flex-end;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-intro h1 {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--ink);
|
||||||
|
font-size: 19px;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-intro p {
|
||||||
|
margin: 4px 0 0;
|
||||||
|
color: var(--mut);
|
||||||
|
font-size: 12.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-intro__meta {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
color: var(--mut);
|
||||||
|
font-size: 12px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-intro__meta span {
|
||||||
|
padding: 5px 9px;
|
||||||
|
background: var(--card);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-workspace {
|
||||||
|
display: flex;
|
||||||
|
gap: 16px;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-workspace > * {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1200px) {
|
||||||
|
.task-workspace {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-workspace > * {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 680px) {
|
||||||
|
.page-intro {
|
||||||
|
align-items: flex-start;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-intro__meta {
|
||||||
|
white-space: normal;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -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 {
|
export interface SigningFilterForm {
|
||||||
keyword: string
|
keyword: string
|
||||||
status: SigningFilterStatus
|
status: SigningFilterStatus
|
||||||
dateRange: [string, string] | null
|
dateRange: SigningDateRange
|
||||||
|
campus: WorkbenchCampus | 'all'
|
||||||
|
method: SigningMethod | 'all'
|
||||||
|
department: string
|
||||||
|
documentId: string
|
||||||
|
visitType: VisitType | 'all'
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SigningTableRow {
|
export type SigningTableRow = SigningTaskRecord
|
||||||
id: string
|
|
||||||
patientName: string
|
export interface SigningFilterOption<T extends string = string> {
|
||||||
visitNo: string
|
label: string
|
||||||
documentName: string
|
value: T
|
||||||
department: string
|
|
||||||
signerName: string
|
|
||||||
status: SigningFilterStatus
|
|
||||||
updatedAt: string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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'
|
||||||
|
|||||||
Reference in New Issue
Block a user