1122 lines
30 KiB
TypeScript
1122 lines
30 KiB
TypeScript
import axios from 'axios'
|
||
|
||
import { unwrapApiResponse } from '@/utils/api-response'
|
||
import { request } from '@/utils/request'
|
||
import { resendSigningSms as resendSigningSmsDelivery } from './deliveries'
|
||
|
||
import type {
|
||
ApiPageResponse,
|
||
ApiResponseOf,
|
||
AvailableTemplateVersionResponseDto,
|
||
BackendSigningMethod,
|
||
BackendSigningTaskStatus,
|
||
CreateSigningTaskInput,
|
||
CreateSigningTaskRequest,
|
||
PatientProfile,
|
||
PatientResponseDto,
|
||
PatientSex,
|
||
SignTaskActionRequest,
|
||
SignTaskEventResponseDto,
|
||
SignTaskResponseDto,
|
||
SignTaskSendPreparationResponseDto,
|
||
SigningApiOptions,
|
||
SigningMethod,
|
||
SigningTaskEvent,
|
||
SigningTaskActionOptions,
|
||
SigningTaskListResponse,
|
||
SigningTaskQuery,
|
||
SigningTaskRecord,
|
||
SigningTaskStatus,
|
||
SigningTemplate,
|
||
VisitResponseDto,
|
||
VisitType,
|
||
} from './types'
|
||
|
||
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',
|
||
versionId: 'tpl-001-v2',
|
||
name: '住院患者知情同意书',
|
||
code: 'DOC-HOS-001',
|
||
version: 'V2.0',
|
||
department: '全院通用',
|
||
category: '住院告知',
|
||
description: '住院期间诊疗事项、风险与患者权利告知。',
|
||
supportedMethods: ['pad', 'sms'],
|
||
},
|
||
{
|
||
id: 'tpl-002',
|
||
versionId: 'tpl-002-v1',
|
||
name: '住院须知及告知书',
|
||
code: 'DOC-HOS-002',
|
||
version: 'V1.0',
|
||
department: '全院通用',
|
||
category: '住院告知',
|
||
description: '住院流程、陪护要求和患者须知确认。',
|
||
supportedMethods: ['pad', 'sms'],
|
||
},
|
||
{
|
||
id: 'tpl-003',
|
||
versionId: 'tpl-003-v3',
|
||
name: '内镜检查知情同意书',
|
||
code: 'DOC-DIG-001',
|
||
version: 'V3.0',
|
||
department: '消化内科',
|
||
category: '检查告知',
|
||
description: '内镜检查适应证、风险及替代方案告知。',
|
||
supportedMethods: ['pad', 'sms'],
|
||
},
|
||
{
|
||
id: 'tpl-004',
|
||
versionId: 'tpl-004-v1',
|
||
name: 'MRI 磁共振检查知情同意书',
|
||
code: 'DOC-RAD-001',
|
||
version: 'V1.0',
|
||
department: '放射科',
|
||
category: '检查告知',
|
||
description: '磁共振检查注意事项、禁忌证及风险告知。',
|
||
supportedMethods: ['pad', 'sms'],
|
||
},
|
||
{
|
||
id: 'tpl-005',
|
||
versionId: 'tpl-005-v2',
|
||
name: '心脏介入手术知情同意书',
|
||
code: 'DOC-CARD-001',
|
||
version: 'V2.0',
|
||
department: '心内科',
|
||
category: '手术告知',
|
||
description: '介入治疗方案、围术期风险及替代方案告知。',
|
||
supportedMethods: ['pad'],
|
||
},
|
||
{
|
||
id: 'tpl-006',
|
||
versionId: 'tpl-006-v1',
|
||
name: '体检报告发放及知情同意书',
|
||
code: 'DOC-PE-001',
|
||
version: 'V1.0',
|
||
department: '健康管理中心',
|
||
category: '体检告知',
|
||
description: '体检报告领取方式、隐私保护和异常结果随访告知。',
|
||
supportedMethods: ['pad', 'sms'],
|
||
},
|
||
{
|
||
id: 'tpl-007',
|
||
versionId: 'tpl-007-v1',
|
||
name: '患者授权委托书',
|
||
code: 'DOC-GEN-003',
|
||
version: 'V1.0',
|
||
department: '医务处',
|
||
category: '授权文书',
|
||
description: '患者授权家属或代理人办理相关医疗事项。',
|
||
supportedMethods: ['pad', 'sms'],
|
||
},
|
||
]
|
||
|
||
const mockRecords: SigningTaskRecord[] = [
|
||
{
|
||
id: 'task-001',
|
||
templateVersionId: 'tpl-001-v2',
|
||
campus: '本部院区',
|
||
patientId: 'P202610001',
|
||
patientName: '李某某',
|
||
sex: '男',
|
||
age: 46,
|
||
visitNo: 'ZY20260827001',
|
||
visitType: '住院',
|
||
visitDate: '2026-08-27',
|
||
documentId: 'tpl-001',
|
||
documentName: '住院患者知情同意书',
|
||
department: '消化内科',
|
||
status: 'pending',
|
||
signerName: '李某某',
|
||
method: 'pad',
|
||
source: '电子病历',
|
||
expiresAt: '2026-08-30 09:30',
|
||
updatedAt: '2026-08-27 09:30',
|
||
},
|
||
{
|
||
id: 'task-002',
|
||
templateVersionId: 'tpl-004-v1',
|
||
campus: '东院区',
|
||
patientId: 'P202610002',
|
||
patientName: '王某某',
|
||
sex: '女',
|
||
age: 37,
|
||
visitNo: 'JZ20260827018',
|
||
visitType: '门诊',
|
||
visitDate: '2026-08-27',
|
||
documentId: 'tpl-004',
|
||
documentName: 'MRI 磁共振检查知情同意书',
|
||
department: '放射科',
|
||
status: 'signed',
|
||
signerName: '王某某家属',
|
||
method: 'sms',
|
||
source: '工作台发起',
|
||
expiresAt: '2026-08-29 18:00',
|
||
updatedAt: '2026-08-27 09:12',
|
||
signedAt: '2026-08-27 09:12',
|
||
},
|
||
{
|
||
id: 'task-003',
|
||
templateVersionId: 'tpl-003-v3',
|
||
campus: '本部院区',
|
||
patientId: 'P202610003',
|
||
patientName: '赵某某',
|
||
sex: '男',
|
||
age: 52,
|
||
visitNo: 'ZY20260826027',
|
||
visitType: '住院',
|
||
visitDate: '2026-08-26',
|
||
documentId: 'tpl-003',
|
||
documentName: '内镜检查知情同意书',
|
||
department: '消化内科',
|
||
status: 'expired',
|
||
signerName: '赵某某家属',
|
||
method: 'sms',
|
||
source: '电子病历',
|
||
expiresAt: '2026-08-27 16:00',
|
||
updatedAt: '2026-08-27 08:45',
|
||
},
|
||
{
|
||
id: 'task-004',
|
||
templateVersionId: 'tpl-002-v1',
|
||
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',
|
||
templateVersionId: 'tpl-006-v1',
|
||
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 const isSigningMockEnabled = useMockData
|
||
|
||
function isNotFoundError(error: unknown) {
|
||
return axios.isAxiosError(error) && error.response?.status === 404
|
||
}
|
||
|
||
function formatApiDateTime(value: string | null | undefined) {
|
||
if (!value) {
|
||
return ''
|
||
}
|
||
|
||
const date = new Date(value)
|
||
|
||
if (Number.isNaN(date.getTime())) {
|
||
return value
|
||
.replace('T', ' ')
|
||
.replace(/\.\d+Z$/, '')
|
||
.replace(/Z$/, '')
|
||
}
|
||
|
||
const pad = (part: number) => String(part).padStart(2, '0')
|
||
|
||
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(
|
||
date.getHours(),
|
||
)}:${pad(date.getMinutes())}`
|
||
}
|
||
|
||
function formatApiDate(value: string | null | undefined) {
|
||
return formatApiDateTime(value).slice(0, 10)
|
||
}
|
||
|
||
function normalizePatientSex(value: string): PatientSex {
|
||
if (value === 'MALE' || value === '男') {
|
||
return '男'
|
||
}
|
||
|
||
if (value === 'FEMALE' || value === '女') {
|
||
return '女'
|
||
}
|
||
|
||
return '未知'
|
||
}
|
||
|
||
function normalizeVisitType(value: string): VisitType {
|
||
if (value === 'OUTPATIENT' || value === '门诊') {
|
||
return '门诊'
|
||
}
|
||
|
||
if (value === 'INPATIENT' || value === '住院') {
|
||
return '住院'
|
||
}
|
||
|
||
if (value === 'CHECKUP' || value === '体检') {
|
||
return '体检'
|
||
}
|
||
|
||
return '其他'
|
||
}
|
||
|
||
function normalizeSigningMethod(value: BackendSigningMethod): SigningMethod {
|
||
return value === 'SMS' ? 'sms' : 'pad'
|
||
}
|
||
|
||
function normalizeTaskStatus(value: BackendSigningTaskStatus): SigningTaskStatus {
|
||
switch (value) {
|
||
case 'SIGNED':
|
||
return 'signed'
|
||
case 'EXPIRED':
|
||
return 'expired'
|
||
case 'VOIDED':
|
||
return 'void'
|
||
case 'GENERATING':
|
||
return 'signing'
|
||
case 'FAILED':
|
||
return 'failed'
|
||
case 'CREATED':
|
||
case 'WAITING_SIGN':
|
||
default:
|
||
return 'pending'
|
||
}
|
||
}
|
||
|
||
function mapPatient(dto: PatientResponseDto, visits: PatientProfile['visits']): PatientProfile {
|
||
return {
|
||
id: dto.id,
|
||
name: dto.name,
|
||
sex: normalizePatientSex(dto.sex),
|
||
age: dto.age,
|
||
idCard: dto.idCardMasked ?? '',
|
||
phone: dto.phoneMasked ?? '',
|
||
visits,
|
||
}
|
||
}
|
||
|
||
function mapVisit(dto: VisitResponseDto): PatientProfile['visits'][number] {
|
||
return {
|
||
id: dto.id,
|
||
type: normalizeVisitType(dto.visitType),
|
||
visitNo: dto.visitNo,
|
||
department: dto.departmentName || '未指定科室',
|
||
departmentId: dto.departmentId,
|
||
doctorName: dto.doctorName ?? undefined,
|
||
visitDate: formatApiDate(dto.visitedAt),
|
||
visitedAt: dto.visitedAt,
|
||
campusId: dto.campusId,
|
||
sourceSystem: dto.sourceSystem,
|
||
}
|
||
}
|
||
|
||
function mapAvailableTemplate(dto: AvailableTemplateVersionResponseDto): SigningTemplate {
|
||
return {
|
||
id: dto.templateId,
|
||
versionId: dto.templateVersionId,
|
||
name: dto.templateName,
|
||
code: dto.templateCode,
|
||
version: dto.versionNo,
|
||
department: dto.departmentId ? '指定科室' : '全院通用',
|
||
departmentId: dto.departmentId,
|
||
campusId: dto.campusId,
|
||
category: dto.category || '未分类',
|
||
description: `已发布版本 ${dto.versionNo}`,
|
||
// 可用模板接口没有返回通道 ACL;具体通道仍由后端在创建时校验。
|
||
supportedMethods: ['pad', 'sms'],
|
||
}
|
||
}
|
||
|
||
function findTemplate(templates: SigningTemplate[] | undefined, versionId: string) {
|
||
return templates?.find((template) => template.versionId === versionId)
|
||
}
|
||
|
||
function mapSigningTask(
|
||
dto: SignTaskResponseDto,
|
||
options: SigningApiOptions = {},
|
||
): SigningTaskRecord {
|
||
const template = findTemplate(options.templates, dto.templateVersionId)
|
||
|
||
return {
|
||
id: dto.id,
|
||
templateVersionId: dto.templateVersionId,
|
||
campus: options.campusNames?.[dto.campusId] ?? options.campus ?? '本部院区',
|
||
campusId: dto.campusId,
|
||
patientId: dto.patientSnapshot.patientId,
|
||
patientName: dto.patientSnapshot.name,
|
||
sex: normalizePatientSex(dto.patientSnapshot.sex),
|
||
age: dto.patientSnapshot.age,
|
||
visitNo: dto.visitSnapshot.visitNo,
|
||
visitType: normalizeVisitType(dto.visitSnapshot.visitType),
|
||
visitDate: formatApiDate(dto.visitSnapshot.visitedAt),
|
||
documentId: template?.id ?? dto.templateVersionId,
|
||
documentName: template?.name ?? `模板版本 ${dto.templateVersionNumber}`,
|
||
department: dto.visitSnapshot.departmentName || '未指定科室',
|
||
departmentId: dto.departmentId,
|
||
signerName: dto.patientSnapshot.name,
|
||
method: normalizeSigningMethod(dto.signMethod),
|
||
status: normalizeTaskStatus(dto.status),
|
||
source: dto.visitSnapshot.sourceSystem || dto.patientSnapshot.sourceSystem || '—',
|
||
expiresAt: formatApiDateTime(dto.expiredAt),
|
||
createdAt: formatApiDateTime(dto.createdAt),
|
||
updatedAt: formatApiDateTime(dto.updatedAt),
|
||
rowVersion: dto.rowVersion,
|
||
backendStatus: dto.status,
|
||
signedAt: formatApiDateTime(dto.signedAt) || undefined,
|
||
voidReason: dto.voidReason ?? undefined,
|
||
}
|
||
}
|
||
|
||
function createTemplateFromInput(input: CreateSigningTaskInput): SigningTemplate {
|
||
return {
|
||
id: input.documentId,
|
||
versionId: input.templateVersionId,
|
||
name: input.documentName,
|
||
code: input.documentId,
|
||
version: '—',
|
||
department: input.department,
|
||
category: '—',
|
||
description: '',
|
||
supportedMethods: ['pad', 'sms'],
|
||
}
|
||
}
|
||
|
||
function toBackendTaskQuery(query: SigningTaskQuery, options: SigningApiOptions = {}) {
|
||
const params: Record<string, string | number> = {
|
||
page: Math.max(query.page, 1),
|
||
size: Math.min(Math.max(query.pageSize, 1), 200),
|
||
}
|
||
|
||
if (query.keyword?.trim()) {
|
||
params.keyword = query.keyword.trim()
|
||
}
|
||
|
||
if (query.status && query.status !== 'all') {
|
||
const statusMap: Partial<Record<SigningTaskStatus, BackendSigningTaskStatus>> = {
|
||
pending: 'WAITING_SIGN',
|
||
signing: 'GENERATING',
|
||
signed: 'SIGNED',
|
||
rejected: 'FAILED',
|
||
expired: 'EXPIRED',
|
||
void: 'VOIDED',
|
||
failed: 'FAILED',
|
||
}
|
||
const status = statusMap[query.status]
|
||
|
||
if (status) {
|
||
params.status = status
|
||
}
|
||
}
|
||
|
||
if (query.method && query.method !== 'all') {
|
||
params.signMethod = query.method === 'sms' ? 'SMS' : 'PAD'
|
||
}
|
||
|
||
if (query.documentId) {
|
||
params.templateVersionId =
|
||
findTemplate(options.templates, query.documentId)?.versionId ?? query.documentId
|
||
}
|
||
|
||
const now = new Date()
|
||
const start = new Date(now)
|
||
const end = new Date(now)
|
||
start.setHours(0, 0, 0, 0)
|
||
end.setHours(23, 59, 59, 999)
|
||
|
||
if (query.dateRange === 'yesterday') {
|
||
start.setDate(start.getDate() - 1)
|
||
end.setDate(end.getDate() - 1)
|
||
} else if (query.dateRange === '3d') {
|
||
start.setDate(start.getDate() - 2)
|
||
} else if (query.dateRange === '7d') {
|
||
start.setDate(start.getDate() - 6)
|
||
}
|
||
|
||
if (query.dateRange && query.dateRange !== 'all') {
|
||
params.createdFrom = start.toISOString()
|
||
params.createdTo = end.toISOString()
|
||
}
|
||
|
||
if (query.campus && query.campus !== 'all' && options.campusId) {
|
||
params.campusId = options.campusId
|
||
}
|
||
|
||
if (query.department && options.departmentIds?.[query.department]) {
|
||
params.departmentId = options.departmentIds[query.department]
|
||
}
|
||
|
||
return params
|
||
}
|
||
|
||
function createIdempotencyKey() {
|
||
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
|
||
return `clinical-web-${crypto.randomUUID()}`
|
||
}
|
||
|
||
return `clinical-web-${Date.now()}-${Math.random().toString(36).slice(2)}`
|
||
}
|
||
|
||
function getTaskActionBody(expectedRowVersion?: number): SignTaskActionRequest {
|
||
return expectedRowVersion === undefined ? {} : { expectedRowVersion }
|
||
}
|
||
|
||
function mapEventText(event: SignTaskEventResponseDto) {
|
||
const actor = event.actorSubject || '系统'
|
||
const channel = event.channel === 'SMS' ? '短信' : event.channel === 'PAD' ? '手写板' : ''
|
||
|
||
switch (event.eventType) {
|
||
case 'TASK_CREATED':
|
||
return `${actor}创建签署任务${channel ? `(${channel})` : ''}`
|
||
case 'SEND_PREPARED':
|
||
return `${actor}准备${channel || '签署'}发送`
|
||
case 'VOIDED':
|
||
return `任务已作废${event.reason ? `,原因:${event.reason}` : ''}`
|
||
case 'RESENT':
|
||
case 'RESEND_PREPARED':
|
||
return `${actor}重新发送${channel || '签署'}请求`
|
||
case 'REOPENED':
|
||
return `${actor}重新开启签署任务`
|
||
case 'SIGNED':
|
||
return `${actor}完成签名`
|
||
default:
|
||
return `${actor}:${event.eventType}`
|
||
}
|
||
}
|
||
|
||
function mapSigningEvent(event: SignTaskEventResponseDto): SigningTaskEvent {
|
||
return {
|
||
id: event.id,
|
||
time: formatApiDateTime(event.eventTime),
|
||
text: mapEventText(event),
|
||
eventType: event.eventType,
|
||
actorSubject: event.actorSubject ?? undefined,
|
||
reason: event.reason ?? undefined,
|
||
channel: event.channel ?? undefined,
|
||
attemptNo: event.attemptNo ?? undefined,
|
||
}
|
||
}
|
||
|
||
export async function getSigningTasks(
|
||
query: SigningTaskQuery,
|
||
options: SigningApiOptions = {},
|
||
): Promise<SigningTaskListResponse> {
|
||
if (!useMockData) {
|
||
const response = await request.get<ApiResponseOf<ApiPageResponse<SignTaskResponseDto>>>(
|
||
'/v1/sign-tasks',
|
||
{ params: toBackendTaskQuery(query, options) },
|
||
)
|
||
const page = unwrapApiResponse(response)
|
||
|
||
return {
|
||
records: page.records.map((task) => mapSigningTask(task, options)),
|
||
total: page.total,
|
||
page: page.page,
|
||
pageSize: page.size,
|
||
}
|
||
}
|
||
|
||
const keyword = query.keyword?.trim().toLowerCase()
|
||
const rangeStart = getRangeStart(query.dateRange)
|
||
const filteredRecords = mockRecords
|
||
.filter((record) => {
|
||
const matchesKeyword =
|
||
!keyword ||
|
||
[record.patientName, record.patientId, record.visitNo, record.documentName, record.id]
|
||
.join(' ')
|
||
.toLowerCase()
|
||
.includes(keyword)
|
||
const matchesStatus =
|
||
!query.status || query.status === 'all' || record.status === query.status
|
||
const matchesCampus =
|
||
!query.campus || query.campus === 'all' || record.campus === query.campus
|
||
const matchesMethod =
|
||
!query.method || query.method === 'all' || record.method === query.method
|
||
const matchesDepartment = !query.department || record.department === query.department
|
||
const matchesDocument = !query.documentId || record.documentId === query.documentId
|
||
const matchesVisitType =
|
||
!query.visitType || query.visitType === 'all' || record.visitType === query.visitType
|
||
const matchesDate = !rangeStart || parseMockDate(record.updatedAt) >= rangeStart
|
||
|
||
return (
|
||
matchesKeyword &&
|
||
matchesStatus &&
|
||
matchesCampus &&
|
||
matchesMethod &&
|
||
matchesDepartment &&
|
||
matchesDocument &&
|
||
matchesVisitType &&
|
||
matchesDate
|
||
)
|
||
})
|
||
.sort(
|
||
(left, right) =>
|
||
parseMockDate(right.updatedAt).getTime() - parseMockDate(left.updatedAt).getTime(),
|
||
)
|
||
|
||
const page = Math.max(query.page, 1)
|
||
const pageSize = Math.max(query.pageSize, 1)
|
||
const start = (page - 1) * pageSize
|
||
|
||
return Promise.resolve({
|
||
records: filteredRecords.slice(start, start + pageSize).map(cloneTask),
|
||
total: filteredRecords.length,
|
||
page,
|
||
pageSize,
|
||
})
|
||
}
|
||
|
||
export async function getSigningTaskDetail(
|
||
id: string,
|
||
options: SigningApiOptions = {},
|
||
): Promise<SigningTaskRecord | null> {
|
||
if (!useMockData) {
|
||
try {
|
||
const response = await request.get<ApiResponseOf<SignTaskResponseDto>>(
|
||
`/v1/sign-tasks/${encodeURIComponent(id)}`,
|
||
)
|
||
return mapSigningTask(unwrapApiResponse(response), options)
|
||
} catch (error) {
|
||
if (isNotFoundError(error)) {
|
||
return null
|
||
}
|
||
|
||
throw error
|
||
}
|
||
}
|
||
|
||
return Promise.resolve(mockRecords.find((record) => record.id === id) ?? null).then((task) =>
|
||
task ? cloneTask(task) : null,
|
||
)
|
||
}
|
||
|
||
async function getPatientVisits(patientId: string): Promise<PatientProfile['visits']> {
|
||
const response = await request.get<ApiResponseOf<ApiPageResponse<VisitResponseDto>>>(
|
||
`/v1/patients/${encodeURIComponent(patientId)}/visits`,
|
||
{ params: { page: 1, size: 200 } },
|
||
)
|
||
const page = unwrapApiResponse(response)
|
||
|
||
return page.records.map(mapVisit)
|
||
}
|
||
|
||
async function getPatientById(id: string): Promise<PatientProfile | null> {
|
||
try {
|
||
const [patientResponse, visits] = await Promise.all([
|
||
request.get<ApiResponseOf<PatientResponseDto>>(`/v1/patients/${encodeURIComponent(id)}`),
|
||
getPatientVisits(id),
|
||
])
|
||
|
||
return mapPatient(unwrapApiResponse(patientResponse), visits)
|
||
} catch (error) {
|
||
if (isNotFoundError(error)) {
|
||
return null
|
||
}
|
||
|
||
throw error
|
||
}
|
||
}
|
||
|
||
async function getPatientByVisitNo(visitNo: string): Promise<PatientProfile | null> {
|
||
const response = await request.get<ApiResponseOf<ApiPageResponse<VisitResponseDto>>>(
|
||
'/v1/visits',
|
||
{
|
||
params: { page: 1, size: 20, visitNo },
|
||
},
|
||
)
|
||
const page = unwrapApiResponse(response)
|
||
const visit = page.records[0]
|
||
|
||
return visit ? getPatientById(visit.patientId) : null
|
||
}
|
||
|
||
function isUuid(value: string) {
|
||
return /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(value)
|
||
}
|
||
|
||
export async function getPatientProfile(keyword: string): Promise<PatientProfile | null> {
|
||
if (!useMockData) {
|
||
const normalizedKeyword = keyword.trim()
|
||
|
||
if (isUuid(normalizedKeyword)) {
|
||
return getPatientById(normalizedKeyword)
|
||
}
|
||
|
||
const patientResponse = await request.get<ApiResponseOf<ApiPageResponse<PatientResponseDto>>>(
|
||
'/v1/patients',
|
||
{
|
||
params: { page: 1, size: 20, keyword: normalizedKeyword },
|
||
},
|
||
)
|
||
const patients = unwrapApiResponse(patientResponse).records
|
||
const matchedPatient = patients.find(
|
||
(patient) =>
|
||
patient.externalPatientId === normalizedKeyword || patient.name === normalizedKeyword,
|
||
)
|
||
|
||
if (matchedPatient) {
|
||
return getPatientById(matchedPatient.id)
|
||
}
|
||
|
||
if (patients.length) {
|
||
return getPatientById(patients[0].id)
|
||
}
|
||
|
||
return getPatientByVisitNo(normalizedKeyword)
|
||
}
|
||
|
||
const normalizedKeyword = keyword.trim()
|
||
const patient = mockPatients.find(
|
||
(item) =>
|
||
item.id === normalizedKeyword ||
|
||
item.visits.some((visit) => visit.visitNo === normalizedKeyword),
|
||
)
|
||
|
||
return Promise.resolve(patient ? clonePatient(patient) : null)
|
||
}
|
||
|
||
export async function getSigningTemplates(
|
||
query: {
|
||
campusId?: string
|
||
departmentId?: string
|
||
} = {},
|
||
): Promise<SigningTemplate[]> {
|
||
if (!useMockData) {
|
||
const response = await request.get<
|
||
ApiResponseOf<
|
||
AvailableTemplateVersionResponseDto[] | ApiPageResponse<AvailableTemplateVersionResponseDto>
|
||
>
|
||
>('/v1/templates/available-versions', { params: query })
|
||
|
||
const data = unwrapApiResponse(response)
|
||
const records = Array.isArray(data) ? data : data.records
|
||
return records.map(mapAvailableTemplate)
|
||
}
|
||
|
||
return Promise.resolve(mockTemplates.map((template) => ({ ...template })))
|
||
}
|
||
|
||
export async function prepareSigningTask(
|
||
id: string,
|
||
expectedRowVersion?: number,
|
||
): Promise<SignTaskSendPreparationResponseDto> {
|
||
const response = await request.post<ApiResponseOf<SignTaskSendPreparationResponseDto>>(
|
||
`/v1/sign-tasks/${encodeURIComponent(id)}/prepare-send`,
|
||
getTaskActionBody(expectedRowVersion),
|
||
)
|
||
|
||
return unwrapApiResponse(response)
|
||
}
|
||
|
||
export async function createSigningTask(input: CreateSigningTaskInput): Promise<SigningTaskRecord> {
|
||
if (!useMockData) {
|
||
const payload: CreateSigningTaskRequest = {
|
||
patientId: input.patientId,
|
||
visitId: input.visitId,
|
||
templateVersionId: input.templateVersionId,
|
||
signMethod: input.method === 'sms' ? 'SMS' : 'PAD',
|
||
idempotencyKey: createIdempotencyKey(),
|
||
}
|
||
const response = await request.post<ApiResponseOf<SignTaskResponseDto>>(
|
||
'/v1/sign-tasks',
|
||
payload,
|
||
)
|
||
const createdTask = unwrapApiResponse(response)
|
||
const options: SigningApiOptions = {
|
||
campus: input.campus,
|
||
templates: [createTemplateFromInput(input)],
|
||
}
|
||
|
||
if (createdTask.status === 'CREATED') {
|
||
await prepareSigningTask(createdTask.id, createdTask.rowVersion)
|
||
const preparedTask = await getSigningTaskDetail(createdTask.id, options)
|
||
|
||
if (preparedTask) {
|
||
return preparedTask
|
||
}
|
||
}
|
||
|
||
return mapSigningTask(createdTask, options)
|
||
}
|
||
|
||
const patient = mockPatients.find((item) => item.id === input.patientId)
|
||
const task: SigningTaskRecord = {
|
||
id: `task-${Date.now()}`,
|
||
templateVersionId: input.templateVersionId,
|
||
campus: input.campus,
|
||
patientId: input.patientId,
|
||
patientName: patient?.name ?? '演示患者',
|
||
sex: patient?.sex ?? '男',
|
||
age: patient?.age ?? 40,
|
||
visitNo: input.visitNo,
|
||
visitType: input.visitType,
|
||
visitDate:
|
||
patient?.visits.find((visit) => visit.id === input.visitId)?.visitDate ?? '2026-08-27',
|
||
documentId: input.documentId,
|
||
documentName: input.documentName,
|
||
department: input.department,
|
||
signerName: patient?.name ?? '演示患者',
|
||
method: input.method,
|
||
status: 'pending',
|
||
source: '工作台发起',
|
||
expiresAt: addHours('2026-08-27 12:00', 48),
|
||
updatedAt: formatMockDate(),
|
||
rowVersion: 1,
|
||
backendStatus: 'WAITING_SIGN',
|
||
}
|
||
|
||
mockRecords.unshift(task)
|
||
return Promise.resolve(cloneTask(task))
|
||
}
|
||
|
||
export function updateSigningTaskMethod(
|
||
id: string,
|
||
method: SigningMethod,
|
||
): Promise<SigningTaskRecord | null> {
|
||
if (!useMockData) {
|
||
throw new Error('当前后端未提供切换签署方式接口,请作废后重新创建任务')
|
||
}
|
||
|
||
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) {
|
||
throw new Error('真实签署需要通过签署投递与文件上传接口完成')
|
||
}
|
||
|
||
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 async function reopenSigningTask(
|
||
id: string,
|
||
options: SigningTaskActionOptions = {},
|
||
): Promise<SigningTaskRecord | null> {
|
||
if (!useMockData) {
|
||
const response = await request.post<ApiResponseOf<SignTaskResponseDto>>(
|
||
`/v1/sign-tasks/${encodeURIComponent(id)}/reopen`,
|
||
getTaskActionBody(options.expectedRowVersion),
|
||
)
|
||
return mapSigningTask(unwrapApiResponse(response), options)
|
||
}
|
||
|
||
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 async function voidSigningTask(
|
||
id: string,
|
||
reason: string,
|
||
options: SigningTaskActionOptions = {},
|
||
): Promise<SigningTaskRecord | null> {
|
||
if (!useMockData) {
|
||
const response = await request.post<ApiResponseOf<SignTaskResponseDto>>(
|
||
`/v1/sign-tasks/${encodeURIComponent(id)}/void`,
|
||
{ ...getTaskActionBody(options.expectedRowVersion), reason },
|
||
)
|
||
return mapSigningTask(unwrapApiResponse(response), options)
|
||
}
|
||
|
||
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 async function resendSigningSms(
|
||
id: string,
|
||
options: SigningTaskActionOptions = {},
|
||
): Promise<SigningTaskRecord | null> {
|
||
if (!useMockData) {
|
||
await resendSigningSmsDelivery(id, options.expectedRowVersion)
|
||
return getSigningTaskDetail(id, options)
|
||
}
|
||
|
||
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 async function getSigningTaskEvents(id: string): Promise<SigningTaskEvent[]> {
|
||
if (!useMockData) {
|
||
const response = await request.get<ApiResponseOf<SignTaskEventResponseDto[]>>(
|
||
`/v1/sign-tasks/${encodeURIComponent(id)}/events`,
|
||
)
|
||
|
||
return unwrapApiResponse(response).map(mapSigningEvent)
|
||
}
|
||
|
||
const task = mockRecords.find((record) => record.id === id)
|
||
|
||
if (!task) {
|
||
return []
|
||
}
|
||
|
||
const events: SigningTaskEvent[] = [
|
||
{
|
||
id: `${task.id}-created`,
|
||
time: task.updatedAt,
|
||
text: `张文静发起签署任务(${task.method === 'pad' ? '手写板' : '线上短信'})`,
|
||
eventType: 'TASK_CREATED',
|
||
},
|
||
]
|
||
|
||
if (task.status === 'signing') {
|
||
events.push({
|
||
id: `${task.id}-signing`,
|
||
time: task.updatedAt,
|
||
text: '已打开签名采集,等待患者完成签名',
|
||
eventType: 'SIGNING_STARTED',
|
||
})
|
||
}
|
||
|
||
if (task.signedAt) {
|
||
events.push({
|
||
id: `${task.id}-signed`,
|
||
time: task.signedAt,
|
||
text: `患者 ${task.patientName} 完成签名,系统自动合成并双留存`,
|
||
eventType: 'SIGNED',
|
||
})
|
||
}
|
||
|
||
if (task.status === 'void') {
|
||
events.push({
|
||
id: `${task.id}-voided`,
|
||
time: task.updatedAt,
|
||
text: `任务已作废${task.voidReason ? `,原因:${task.voidReason}` : ''},全程可审计`,
|
||
eventType: 'VOIDED',
|
||
})
|
||
}
|
||
|
||
return events
|
||
}
|
||
|
||
export function getSigningStatusLabel(status: SigningTaskStatus) {
|
||
const labels: Record<SigningTaskStatus, string> = {
|
||
pending: '待签署',
|
||
signing: '签署中',
|
||
signed: '已签署',
|
||
rejected: '已拒签',
|
||
expired: '已超时',
|
||
void: '已作废',
|
||
failed: '处理失败',
|
||
}
|
||
|
||
return labels[status]
|
||
}
|
||
|
||
export function getVisitTypeLabel(type: VisitType) {
|
||
return type
|
||
}
|