Files
xh-medical-sign-web/clinical-web/src/api/workbench/signing.ts

1138 lines
31 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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,
BackendVisitType,
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',
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.visitType && query.visitType !== 'all') {
const visitTypeMap: Partial<Record<VisitType, BackendVisitType>> = {
: 'OUTPATIENT',
: 'INPATIENT',
: 'CHECKUP',
}
const visitType = visitTypeMap[query.visitType]
if (visitType) {
params.visitType = visitType
}
}
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') {
const campusId = options.campusIds?.[query.campus] ?? options.campusId
if (campusId) {
params.campusId = 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)
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: '已签署',
expired: '已超时',
void: '已作废',
failed: '处理失败',
}
return labels[status]
}
export function getVisitTypeLabel(type: VisitType) {
return type
}