feat(signing): 接入患者与任务真实接口
This commit is contained in:
@@ -1,14 +1,32 @@
|
|||||||
|
import axios from 'axios'
|
||||||
|
|
||||||
import { request } from '@/utils/request'
|
import { request } from '@/utils/request'
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
|
ApiPageResponse,
|
||||||
|
ApiResponseOf,
|
||||||
|
AvailableTemplateVersionResponseDto,
|
||||||
|
BackendSigningMethod,
|
||||||
|
BackendSigningTaskStatus,
|
||||||
|
CreateSigningTaskInput,
|
||||||
CreateSigningTaskRequest,
|
CreateSigningTaskRequest,
|
||||||
PatientProfile,
|
PatientProfile,
|
||||||
|
PatientResponseDto,
|
||||||
|
PatientSex,
|
||||||
|
SignTaskActionRequest,
|
||||||
|
SignTaskEventResponseDto,
|
||||||
|
SignTaskResponseDto,
|
||||||
|
SignTaskSendPreparationResponseDto,
|
||||||
|
SigningApiOptions,
|
||||||
SigningMethod,
|
SigningMethod,
|
||||||
|
SigningTaskEvent,
|
||||||
|
SigningTaskActionOptions,
|
||||||
SigningTaskListResponse,
|
SigningTaskListResponse,
|
||||||
SigningTaskQuery,
|
SigningTaskQuery,
|
||||||
SigningTaskRecord,
|
SigningTaskRecord,
|
||||||
SigningTaskStatus,
|
SigningTaskStatus,
|
||||||
SigningTemplate,
|
SigningTemplate,
|
||||||
|
VisitResponseDto,
|
||||||
VisitType,
|
VisitType,
|
||||||
} from './types'
|
} from './types'
|
||||||
|
|
||||||
@@ -117,8 +135,10 @@ const mockPatients: PatientProfile[] = [
|
|||||||
const mockTemplates: SigningTemplate[] = [
|
const mockTemplates: SigningTemplate[] = [
|
||||||
{
|
{
|
||||||
id: 'tpl-001',
|
id: 'tpl-001',
|
||||||
|
versionId: 'tpl-001-v2',
|
||||||
name: '住院患者知情同意书',
|
name: '住院患者知情同意书',
|
||||||
code: 'DOC-HOS-001',
|
code: 'DOC-HOS-001',
|
||||||
|
version: 'V2.0',
|
||||||
department: '全院通用',
|
department: '全院通用',
|
||||||
category: '住院告知',
|
category: '住院告知',
|
||||||
description: '住院期间诊疗事项、风险与患者权利告知。',
|
description: '住院期间诊疗事项、风险与患者权利告知。',
|
||||||
@@ -126,8 +146,10 @@ const mockTemplates: SigningTemplate[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'tpl-002',
|
id: 'tpl-002',
|
||||||
|
versionId: 'tpl-002-v1',
|
||||||
name: '住院须知及告知书',
|
name: '住院须知及告知书',
|
||||||
code: 'DOC-HOS-002',
|
code: 'DOC-HOS-002',
|
||||||
|
version: 'V1.0',
|
||||||
department: '全院通用',
|
department: '全院通用',
|
||||||
category: '住院告知',
|
category: '住院告知',
|
||||||
description: '住院流程、陪护要求和患者须知确认。',
|
description: '住院流程、陪护要求和患者须知确认。',
|
||||||
@@ -135,8 +157,10 @@ const mockTemplates: SigningTemplate[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'tpl-003',
|
id: 'tpl-003',
|
||||||
|
versionId: 'tpl-003-v3',
|
||||||
name: '内镜检查知情同意书',
|
name: '内镜检查知情同意书',
|
||||||
code: 'DOC-DIG-001',
|
code: 'DOC-DIG-001',
|
||||||
|
version: 'V3.0',
|
||||||
department: '消化内科',
|
department: '消化内科',
|
||||||
category: '检查告知',
|
category: '检查告知',
|
||||||
description: '内镜检查适应证、风险及替代方案告知。',
|
description: '内镜检查适应证、风险及替代方案告知。',
|
||||||
@@ -144,8 +168,10 @@ const mockTemplates: SigningTemplate[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'tpl-004',
|
id: 'tpl-004',
|
||||||
|
versionId: 'tpl-004-v1',
|
||||||
name: 'MRI 磁共振检查知情同意书',
|
name: 'MRI 磁共振检查知情同意书',
|
||||||
code: 'DOC-RAD-001',
|
code: 'DOC-RAD-001',
|
||||||
|
version: 'V1.0',
|
||||||
department: '放射科',
|
department: '放射科',
|
||||||
category: '检查告知',
|
category: '检查告知',
|
||||||
description: '磁共振检查注意事项、禁忌证及风险告知。',
|
description: '磁共振检查注意事项、禁忌证及风险告知。',
|
||||||
@@ -153,8 +179,10 @@ const mockTemplates: SigningTemplate[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'tpl-005',
|
id: 'tpl-005',
|
||||||
|
versionId: 'tpl-005-v2',
|
||||||
name: '心脏介入手术知情同意书',
|
name: '心脏介入手术知情同意书',
|
||||||
code: 'DOC-CARD-001',
|
code: 'DOC-CARD-001',
|
||||||
|
version: 'V2.0',
|
||||||
department: '心内科',
|
department: '心内科',
|
||||||
category: '手术告知',
|
category: '手术告知',
|
||||||
description: '介入治疗方案、围术期风险及替代方案告知。',
|
description: '介入治疗方案、围术期风险及替代方案告知。',
|
||||||
@@ -162,8 +190,10 @@ const mockTemplates: SigningTemplate[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'tpl-006',
|
id: 'tpl-006',
|
||||||
|
versionId: 'tpl-006-v1',
|
||||||
name: '体检报告发放及知情同意书',
|
name: '体检报告发放及知情同意书',
|
||||||
code: 'DOC-PE-001',
|
code: 'DOC-PE-001',
|
||||||
|
version: 'V1.0',
|
||||||
department: '健康管理中心',
|
department: '健康管理中心',
|
||||||
category: '体检告知',
|
category: '体检告知',
|
||||||
description: '体检报告领取方式、隐私保护和异常结果随访告知。',
|
description: '体检报告领取方式、隐私保护和异常结果随访告知。',
|
||||||
@@ -171,8 +201,10 @@ const mockTemplates: SigningTemplate[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'tpl-007',
|
id: 'tpl-007',
|
||||||
|
versionId: 'tpl-007-v1',
|
||||||
name: '患者授权委托书',
|
name: '患者授权委托书',
|
||||||
code: 'DOC-GEN-003',
|
code: 'DOC-GEN-003',
|
||||||
|
version: 'V1.0',
|
||||||
department: '医务处',
|
department: '医务处',
|
||||||
category: '授权文书',
|
category: '授权文书',
|
||||||
description: '患者授权家属或代理人办理相关医疗事项。',
|
description: '患者授权家属或代理人办理相关医疗事项。',
|
||||||
@@ -183,6 +215,7 @@ const mockTemplates: SigningTemplate[] = [
|
|||||||
const mockRecords: SigningTaskRecord[] = [
|
const mockRecords: SigningTaskRecord[] = [
|
||||||
{
|
{
|
||||||
id: 'task-001',
|
id: 'task-001',
|
||||||
|
templateVersionId: 'tpl-001-v2',
|
||||||
campus: '本部院区',
|
campus: '本部院区',
|
||||||
patientId: 'P202610001',
|
patientId: 'P202610001',
|
||||||
patientName: '李某某',
|
patientName: '李某某',
|
||||||
@@ -203,6 +236,7 @@ const mockRecords: SigningTaskRecord[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'task-002',
|
id: 'task-002',
|
||||||
|
templateVersionId: 'tpl-004-v1',
|
||||||
campus: '东院区',
|
campus: '东院区',
|
||||||
patientId: 'P202610002',
|
patientId: 'P202610002',
|
||||||
patientName: '王某某',
|
patientName: '王某某',
|
||||||
@@ -224,6 +258,7 @@ const mockRecords: SigningTaskRecord[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'task-003',
|
id: 'task-003',
|
||||||
|
templateVersionId: 'tpl-003-v3',
|
||||||
campus: '本部院区',
|
campus: '本部院区',
|
||||||
patientId: 'P202610003',
|
patientId: 'P202610003',
|
||||||
patientName: '赵某某',
|
patientName: '赵某某',
|
||||||
@@ -244,6 +279,7 @@ const mockRecords: SigningTaskRecord[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'task-004',
|
id: 'task-004',
|
||||||
|
templateVersionId: 'tpl-002-v1',
|
||||||
campus: '本部院区',
|
campus: '本部院区',
|
||||||
patientId: 'P202610005',
|
patientId: 'P202610005',
|
||||||
patientName: '陈志强',
|
patientName: '陈志强',
|
||||||
@@ -265,6 +301,7 @@ const mockRecords: SigningTaskRecord[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'task-005',
|
id: 'task-005',
|
||||||
|
templateVersionId: 'tpl-006-v1',
|
||||||
campus: '东院区',
|
campus: '东院区',
|
||||||
patientId: 'P202610004',
|
patientId: 'P202610004',
|
||||||
patientName: '周雅琴',
|
patientName: '周雅琴',
|
||||||
@@ -329,11 +366,325 @@ function addHours(value: string, hours: number) {
|
|||||||
return formatMockDate(date)
|
return formatMockDate(date)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getSigningTasks(query: SigningTaskQuery): Promise<SigningTaskListResponse> {
|
export const isSigningMockEnabled = useMockData
|
||||||
|
|
||||||
|
function isSuccessCode(code: number | string) {
|
||||||
|
return code === 0 || code === '0'
|
||||||
|
}
|
||||||
|
|
||||||
|
function unwrapApiResponse<T>(response: ApiResponseOf<T>): T {
|
||||||
|
if (!isSuccessCode(response.code)) {
|
||||||
|
throw new Error(response.message || '接口请求失败')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response.data === null) {
|
||||||
|
throw new Error(response.message || '接口未返回业务数据')
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
|
|
||||||
|
function isNotFoundError(error: unknown) {
|
||||||
|
return axios.isAxiosError(error) && error.response?.status === 404
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatApiDateTime(value: string | null | undefined) {
|
||||||
|
if (!value) {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
const date = new Date(value)
|
||||||
|
|
||||||
|
if (Number.isNaN(date.getTime())) {
|
||||||
|
return value
|
||||||
|
.replace('T', ' ')
|
||||||
|
.replace(/\.\d+Z$/, '')
|
||||||
|
.replace(/Z$/, '')
|
||||||
|
}
|
||||||
|
|
||||||
|
const pad = (part: number) => String(part).padStart(2, '0')
|
||||||
|
|
||||||
|
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(
|
||||||
|
date.getHours(),
|
||||||
|
)}:${pad(date.getMinutes())}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatApiDate(value: string | null | undefined) {
|
||||||
|
return formatApiDateTime(value).slice(0, 10)
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizePatientSex(value: string): PatientSex {
|
||||||
|
if (value === 'MALE' || value === '男') {
|
||||||
|
return '男'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value === 'FEMALE' || value === '女') {
|
||||||
|
return '女'
|
||||||
|
}
|
||||||
|
|
||||||
|
return '未知'
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeVisitType(value: string): VisitType {
|
||||||
|
if (value === 'OUTPATIENT' || value === '门诊') {
|
||||||
|
return '门诊'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value === 'INPATIENT' || value === '住院') {
|
||||||
|
return '住院'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value === 'CHECKUP' || value === '体检') {
|
||||||
|
return '体检'
|
||||||
|
}
|
||||||
|
|
||||||
|
return '其他'
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeSigningMethod(value: BackendSigningMethod): SigningMethod {
|
||||||
|
return value === 'SMS' ? 'sms' : 'pad'
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeTaskStatus(value: BackendSigningTaskStatus): SigningTaskStatus {
|
||||||
|
switch (value) {
|
||||||
|
case 'SIGNED':
|
||||||
|
return 'signed'
|
||||||
|
case 'EXPIRED':
|
||||||
|
return 'expired'
|
||||||
|
case 'VOIDED':
|
||||||
|
return 'void'
|
||||||
|
case 'GENERATING':
|
||||||
|
return 'signing'
|
||||||
|
case 'FAILED':
|
||||||
|
return 'failed'
|
||||||
|
case 'CREATED':
|
||||||
|
case 'WAITING_SIGN':
|
||||||
|
default:
|
||||||
|
return 'pending'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapPatient(dto: PatientResponseDto, visits: PatientProfile['visits']): PatientProfile {
|
||||||
|
return {
|
||||||
|
id: dto.id,
|
||||||
|
name: dto.name,
|
||||||
|
sex: normalizePatientSex(dto.sex),
|
||||||
|
age: dto.age,
|
||||||
|
idCard: dto.idCardMasked ?? '',
|
||||||
|
phone: dto.phoneMasked ?? '',
|
||||||
|
visits,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapVisit(dto: VisitResponseDto): PatientProfile['visits'][number] {
|
||||||
|
return {
|
||||||
|
id: dto.id,
|
||||||
|
type: normalizeVisitType(dto.visitType),
|
||||||
|
visitNo: dto.visitNo,
|
||||||
|
department: dto.departmentName || '未指定科室',
|
||||||
|
departmentId: dto.departmentId,
|
||||||
|
doctorName: dto.doctorName ?? undefined,
|
||||||
|
visitDate: formatApiDate(dto.visitedAt),
|
||||||
|
visitedAt: dto.visitedAt,
|
||||||
|
campusId: dto.campusId,
|
||||||
|
sourceSystem: dto.sourceSystem,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapAvailableTemplate(dto: AvailableTemplateVersionResponseDto): SigningTemplate {
|
||||||
|
return {
|
||||||
|
id: dto.templateId,
|
||||||
|
versionId: dto.templateVersionId,
|
||||||
|
name: dto.templateName,
|
||||||
|
code: dto.templateCode,
|
||||||
|
version: dto.versionNo,
|
||||||
|
department: dto.departmentId ? '指定科室' : '全院通用',
|
||||||
|
departmentId: dto.departmentId,
|
||||||
|
campusId: dto.campusId,
|
||||||
|
category: dto.category || '未分类',
|
||||||
|
description: `已发布版本 ${dto.versionNo}`,
|
||||||
|
// 可用模板接口没有返回通道 ACL;具体通道仍由后端在创建时校验。
|
||||||
|
supportedMethods: ['pad', 'sms'],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function findTemplate(templates: SigningTemplate[] | undefined, versionId: string) {
|
||||||
|
return templates?.find((template) => template.versionId === versionId)
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapSigningTask(
|
||||||
|
dto: SignTaskResponseDto,
|
||||||
|
options: SigningApiOptions = {},
|
||||||
|
): SigningTaskRecord {
|
||||||
|
const template = findTemplate(options.templates, dto.templateVersionId)
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: dto.id,
|
||||||
|
templateVersionId: dto.templateVersionId,
|
||||||
|
campus: options.campus ?? '本部院区',
|
||||||
|
patientId: dto.patientSnapshot.patientId,
|
||||||
|
patientName: dto.patientSnapshot.name,
|
||||||
|
sex: normalizePatientSex(dto.patientSnapshot.sex),
|
||||||
|
age: dto.patientSnapshot.age,
|
||||||
|
visitNo: dto.visitSnapshot.visitNo,
|
||||||
|
visitType: normalizeVisitType(dto.visitSnapshot.visitType),
|
||||||
|
visitDate: formatApiDate(dto.visitSnapshot.visitedAt),
|
||||||
|
documentId: template?.id ?? dto.templateVersionId,
|
||||||
|
documentName: template?.name ?? `模板版本 ${dto.templateVersionNumber}`,
|
||||||
|
department: dto.visitSnapshot.departmentName || '未指定科室',
|
||||||
|
signerName: dto.patientSnapshot.name,
|
||||||
|
method: normalizeSigningMethod(dto.signMethod),
|
||||||
|
status: normalizeTaskStatus(dto.status),
|
||||||
|
source: dto.visitSnapshot.sourceSystem || dto.patientSnapshot.sourceSystem || '—',
|
||||||
|
expiresAt: formatApiDateTime(dto.expiredAt),
|
||||||
|
updatedAt: formatApiDateTime(dto.updatedAt),
|
||||||
|
rowVersion: dto.rowVersion,
|
||||||
|
backendStatus: dto.status,
|
||||||
|
signedAt: formatApiDateTime(dto.signedAt) || undefined,
|
||||||
|
voidReason: dto.voidReason ?? undefined,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function createTemplateFromInput(input: CreateSigningTaskInput): SigningTemplate {
|
||||||
|
return {
|
||||||
|
id: input.documentId,
|
||||||
|
versionId: input.templateVersionId,
|
||||||
|
name: input.documentName,
|
||||||
|
code: input.documentId,
|
||||||
|
version: '—',
|
||||||
|
department: input.department,
|
||||||
|
category: '—',
|
||||||
|
description: '',
|
||||||
|
supportedMethods: ['pad', 'sms'],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toBackendTaskQuery(query: SigningTaskQuery, templates?: SigningTemplate[]) {
|
||||||
|
const params: Record<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(templates, query.documentId)?.versionId ?? query.documentId
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = new Date()
|
||||||
|
const start = new Date(now)
|
||||||
|
const end = new Date(now)
|
||||||
|
start.setHours(0, 0, 0, 0)
|
||||||
|
end.setHours(23, 59, 59, 999)
|
||||||
|
|
||||||
|
if (query.dateRange === 'yesterday') {
|
||||||
|
start.setDate(start.getDate() - 1)
|
||||||
|
end.setDate(end.getDate() - 1)
|
||||||
|
} else if (query.dateRange === '3d') {
|
||||||
|
start.setDate(start.getDate() - 2)
|
||||||
|
} else if (query.dateRange === '7d') {
|
||||||
|
start.setDate(start.getDate() - 6)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (query.dateRange && query.dateRange !== 'all') {
|
||||||
|
params.createdFrom = start.toISOString()
|
||||||
|
params.createdTo = end.toISOString()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 院区、科室和就诊类型在页面中使用展示名称,不能直接当作 UUID 发送。
|
||||||
|
// 后端会按当前登录用户的数据范围默认过滤,待院区/科室字典接入后再补充 ID 映射。
|
||||||
|
return params
|
||||||
|
}
|
||||||
|
|
||||||
|
function createIdempotencyKey() {
|
||||||
|
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
|
||||||
|
return `clinical-web-${crypto.randomUUID()}`
|
||||||
|
}
|
||||||
|
|
||||||
|
return `clinical-web-${Date.now()}-${Math.random().toString(36).slice(2)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function getTaskActionBody(expectedRowVersion?: number): SignTaskActionRequest {
|
||||||
|
return expectedRowVersion === undefined ? {} : { expectedRowVersion }
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapEventText(event: SignTaskEventResponseDto) {
|
||||||
|
const actor = event.actorSubject || '系统'
|
||||||
|
const channel = event.channel === 'SMS' ? '短信' : event.channel === 'PAD' ? '手写板' : ''
|
||||||
|
|
||||||
|
switch (event.eventType) {
|
||||||
|
case 'TASK_CREATED':
|
||||||
|
return `${actor}创建签署任务${channel ? `(${channel})` : ''}`
|
||||||
|
case 'SEND_PREPARED':
|
||||||
|
return `${actor}准备${channel || '签署'}发送`
|
||||||
|
case 'VOIDED':
|
||||||
|
return `任务已作废${event.reason ? `,原因:${event.reason}` : ''}`
|
||||||
|
case 'RESENT':
|
||||||
|
case 'RESEND_PREPARED':
|
||||||
|
return `${actor}重新发送${channel || '签署'}请求`
|
||||||
|
case 'REOPENED':
|
||||||
|
return `${actor}重新开启签署任务`
|
||||||
|
case 'SIGNED':
|
||||||
|
return `${actor}完成签名`
|
||||||
|
default:
|
||||||
|
return `${actor}:${event.eventType}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapSigningEvent(event: SignTaskEventResponseDto): SigningTaskEvent {
|
||||||
|
return {
|
||||||
|
id: event.id,
|
||||||
|
time: formatApiDateTime(event.eventTime),
|
||||||
|
text: mapEventText(event),
|
||||||
|
eventType: event.eventType,
|
||||||
|
actorSubject: event.actorSubject ?? undefined,
|
||||||
|
reason: event.reason ?? undefined,
|
||||||
|
channel: event.channel ?? undefined,
|
||||||
|
attemptNo: event.attemptNo ?? undefined,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getSigningTasks(
|
||||||
|
query: SigningTaskQuery,
|
||||||
|
options: SigningApiOptions = {},
|
||||||
|
): Promise<SigningTaskListResponse> {
|
||||||
if (!useMockData) {
|
if (!useMockData) {
|
||||||
return request.get<SigningTaskListResponse>('/workbench/signing/tasks', {
|
const response = await request.get<ApiResponseOf<ApiPageResponse<SignTaskResponseDto>>>(
|
||||||
params: query,
|
'/v1/sign-tasks',
|
||||||
})
|
{ params: toBackendTaskQuery(query, options.templates) },
|
||||||
|
)
|
||||||
|
const page = unwrapApiResponse(response)
|
||||||
|
|
||||||
|
return {
|
||||||
|
records: page.records.map((task) => mapSigningTask(task, options)),
|
||||||
|
total: page.total,
|
||||||
|
page: page.page,
|
||||||
|
pageSize: page.size,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const keyword = query.keyword?.trim().toLowerCase()
|
const keyword = query.keyword?.trim().toLowerCase()
|
||||||
@@ -386,9 +737,23 @@ export function getSigningTasks(query: SigningTaskQuery): Promise<SigningTaskLis
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getSigningTaskDetail(id: string): Promise<SigningTaskRecord | null> {
|
export async function getSigningTaskDetail(
|
||||||
|
id: string,
|
||||||
|
options: SigningApiOptions = {},
|
||||||
|
): Promise<SigningTaskRecord | null> {
|
||||||
if (!useMockData) {
|
if (!useMockData) {
|
||||||
return request.get<SigningTaskRecord>('/workbench/signing/tasks/' + id)
|
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) =>
|
return Promise.resolve(mockRecords.find((record) => record.id === id) ?? null).then((task) =>
|
||||||
@@ -396,9 +761,79 @@ export function getSigningTaskDetail(id: string): Promise<SigningTaskRecord | nu
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getPatientProfile(keyword: string): Promise<PatientProfile | 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) {
|
if (!useMockData) {
|
||||||
return request.get<PatientProfile>('/patients/' + encodeURIComponent(keyword))
|
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 normalizedKeyword = keyword.trim()
|
||||||
@@ -411,40 +846,91 @@ export function getPatientProfile(keyword: string): Promise<PatientProfile | nul
|
|||||||
return Promise.resolve(patient ? clonePatient(patient) : null)
|
return Promise.resolve(patient ? clonePatient(patient) : null)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getSigningTemplates(): Promise<SigningTemplate[]> {
|
export async function getSigningTemplates(
|
||||||
|
query: {
|
||||||
|
campusId?: string
|
||||||
|
departmentId?: string
|
||||||
|
} = {},
|
||||||
|
): Promise<SigningTemplate[]> {
|
||||||
if (!useMockData) {
|
if (!useMockData) {
|
||||||
return request.get<SigningTemplate[]>('/workbench/signing/templates')
|
const response = await request.get<ApiResponseOf<AvailableTemplateVersionResponseDto[]>>(
|
||||||
|
'/v1/templates/available-versions',
|
||||||
|
{ params: query },
|
||||||
|
)
|
||||||
|
|
||||||
|
return unwrapApiResponse(response).map(mapAvailableTemplate)
|
||||||
}
|
}
|
||||||
|
|
||||||
return Promise.resolve(mockTemplates.map((template) => ({ ...template })))
|
return Promise.resolve(mockTemplates.map((template) => ({ ...template })))
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createSigningTask(payload: CreateSigningTaskRequest): Promise<SigningTaskRecord> {
|
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) {
|
if (!useMockData) {
|
||||||
return request.post<SigningTaskRecord>('/workbench/signing/tasks', payload)
|
const payload: CreateSigningTaskRequest = {
|
||||||
|
patientId: input.patientId,
|
||||||
|
visitId: input.visitId,
|
||||||
|
templateVersionId: input.templateVersionId,
|
||||||
|
signMethod: input.method === 'sms' ? 'SMS' : 'PAD',
|
||||||
|
idempotencyKey: createIdempotencyKey(),
|
||||||
|
}
|
||||||
|
const response = await request.post<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 === payload.patientId)
|
const patient = mockPatients.find((item) => item.id === input.patientId)
|
||||||
const task: SigningTaskRecord = {
|
const task: SigningTaskRecord = {
|
||||||
id: `task-${Date.now()}`,
|
id: `task-${Date.now()}`,
|
||||||
campus: payload.campus,
|
templateVersionId: input.templateVersionId,
|
||||||
patientId: payload.patientId,
|
campus: input.campus,
|
||||||
|
patientId: input.patientId,
|
||||||
patientName: patient?.name ?? '演示患者',
|
patientName: patient?.name ?? '演示患者',
|
||||||
sex: patient?.sex ?? '男',
|
sex: patient?.sex ?? '男',
|
||||||
age: patient?.age ?? 40,
|
age: patient?.age ?? 40,
|
||||||
visitNo: payload.visitNo,
|
visitNo: input.visitNo,
|
||||||
visitType: payload.visitType,
|
visitType: input.visitType,
|
||||||
visitDate:
|
visitDate:
|
||||||
patient?.visits.find((visit) => visit.id === payload.visitId)?.visitDate ?? '2026-08-27',
|
patient?.visits.find((visit) => visit.id === input.visitId)?.visitDate ?? '2026-08-27',
|
||||||
documentId: payload.documentId,
|
documentId: input.documentId,
|
||||||
documentName: payload.documentName,
|
documentName: input.documentName,
|
||||||
department: payload.department,
|
department: input.department,
|
||||||
signerName: patient?.name ?? '演示患者',
|
signerName: patient?.name ?? '演示患者',
|
||||||
method: payload.method,
|
method: input.method,
|
||||||
status: 'pending',
|
status: 'pending',
|
||||||
source: '工作台发起',
|
source: '工作台发起',
|
||||||
expiresAt: addHours('2026-08-27 12:00', 48),
|
expiresAt: addHours('2026-08-27 12:00', 48),
|
||||||
updatedAt: formatMockDate(),
|
updatedAt: formatMockDate(),
|
||||||
|
rowVersion: 1,
|
||||||
|
backendStatus: 'WAITING_SIGN',
|
||||||
}
|
}
|
||||||
|
|
||||||
mockRecords.unshift(task)
|
mockRecords.unshift(task)
|
||||||
@@ -456,7 +942,7 @@ export function updateSigningTaskMethod(
|
|||||||
method: SigningMethod,
|
method: SigningMethod,
|
||||||
): Promise<SigningTaskRecord | null> {
|
): Promise<SigningTaskRecord | null> {
|
||||||
if (!useMockData) {
|
if (!useMockData) {
|
||||||
return request.put<SigningTaskRecord>('/workbench/signing/tasks/' + id + '/method', { method })
|
throw new Error('当前后端未提供切换签署方式接口,请作废后重新创建任务')
|
||||||
}
|
}
|
||||||
|
|
||||||
const task = mockRecords.find((record) => record.id === id)
|
const task = mockRecords.find((record) => record.id === id)
|
||||||
@@ -475,9 +961,7 @@ export function completeSigningTask(
|
|||||||
signatureDataUrl: string,
|
signatureDataUrl: string,
|
||||||
): Promise<SigningTaskRecord | null> {
|
): Promise<SigningTaskRecord | null> {
|
||||||
if (!useMockData) {
|
if (!useMockData) {
|
||||||
return request.post<SigningTaskRecord>('/workbench/signing/tasks/' + id + '/complete', {
|
throw new Error('真实签署需要通过签署投递与文件上传接口完成')
|
||||||
signatureDataUrl,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const task = mockRecords.find((record) => record.id === id)
|
const task = mockRecords.find((record) => record.id === id)
|
||||||
@@ -493,9 +977,16 @@ export function completeSigningTask(
|
|||||||
return Promise.resolve(cloneTask(task))
|
return Promise.resolve(cloneTask(task))
|
||||||
}
|
}
|
||||||
|
|
||||||
export function reopenSigningTask(id: string): Promise<SigningTaskRecord | null> {
|
export async function reopenSigningTask(
|
||||||
|
id: string,
|
||||||
|
options: SigningTaskActionOptions = {},
|
||||||
|
): Promise<SigningTaskRecord | null> {
|
||||||
if (!useMockData) {
|
if (!useMockData) {
|
||||||
return request.post<SigningTaskRecord>('/workbench/signing/tasks/' + id + '/reopen')
|
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)
|
const task = mockRecords.find((record) => record.id === id)
|
||||||
@@ -511,9 +1002,17 @@ export function reopenSigningTask(id: string): Promise<SigningTaskRecord | null>
|
|||||||
return Promise.resolve(cloneTask(task))
|
return Promise.resolve(cloneTask(task))
|
||||||
}
|
}
|
||||||
|
|
||||||
export function voidSigningTask(id: string, reason: string): Promise<SigningTaskRecord | null> {
|
export async function voidSigningTask(
|
||||||
|
id: string,
|
||||||
|
reason: string,
|
||||||
|
options: SigningTaskActionOptions = {},
|
||||||
|
): Promise<SigningTaskRecord | null> {
|
||||||
if (!useMockData) {
|
if (!useMockData) {
|
||||||
return request.post<SigningTaskRecord>('/workbench/signing/tasks/' + id + '/void', { reason })
|
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)
|
const task = mockRecords.find((record) => record.id === id)
|
||||||
@@ -529,9 +1028,17 @@ export function voidSigningTask(id: string, reason: string): Promise<SigningTask
|
|||||||
return Promise.resolve(cloneTask(task))
|
return Promise.resolve(cloneTask(task))
|
||||||
}
|
}
|
||||||
|
|
||||||
export function resendSigningSms(id: string): Promise<SigningTaskRecord | null> {
|
export async function resendSigningSms(
|
||||||
|
id: string,
|
||||||
|
options: SigningTaskActionOptions = {},
|
||||||
|
): Promise<SigningTaskRecord | null> {
|
||||||
if (!useMockData) {
|
if (!useMockData) {
|
||||||
return request.post<SigningTaskRecord>('/workbench/signing/tasks/' + id + '/resend-sms')
|
const response = await request.post<ApiResponseOf<SignTaskSendPreparationResponseDto>>(
|
||||||
|
`/v1/sign-tasks/${encodeURIComponent(id)}/resend`,
|
||||||
|
getTaskActionBody(options.expectedRowVersion),
|
||||||
|
)
|
||||||
|
unwrapApiResponse(response)
|
||||||
|
return getSigningTaskDetail(id, options)
|
||||||
}
|
}
|
||||||
|
|
||||||
const task = mockRecords.find((record) => record.id === id)
|
const task = mockRecords.find((record) => record.id === id)
|
||||||
@@ -547,6 +1054,60 @@ export function resendSigningSms(id: string): Promise<SigningTaskRecord | null>
|
|||||||
return Promise.resolve(cloneTask(task))
|
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) {
|
export function getSigningStatusLabel(status: SigningTaskStatus) {
|
||||||
const labels: Record<SigningTaskStatus, string> = {
|
const labels: Record<SigningTaskStatus, string> = {
|
||||||
pending: '待签署',
|
pending: '待签署',
|
||||||
@@ -555,6 +1116,7 @@ export function getSigningStatusLabel(status: SigningTaskStatus) {
|
|||||||
rejected: '已拒签',
|
rejected: '已拒签',
|
||||||
expired: '已超时',
|
expired: '已超时',
|
||||||
void: '已作废',
|
void: '已作废',
|
||||||
|
failed: '处理失败',
|
||||||
}
|
}
|
||||||
|
|
||||||
return labels[status]
|
return labels[status]
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { PageQuery, PageResult } from '@/types/common'
|
import type { ApiResponse, PageQuery, PageResult } from '@/types/common'
|
||||||
|
|
||||||
export type WorkbenchCampus = '本部院区' | '东院区' | '西院区'
|
export type WorkbenchCampus = '本部院区' | '东院区' | '西院区'
|
||||||
|
|
||||||
@@ -9,23 +9,31 @@ export interface WorkbenchOverviewQuery {
|
|||||||
rankingPeriod: HomeRankingPeriod
|
rankingPeriod: HomeRankingPeriod
|
||||||
}
|
}
|
||||||
|
|
||||||
export type SigningTaskStatus = 'pending' | 'signing' | 'signed' | 'rejected' | 'expired' | 'void'
|
export type SigningTaskStatus =
|
||||||
|
'pending' | 'signing' | 'signed' | 'rejected' | 'expired' | 'void' | 'failed'
|
||||||
|
|
||||||
export type VisitType = '门诊' | '住院' | '体检'
|
export type PatientSex = '男' | '女' | '未知'
|
||||||
|
|
||||||
|
export type VisitType = '门诊' | '住院' | '体检' | '其他'
|
||||||
|
|
||||||
export interface PatientVisit {
|
export interface PatientVisit {
|
||||||
id: string
|
id: string
|
||||||
type: VisitType
|
type: VisitType
|
||||||
visitNo: string
|
visitNo: string
|
||||||
department: string
|
department: string
|
||||||
|
departmentId?: string | null
|
||||||
|
doctorName?: string
|
||||||
visitDate: string
|
visitDate: string
|
||||||
|
visitedAt?: string
|
||||||
|
campusId?: string
|
||||||
|
sourceSystem?: string
|
||||||
isCurrent?: boolean
|
isCurrent?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PatientProfile {
|
export interface PatientProfile {
|
||||||
id: string
|
id: string
|
||||||
name: string
|
name: string
|
||||||
sex: '男' | '女'
|
sex: PatientSex
|
||||||
age: number
|
age: number
|
||||||
idCard: string
|
idCard: string
|
||||||
phone: string
|
phone: string
|
||||||
@@ -34,9 +42,13 @@ export interface PatientProfile {
|
|||||||
|
|
||||||
export interface SigningTemplate {
|
export interface SigningTemplate {
|
||||||
id: string
|
id: string
|
||||||
|
versionId: string
|
||||||
name: string
|
name: string
|
||||||
code: string
|
code: string
|
||||||
|
version: string
|
||||||
department: string
|
department: string
|
||||||
|
departmentId?: string | null
|
||||||
|
campusId?: string
|
||||||
category: string
|
category: string
|
||||||
description: string
|
description: string
|
||||||
supportedMethods: SigningMethod[]
|
supportedMethods: SigningMethod[]
|
||||||
@@ -108,9 +120,10 @@ export interface SigningTaskQuery extends PageQuery {
|
|||||||
export type SigningDateRange = 'today' | 'yesterday' | '3d' | '7d' | 'all'
|
export type SigningDateRange = 'today' | 'yesterday' | '3d' | '7d' | 'all'
|
||||||
|
|
||||||
export interface SigningTaskRecord extends WorkbenchTask {
|
export interface SigningTaskRecord extends WorkbenchTask {
|
||||||
|
templateVersionId: string
|
||||||
campus: WorkbenchCampus
|
campus: WorkbenchCampus
|
||||||
patientId: string
|
patientId: string
|
||||||
sex: '男' | '女'
|
sex: PatientSex
|
||||||
age: number
|
age: number
|
||||||
documentId: string
|
documentId: string
|
||||||
signerName: string
|
signerName: string
|
||||||
@@ -119,6 +132,8 @@ export interface SigningTaskRecord extends WorkbenchTask {
|
|||||||
expiresAt: string
|
expiresAt: string
|
||||||
visitType: VisitType
|
visitType: VisitType
|
||||||
visitDate: string
|
visitDate: string
|
||||||
|
rowVersion?: number
|
||||||
|
backendStatus?: BackendSigningTaskStatus
|
||||||
signatureDataUrl?: string
|
signatureDataUrl?: string
|
||||||
signedAt?: string
|
signedAt?: string
|
||||||
voidReason?: string
|
voidReason?: string
|
||||||
@@ -126,15 +141,197 @@ export interface SigningTaskRecord extends WorkbenchTask {
|
|||||||
|
|
||||||
export type SigningTaskListResponse = PageResult<SigningTaskRecord>
|
export type SigningTaskListResponse = PageResult<SigningTaskRecord>
|
||||||
|
|
||||||
export interface CreateSigningTaskRequest {
|
/** 页面提交模型。真实接口请求会在 API 边界转换为 CreateSigningTaskRequest。 */
|
||||||
|
export interface CreateSigningTaskInput {
|
||||||
campus: WorkbenchCampus
|
campus: WorkbenchCampus
|
||||||
patientId: string
|
patientId: string
|
||||||
visitId: string
|
visitId: string
|
||||||
documentId: string
|
documentId: string
|
||||||
documentName: string
|
documentName: string
|
||||||
|
templateVersionId: string
|
||||||
method: SigningMethod
|
method: SigningMethod
|
||||||
visitType: VisitType
|
visitType: VisitType
|
||||||
visitNo: string
|
visitNo: string
|
||||||
department: string
|
department: string
|
||||||
phone?: string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** MEDISIGN 创建签署任务请求 DTO。不要向后端提交页面快照字段。 */
|
||||||
|
export interface CreateSigningTaskRequest {
|
||||||
|
patientId: string
|
||||||
|
visitId: string
|
||||||
|
templateVersionId: string
|
||||||
|
signMethod: BackendSigningMethod
|
||||||
|
idempotencyKey?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SigningApiOptions {
|
||||||
|
templates?: SigningTemplate[]
|
||||||
|
campus?: WorkbenchCampus
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SigningTaskActionOptions extends SigningApiOptions {
|
||||||
|
expectedRowVersion?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type BackendSigningTaskStatus =
|
||||||
|
'CREATED' | 'WAITING_SIGN' | 'SIGNED' | 'EXPIRED' | 'VOIDED' | 'GENERATING' | 'FAILED'
|
||||||
|
|
||||||
|
export type BackendSigningMethod = 'PAD' | 'SMS'
|
||||||
|
|
||||||
|
export type BackendVisitType = 'OUTPATIENT' | 'INPATIENT' | 'CHECKUP' | string
|
||||||
|
|
||||||
|
export interface ApiPageResponse<T> {
|
||||||
|
records: T[]
|
||||||
|
page: number
|
||||||
|
size: number
|
||||||
|
total: number
|
||||||
|
pages: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PatientResponseDto {
|
||||||
|
id: string
|
||||||
|
externalPatientId: string
|
||||||
|
name: string
|
||||||
|
sex: string
|
||||||
|
birthDate: string | null
|
||||||
|
age: number
|
||||||
|
idCardMasked: string | null
|
||||||
|
phoneMasked: string | null
|
||||||
|
sourceSystem: string
|
||||||
|
campusId: string
|
||||||
|
createdAt: string
|
||||||
|
updatedAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VisitResponseDto {
|
||||||
|
id: string
|
||||||
|
patientId: string
|
||||||
|
visitNo: string
|
||||||
|
visitType: BackendVisitType
|
||||||
|
departmentId: string | null
|
||||||
|
departmentName: string
|
||||||
|
doctorName: string | null
|
||||||
|
visitedAt: string
|
||||||
|
campusId: string
|
||||||
|
sourceSystem: string
|
||||||
|
createdAt: string
|
||||||
|
updatedAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AvailableTemplateVersionResponseDto {
|
||||||
|
templateId: string
|
||||||
|
templateVersionId: string
|
||||||
|
templateCode: string
|
||||||
|
templateName: string
|
||||||
|
category: string
|
||||||
|
campusId: string
|
||||||
|
departmentId: string | null
|
||||||
|
versionNo: string
|
||||||
|
contentSha256: string
|
||||||
|
effectiveAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SignTaskPatientSnapshotDto {
|
||||||
|
patientId: string
|
||||||
|
name: string
|
||||||
|
sex: string
|
||||||
|
age: number
|
||||||
|
birthDate: string | null
|
||||||
|
idCardMasked: string | null
|
||||||
|
phoneMasked: string | null
|
||||||
|
sourceSystem: string
|
||||||
|
capturedAt: string
|
||||||
|
schemaVersion: string
|
||||||
|
contentHash: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SignTaskVisitSnapshotDto {
|
||||||
|
visitNo: string
|
||||||
|
visitType: BackendVisitType
|
||||||
|
departmentName: string
|
||||||
|
doctorName: string | null
|
||||||
|
visitedAt: string
|
||||||
|
sourceSystem: string
|
||||||
|
capturedAt: string
|
||||||
|
schemaVersion: string
|
||||||
|
contentHash: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SignTaskResponseDto {
|
||||||
|
id: string
|
||||||
|
templateVersionId: string
|
||||||
|
templateVersionNumber: number
|
||||||
|
signMethod: BackendSigningMethod
|
||||||
|
status: BackendSigningTaskStatus
|
||||||
|
patientSnapshot: SignTaskPatientSnapshotDto
|
||||||
|
visitSnapshot: SignTaskVisitSnapshotDto
|
||||||
|
campusId: string
|
||||||
|
departmentId: string | null
|
||||||
|
patientSnapshotSchemaVersion: string
|
||||||
|
patientSnapshotCapturedAt: string
|
||||||
|
patientSnapshotHash: string
|
||||||
|
visitSnapshotSchemaVersion: string
|
||||||
|
visitSnapshotCapturedAt: string
|
||||||
|
visitSnapshotHash: string
|
||||||
|
templateContentSha256: string
|
||||||
|
templateFileSha256: string
|
||||||
|
smsMobileMasked: string | null
|
||||||
|
signedAt: string | null
|
||||||
|
expiredAt: string | null
|
||||||
|
voidedAt: string | null
|
||||||
|
voidReason: string | null
|
||||||
|
createdAt: string
|
||||||
|
createdBy: string
|
||||||
|
updatedAt: string
|
||||||
|
updatedBy: string
|
||||||
|
rowVersion: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SignTaskActionRequest {
|
||||||
|
reason?: string
|
||||||
|
expectedRowVersion?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SignTaskSendPreparationResponseDto {
|
||||||
|
taskId: string
|
||||||
|
status: BackendSigningTaskStatus
|
||||||
|
signMethod: BackendSigningMethod
|
||||||
|
intentCreated: boolean
|
||||||
|
preparedAt: string
|
||||||
|
rowVersion: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SignTaskEventResponseDto {
|
||||||
|
id: string
|
||||||
|
taskId: string
|
||||||
|
eventType: string
|
||||||
|
fromStatus: string | null
|
||||||
|
toStatus: string | null
|
||||||
|
actorType: string | null
|
||||||
|
actorSubject: string | null
|
||||||
|
operatorId: string | null
|
||||||
|
eventTime: string
|
||||||
|
clientIp: string | null
|
||||||
|
userAgent: string | null
|
||||||
|
traceId: string | null
|
||||||
|
requestId: string | null
|
||||||
|
channel: string | null
|
||||||
|
attemptNo: number | null
|
||||||
|
reason: string | null
|
||||||
|
fromRowVersion: number | null
|
||||||
|
toRowVersion: number | null
|
||||||
|
detail: Record<string, unknown> | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SigningTaskEvent {
|
||||||
|
id: string
|
||||||
|
time: string
|
||||||
|
text: string
|
||||||
|
eventType: string
|
||||||
|
actorSubject?: string
|
||||||
|
reason?: string
|
||||||
|
channel?: string
|
||||||
|
attemptNo?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ApiResponseOf<T> = ApiResponse<T>
|
||||||
|
|||||||
@@ -32,7 +32,6 @@ const {
|
|||||||
method,
|
method,
|
||||||
patient,
|
patient,
|
||||||
patientId,
|
patientId,
|
||||||
patientPhoneRevealed,
|
|
||||||
resetForNextTask,
|
resetForNextTask,
|
||||||
resetForm,
|
resetForm,
|
||||||
selectMethod,
|
selectMethod,
|
||||||
@@ -42,10 +41,8 @@ const {
|
|||||||
selectedTemplateId,
|
selectedTemplateId,
|
||||||
selectedVisit,
|
selectedVisit,
|
||||||
selectedVisitId,
|
selectedVisitId,
|
||||||
smsPhone,
|
|
||||||
submit: submitForm,
|
submit: submitForm,
|
||||||
submitting,
|
submitting,
|
||||||
togglePhone,
|
|
||||||
visits,
|
visits,
|
||||||
} = useSigningTaskForm({
|
} = useSigningTaskForm({
|
||||||
campus: computed(() => appStore.selectedCampus),
|
campus: computed(() => appStore.selectedCampus),
|
||||||
@@ -61,7 +58,7 @@ async function submitTask(keepOpen: boolean) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
emit('created', task)
|
emit('created', task)
|
||||||
ElMessage.success(`已创建 1 个签署任务${task.method === 'sms' ? ',短信已发送' : ''}`)
|
ElMessage.success(`已创建 1 个签署任务${task.method === 'sms' ? ',已生成短信投递意图' : ''}`)
|
||||||
|
|
||||||
if (keepOpen) {
|
if (keepOpen) {
|
||||||
resetForNextTask()
|
resetForNextTask()
|
||||||
@@ -141,10 +138,7 @@ watch(
|
|||||||
<span class="patient-field"><small>身份证号</small>{{ patient.idCard }}</span>
|
<span class="patient-field"><small>身份证号</small>{{ patient.idCard }}</span>
|
||||||
<span class="patient-field">
|
<span class="patient-field">
|
||||||
<small>手机号</small>
|
<small>手机号</small>
|
||||||
{{ patientPhoneRevealed ? patient.phone : maskPhone(patient.phone) }}
|
{{ maskPhone(patient.phone) }}
|
||||||
<button type="button" class="inline-button" @click="togglePhone">
|
|
||||||
{{ patientPhoneRevealed ? '隐藏明文' : '显示明文' }}
|
|
||||||
</button>
|
|
||||||
</span>
|
</span>
|
||||||
<span class="patient-field"><small>患者 ID</small>{{ patient.id }}</span>
|
<span class="patient-field"><small>患者 ID</small>{{ patient.id }}</span>
|
||||||
<span v-if="selectedVisit?.isCurrent" class="in-hospital">在院</span>
|
<span v-if="selectedVisit?.isCurrent" class="in-hospital">在院</span>
|
||||||
@@ -289,15 +283,9 @@ watch(
|
|||||||
<div class="sms-row">
|
<div class="sms-row">
|
||||||
<span class="sms-icon">📱</span>
|
<span class="sms-icon">📱</span>
|
||||||
<strong>短信将发送至</strong>
|
<strong>短信将发送至</strong>
|
||||||
<input
|
<span>{{ patient ? maskPhone(patient.phone) : '定位患者后显示' }}</span>
|
||||||
v-model="smsPhone"
|
|
||||||
placeholder="请确认手机号"
|
|
||||||
aria-label="短信接收手机号"
|
|
||||||
@input="errors.phone = ''"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<p>可修改为患者其他号码,修改将记录留痕</p>
|
<p>使用患者权威记录中的手机号,具体投递结果以服务端返回为准。</p>
|
||||||
<p v-if="errors.phone" class="field-error">{{ errors.phone }}</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -537,15 +525,6 @@ watch(
|
|||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.inline-button {
|
|
||||||
padding: 2px 5px;
|
|
||||||
color: var(--brand);
|
|
||||||
font-size: 11px;
|
|
||||||
background: transparent;
|
|
||||||
border: 1px solid var(--brand);
|
|
||||||
border-radius: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.in-hospital {
|
.in-hospital {
|
||||||
padding: 1px 7px;
|
padding: 1px 7px;
|
||||||
color: #fff;
|
color: #fff;
|
||||||
@@ -840,10 +819,6 @@ watch(
|
|||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sms-row input {
|
|
||||||
width: 170px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sms-confirm p {
|
.sms-confirm p {
|
||||||
padding-left: 28px;
|
padding-left: 28px;
|
||||||
margin: 6px 0 0;
|
margin: 6px 0 0;
|
||||||
|
|||||||
@@ -35,8 +35,6 @@ export function useSigningTaskForm(options: UseSigningTaskFormOptions) {
|
|||||||
const selectedTemplateId = ref('')
|
const selectedTemplateId = ref('')
|
||||||
const selectedVisitId = ref('')
|
const selectedVisitId = ref('')
|
||||||
const method = ref<SigningMethod>('pad')
|
const method = ref<SigningMethod>('pad')
|
||||||
const smsPhone = ref('')
|
|
||||||
const patientPhoneRevealed = ref(false)
|
|
||||||
const loadingTemplates = ref(false)
|
const loadingTemplates = ref(false)
|
||||||
const locating = ref(false)
|
const locating = ref(false)
|
||||||
const submitting = ref(false)
|
const submitting = ref(false)
|
||||||
@@ -45,7 +43,6 @@ export function useSigningTaskForm(options: UseSigningTaskFormOptions) {
|
|||||||
patient: '',
|
patient: '',
|
||||||
visit: '',
|
visit: '',
|
||||||
document: '',
|
document: '',
|
||||||
phone: '',
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const selectedVisit = computed(
|
const selectedVisit = computed(
|
||||||
@@ -107,7 +104,6 @@ export function useSigningTaskForm(options: UseSigningTaskFormOptions) {
|
|||||||
errors.patient = ''
|
errors.patient = ''
|
||||||
errors.visit = ''
|
errors.visit = ''
|
||||||
errors.document = ''
|
errors.document = ''
|
||||||
errors.phone = ''
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function resetForm() {
|
function resetForm() {
|
||||||
@@ -120,8 +116,6 @@ export function useSigningTaskForm(options: UseSigningTaskFormOptions) {
|
|||||||
selectedTemplateId.value = initialTemplate?.id ?? ''
|
selectedTemplateId.value = initialTemplate?.id ?? ''
|
||||||
selectedVisitId.value = ''
|
selectedVisitId.value = ''
|
||||||
method.value = initialTemplate?.supportedMethods[0] ?? 'pad'
|
method.value = initialTemplate?.supportedMethods[0] ?? 'pad'
|
||||||
smsPhone.value = ''
|
|
||||||
patientPhoneRevealed.value = false
|
|
||||||
clearErrors()
|
clearErrors()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -171,7 +165,6 @@ export function useSigningTaskForm(options: UseSigningTaskFormOptions) {
|
|||||||
|
|
||||||
if (!profile) {
|
if (!profile) {
|
||||||
errors.patient = '未找到该患者,请核对输入内容'
|
errors.patient = '未找到该患者,请核对输入内容'
|
||||||
smsPhone.value = ''
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -179,7 +172,6 @@ export function useSigningTaskForm(options: UseSigningTaskFormOptions) {
|
|||||||
visits.value = profile.visits
|
visits.value = profile.visits
|
||||||
selectedVisitId.value =
|
selectedVisitId.value =
|
||||||
profile.visits.find((visit) => visit.isCurrent)?.id ?? profile.visits[0]?.id ?? ''
|
profile.visits.find((visit) => visit.isCurrent)?.id ?? profile.visits[0]?.id ?? ''
|
||||||
smsPhone.value = profile.phone
|
|
||||||
} catch {
|
} catch {
|
||||||
errors.patient = '患者信息查询失败,请稍后重试'
|
errors.patient = '患者信息查询失败,请稍后重试'
|
||||||
} finally {
|
} finally {
|
||||||
@@ -207,7 +199,6 @@ export function useSigningTaskForm(options: UseSigningTaskFormOptions) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
method.value = nextMethod
|
method.value = nextMethod
|
||||||
errors.phone = ''
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function maskPhone(value: string) {
|
function maskPhone(value: string) {
|
||||||
@@ -218,11 +209,6 @@ export function useSigningTaskForm(options: UseSigningTaskFormOptions) {
|
|||||||
return `${value.slice(0, 3)}****${value.slice(-4)}`
|
return `${value.slice(0, 3)}****${value.slice(-4)}`
|
||||||
}
|
}
|
||||||
|
|
||||||
function togglePhone() {
|
|
||||||
patientPhoneRevealed.value = !patientPhoneRevealed.value
|
|
||||||
ElMessage.info('明文查看已记录审计日志')
|
|
||||||
}
|
|
||||||
|
|
||||||
function validate() {
|
function validate() {
|
||||||
clearErrors()
|
clearErrors()
|
||||||
let valid = true
|
let valid = true
|
||||||
@@ -242,11 +228,6 @@ export function useSigningTaskForm(options: UseSigningTaskFormOptions) {
|
|||||||
valid = false
|
valid = false
|
||||||
}
|
}
|
||||||
|
|
||||||
if (method.value === 'sms' && !smsPhone.value.trim()) {
|
|
||||||
errors.phone = '请确认短信接收手机号'
|
|
||||||
valid = false
|
|
||||||
}
|
|
||||||
|
|
||||||
return valid
|
return valid
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -264,11 +245,11 @@ export function useSigningTaskForm(options: UseSigningTaskFormOptions) {
|
|||||||
visitId: selectedVisit.value.id,
|
visitId: selectedVisit.value.id,
|
||||||
documentId: selectedTemplate.value.id,
|
documentId: selectedTemplate.value.id,
|
||||||
documentName: selectedTemplate.value.name,
|
documentName: selectedTemplate.value.name,
|
||||||
|
templateVersionId: selectedTemplate.value.versionId,
|
||||||
method: method.value,
|
method: method.value,
|
||||||
visitType: selectedVisit.value.type,
|
visitType: selectedVisit.value.type,
|
||||||
visitNo: selectedVisit.value.visitNo,
|
visitNo: selectedVisit.value.visitNo,
|
||||||
department: selectedVisit.value.department,
|
department: selectedVisit.value.department,
|
||||||
phone: method.value === 'sms' ? smsPhone.value.trim() : undefined,
|
|
||||||
})
|
})
|
||||||
} finally {
|
} finally {
|
||||||
submitting.value = false
|
submitting.value = false
|
||||||
@@ -287,7 +268,6 @@ export function useSigningTaskForm(options: UseSigningTaskFormOptions) {
|
|||||||
method,
|
method,
|
||||||
patient,
|
patient,
|
||||||
patientId,
|
patientId,
|
||||||
patientPhoneRevealed,
|
|
||||||
resetForNextTask,
|
resetForNextTask,
|
||||||
resetForm,
|
resetForm,
|
||||||
selectMethod,
|
selectMethod,
|
||||||
@@ -297,11 +277,9 @@ export function useSigningTaskForm(options: UseSigningTaskFormOptions) {
|
|||||||
selectedTemplateId,
|
selectedTemplateId,
|
||||||
selectedVisit,
|
selectedVisit,
|
||||||
selectedVisitId,
|
selectedVisitId,
|
||||||
smsPhone,
|
|
||||||
submit,
|
submit,
|
||||||
submitting,
|
submitting,
|
||||||
templates,
|
templates,
|
||||||
togglePhone,
|
|
||||||
visits,
|
visits,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -104,8 +104,10 @@ function showEditMessage(template: DocumentTemplate) {
|
|||||||
function toSigningTemplate(template: DocumentTemplate): SigningTemplate {
|
function toSigningTemplate(template: DocumentTemplate): SigningTemplate {
|
||||||
return {
|
return {
|
||||||
id: template.id,
|
id: template.id,
|
||||||
|
versionId: template.id,
|
||||||
name: template.name,
|
name: template.name,
|
||||||
code: template.code,
|
code: template.code,
|
||||||
|
version: template.version,
|
||||||
department: template.department,
|
department: template.department,
|
||||||
category: template.category,
|
category: template.category,
|
||||||
description: template.description,
|
description: template.description,
|
||||||
|
|||||||
Reference in New Issue
Block a user