feat(signing): 接入患者与任务真实接口

This commit is contained in:
yelan
2026-08-31 16:08:17 +08:00
parent 1bc075d5d6
commit 40b0a5d040
5 changed files with 805 additions and 91 deletions

View File

@@ -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]

View File

@@ -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>

View File

@@ -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;

View File

@@ -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,
} }
} }

View File

@@ -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,