Compare commits
3 Commits
1bc075d5d6
...
f4f40f0596
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f4f40f0596 | ||
|
|
9c1b2f53a6 | ||
|
|
40b0a5d040 |
@@ -84,7 +84,7 @@ TanStack Vue Query、PDF 预览、报表图表、自动化测试和签字板适
|
||||
|
||||
## 当前状态
|
||||
|
||||
已完成 Vite 基础初始化、路由、Pinia、原型主题 CSS、登录页、首页、签署工作台第一版 Mock 和文档库管理页面。报表、用户权限、文档权限和系统设置仍保留页面骨架;登录和签署流程使用演示数据,尚未接入真实后端。
|
||||
已完成 Vite 基础初始化、路由、Pinia、原型主题 CSS、登录页、首页、签署工作台第一版 Mock 和文档库管理页面。报表、用户权限、文档权限和系统设置仍保留页面骨架。登录接口和签署工作台的患者、就诊、模板、任务及审计查询/部分任务操作已接入 MEDISIGN 后端;其余页面仍使用 Mock 或占位实现。
|
||||
|
||||
## 当前路由结构
|
||||
|
||||
@@ -125,8 +125,6 @@ API 模块与页面按业务域对应。工作台页面使用 `api/workbench`
|
||||
- `api/management/document-permissions.ts`
|
||||
- `api/management/settings.ts`
|
||||
|
||||
API 请求和响应类型按业务域放在 `api/workbench/types.ts`、`api/management/types.ts`;页面展示和交互类型放在对应 View 目录的 `types.ts`;全局复用类型放在 `types/common.ts`。
|
||||
|
||||
所有接口统一使用 `utils/request.ts` 导出的单例请求实例。登录接口已接入 MEDISIGN 后端:
|
||||
|
||||
- `POST /api/v1/auth/login`:账号密码登录;
|
||||
@@ -134,8 +132,20 @@ API 请求和响应类型按业务域放在 `api/workbench/types.ts`、`api/mana
|
||||
- `POST /api/v1/auth/logout`:注销当前会话;
|
||||
- 后续请求自动携带 `X-Token` 请求头。
|
||||
|
||||
开发环境默认使用 `/api` 作为同源接口前缀,Vite 会将 `/api` 转发到 `https://ipad.shenynet.com`,因此浏览器不会直接跨域请求后端。代理配置位于 `vite.config.ts`,不改写 `/api/v1/...` 路径。修改代理配置后需要重启 Vite 开发服务。
|
||||
签署工作台已接入的真实接口包括:
|
||||
|
||||
生产环境不会使用 Vite 的开发代理,需要在 Nginx 或其他网关中配置同样的 `/api` 反向代理。
|
||||
- `GET /api/v1/patients`、`GET /api/v1/patients/{id}`:患者定位;
|
||||
- `GET /api/v1/patients/{id}/visits`、`GET /api/v1/visits`:就诊关联;
|
||||
- `GET /api/v1/templates/available-versions`:可发起模板版本;
|
||||
- `GET /api/v1/sign-tasks`、`GET /api/v1/sign-tasks/{id}`:任务列表和详情;
|
||||
- `POST /api/v1/sign-tasks`、`POST /api/v1/sign-tasks/{id}/prepare-send`:创建任务和准备投递;
|
||||
- `POST /api/v1/sign-tasks/{id}/void`、`/resend`、`/reopen`:作废、短信重发和重新开启;
|
||||
- `GET /api/v1/sign-tasks/{id}/events`:操作审计事件。
|
||||
|
||||
除登录外,当前业务 API 模块默认返回 mock 数据,设置 `VITE_USE_MOCK=false` 后切换为真实接口。使用本地开发代理时,`VITE_API_BASE_URL` 应填写 `/api`;如果改为直连后端,则需要后端配置允许当前前端源的 CORS。
|
||||
API 请求和响应类型按业务域放在 `api/workbench/types.ts`、`api/management/types.ts`;页面展示和交互类型放在对应 View 目录的 `types.ts`;全局复用类型放在 `types/common.ts`。页面提交模型会在 API 边界转换为后端 DTO,不向后端发送患者快照、文档名称或明文手机号等页面字段。
|
||||
|
||||
开发环境默认使用 `/api` 作为同源接口前缀,Vite 会将 `/api` 转发到 `https://ipad.shenynet.com`,因此浏览器不会直接跨域请求后端。代理配置位于 `vite.config.ts`,不改写 `/api/v1/...` 路径。修改代理或环境变量后需要重启 Vite 开发服务。
|
||||
|
||||
生产环境不会使用 Vite 的开发代理,需要在 Nginx 或其他网关中配置同样的 `/api` 反向代理。除登录外,当前业务 API 模块默认返回 Mock 数据,设置 `VITE_USE_MOCK=false` 后切换签署工作台及其他已实现 API 的真实接口。使用本地开发代理时,`VITE_API_BASE_URL` 应填写 `/api`;如果改为直连后端,则需要后端配置允许当前前端源的 CORS。
|
||||
|
||||
当前尚未接入的签署能力包括手写板设备桥接、线上签署页面/签名回调、PDF 原件下载、签名原图下载和打印服务。真实接口模式下这些演示按钮会隐藏或提示待接入;Mock 模式仍可用于演示完整交互。
|
||||
|
||||
@@ -1,14 +1,32 @@
|
||||
import axios from 'axios'
|
||||
|
||||
import { request } from '@/utils/request'
|
||||
|
||||
import type {
|
||||
ApiPageResponse,
|
||||
ApiResponseOf,
|
||||
AvailableTemplateVersionResponseDto,
|
||||
BackendSigningMethod,
|
||||
BackendSigningTaskStatus,
|
||||
CreateSigningTaskInput,
|
||||
CreateSigningTaskRequest,
|
||||
PatientProfile,
|
||||
PatientResponseDto,
|
||||
PatientSex,
|
||||
SignTaskActionRequest,
|
||||
SignTaskEventResponseDto,
|
||||
SignTaskResponseDto,
|
||||
SignTaskSendPreparationResponseDto,
|
||||
SigningApiOptions,
|
||||
SigningMethod,
|
||||
SigningTaskEvent,
|
||||
SigningTaskActionOptions,
|
||||
SigningTaskListResponse,
|
||||
SigningTaskQuery,
|
||||
SigningTaskRecord,
|
||||
SigningTaskStatus,
|
||||
SigningTemplate,
|
||||
VisitResponseDto,
|
||||
VisitType,
|
||||
} from './types'
|
||||
|
||||
@@ -117,8 +135,10 @@ const mockPatients: PatientProfile[] = [
|
||||
const mockTemplates: SigningTemplate[] = [
|
||||
{
|
||||
id: 'tpl-001',
|
||||
versionId: 'tpl-001-v2',
|
||||
name: '住院患者知情同意书',
|
||||
code: 'DOC-HOS-001',
|
||||
version: 'V2.0',
|
||||
department: '全院通用',
|
||||
category: '住院告知',
|
||||
description: '住院期间诊疗事项、风险与患者权利告知。',
|
||||
@@ -126,8 +146,10 @@ const mockTemplates: SigningTemplate[] = [
|
||||
},
|
||||
{
|
||||
id: 'tpl-002',
|
||||
versionId: 'tpl-002-v1',
|
||||
name: '住院须知及告知书',
|
||||
code: 'DOC-HOS-002',
|
||||
version: 'V1.0',
|
||||
department: '全院通用',
|
||||
category: '住院告知',
|
||||
description: '住院流程、陪护要求和患者须知确认。',
|
||||
@@ -135,8 +157,10 @@ const mockTemplates: SigningTemplate[] = [
|
||||
},
|
||||
{
|
||||
id: 'tpl-003',
|
||||
versionId: 'tpl-003-v3',
|
||||
name: '内镜检查知情同意书',
|
||||
code: 'DOC-DIG-001',
|
||||
version: 'V3.0',
|
||||
department: '消化内科',
|
||||
category: '检查告知',
|
||||
description: '内镜检查适应证、风险及替代方案告知。',
|
||||
@@ -144,8 +168,10 @@ const mockTemplates: SigningTemplate[] = [
|
||||
},
|
||||
{
|
||||
id: 'tpl-004',
|
||||
versionId: 'tpl-004-v1',
|
||||
name: 'MRI 磁共振检查知情同意书',
|
||||
code: 'DOC-RAD-001',
|
||||
version: 'V1.0',
|
||||
department: '放射科',
|
||||
category: '检查告知',
|
||||
description: '磁共振检查注意事项、禁忌证及风险告知。',
|
||||
@@ -153,8 +179,10 @@ const mockTemplates: SigningTemplate[] = [
|
||||
},
|
||||
{
|
||||
id: 'tpl-005',
|
||||
versionId: 'tpl-005-v2',
|
||||
name: '心脏介入手术知情同意书',
|
||||
code: 'DOC-CARD-001',
|
||||
version: 'V2.0',
|
||||
department: '心内科',
|
||||
category: '手术告知',
|
||||
description: '介入治疗方案、围术期风险及替代方案告知。',
|
||||
@@ -162,8 +190,10 @@ const mockTemplates: SigningTemplate[] = [
|
||||
},
|
||||
{
|
||||
id: 'tpl-006',
|
||||
versionId: 'tpl-006-v1',
|
||||
name: '体检报告发放及知情同意书',
|
||||
code: 'DOC-PE-001',
|
||||
version: 'V1.0',
|
||||
department: '健康管理中心',
|
||||
category: '体检告知',
|
||||
description: '体检报告领取方式、隐私保护和异常结果随访告知。',
|
||||
@@ -171,8 +201,10 @@ const mockTemplates: SigningTemplate[] = [
|
||||
},
|
||||
{
|
||||
id: 'tpl-007',
|
||||
versionId: 'tpl-007-v1',
|
||||
name: '患者授权委托书',
|
||||
code: 'DOC-GEN-003',
|
||||
version: 'V1.0',
|
||||
department: '医务处',
|
||||
category: '授权文书',
|
||||
description: '患者授权家属或代理人办理相关医疗事项。',
|
||||
@@ -183,6 +215,7 @@ const mockTemplates: SigningTemplate[] = [
|
||||
const mockRecords: SigningTaskRecord[] = [
|
||||
{
|
||||
id: 'task-001',
|
||||
templateVersionId: 'tpl-001-v2',
|
||||
campus: '本部院区',
|
||||
patientId: 'P202610001',
|
||||
patientName: '李某某',
|
||||
@@ -203,6 +236,7 @@ const mockRecords: SigningTaskRecord[] = [
|
||||
},
|
||||
{
|
||||
id: 'task-002',
|
||||
templateVersionId: 'tpl-004-v1',
|
||||
campus: '东院区',
|
||||
patientId: 'P202610002',
|
||||
patientName: '王某某',
|
||||
@@ -224,6 +258,7 @@ const mockRecords: SigningTaskRecord[] = [
|
||||
},
|
||||
{
|
||||
id: 'task-003',
|
||||
templateVersionId: 'tpl-003-v3',
|
||||
campus: '本部院区',
|
||||
patientId: 'P202610003',
|
||||
patientName: '赵某某',
|
||||
@@ -244,6 +279,7 @@ const mockRecords: SigningTaskRecord[] = [
|
||||
},
|
||||
{
|
||||
id: 'task-004',
|
||||
templateVersionId: 'tpl-002-v1',
|
||||
campus: '本部院区',
|
||||
patientId: 'P202610005',
|
||||
patientName: '陈志强',
|
||||
@@ -265,6 +301,7 @@ const mockRecords: SigningTaskRecord[] = [
|
||||
},
|
||||
{
|
||||
id: 'task-005',
|
||||
templateVersionId: 'tpl-006-v1',
|
||||
campus: '东院区',
|
||||
patientId: 'P202610004',
|
||||
patientName: '周雅琴',
|
||||
@@ -329,11 +366,325 @@ function addHours(value: string, hours: number) {
|
||||
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) {
|
||||
return request.get<SigningTaskListResponse>('/workbench/signing/tasks', {
|
||||
params: query,
|
||||
})
|
||||
const response = await request.get<ApiResponseOf<ApiPageResponse<SignTaskResponseDto>>>(
|
||||
'/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()
|
||||
@@ -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) {
|
||||
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) =>
|
||||
@@ -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) {
|
||||
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()
|
||||
@@ -411,40 +846,91 @@ export function getPatientProfile(keyword: string): Promise<PatientProfile | nul
|
||||
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) {
|
||||
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 })))
|
||||
}
|
||||
|
||||
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) {
|
||||
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 = {
|
||||
id: `task-${Date.now()}`,
|
||||
campus: payload.campus,
|
||||
patientId: payload.patientId,
|
||||
templateVersionId: input.templateVersionId,
|
||||
campus: input.campus,
|
||||
patientId: input.patientId,
|
||||
patientName: patient?.name ?? '演示患者',
|
||||
sex: patient?.sex ?? '男',
|
||||
age: patient?.age ?? 40,
|
||||
visitNo: payload.visitNo,
|
||||
visitType: payload.visitType,
|
||||
visitNo: input.visitNo,
|
||||
visitType: input.visitType,
|
||||
visitDate:
|
||||
patient?.visits.find((visit) => visit.id === payload.visitId)?.visitDate ?? '2026-08-27',
|
||||
documentId: payload.documentId,
|
||||
documentName: payload.documentName,
|
||||
department: payload.department,
|
||||
patient?.visits.find((visit) => visit.id === input.visitId)?.visitDate ?? '2026-08-27',
|
||||
documentId: input.documentId,
|
||||
documentName: input.documentName,
|
||||
department: input.department,
|
||||
signerName: patient?.name ?? '演示患者',
|
||||
method: payload.method,
|
||||
method: input.method,
|
||||
status: 'pending',
|
||||
source: '工作台发起',
|
||||
expiresAt: addHours('2026-08-27 12:00', 48),
|
||||
updatedAt: formatMockDate(),
|
||||
rowVersion: 1,
|
||||
backendStatus: 'WAITING_SIGN',
|
||||
}
|
||||
|
||||
mockRecords.unshift(task)
|
||||
@@ -456,7 +942,7 @@ export function updateSigningTaskMethod(
|
||||
method: SigningMethod,
|
||||
): Promise<SigningTaskRecord | null> {
|
||||
if (!useMockData) {
|
||||
return request.put<SigningTaskRecord>('/workbench/signing/tasks/' + id + '/method', { method })
|
||||
throw new Error('当前后端未提供切换签署方式接口,请作废后重新创建任务')
|
||||
}
|
||||
|
||||
const task = mockRecords.find((record) => record.id === id)
|
||||
@@ -475,9 +961,7 @@ export function completeSigningTask(
|
||||
signatureDataUrl: string,
|
||||
): Promise<SigningTaskRecord | null> {
|
||||
if (!useMockData) {
|
||||
return request.post<SigningTaskRecord>('/workbench/signing/tasks/' + id + '/complete', {
|
||||
signatureDataUrl,
|
||||
})
|
||||
throw new Error('真实签署需要通过签署投递与文件上传接口完成')
|
||||
}
|
||||
|
||||
const task = mockRecords.find((record) => record.id === id)
|
||||
@@ -493,9 +977,16 @@ export function completeSigningTask(
|
||||
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) {
|
||||
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)
|
||||
@@ -511,9 +1002,17 @@ export function reopenSigningTask(id: string): Promise<SigningTaskRecord | null>
|
||||
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) {
|
||||
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)
|
||||
@@ -529,9 +1028,17 @@ export function voidSigningTask(id: string, reason: string): Promise<SigningTask
|
||||
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) {
|
||||
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)
|
||||
@@ -547,6 +1054,60 @@ export function resendSigningSms(id: string): Promise<SigningTaskRecord | null>
|
||||
return Promise.resolve(cloneTask(task))
|
||||
}
|
||||
|
||||
export async function getSigningTaskEvents(id: string): Promise<SigningTaskEvent[]> {
|
||||
if (!useMockData) {
|
||||
const response = await request.get<ApiResponseOf<SignTaskEventResponseDto[]>>(
|
||||
`/v1/sign-tasks/${encodeURIComponent(id)}/events`,
|
||||
)
|
||||
|
||||
return unwrapApiResponse(response).map(mapSigningEvent)
|
||||
}
|
||||
|
||||
const task = mockRecords.find((record) => record.id === id)
|
||||
|
||||
if (!task) {
|
||||
return []
|
||||
}
|
||||
|
||||
const events: SigningTaskEvent[] = [
|
||||
{
|
||||
id: `${task.id}-created`,
|
||||
time: task.updatedAt,
|
||||
text: `张文静发起签署任务(${task.method === 'pad' ? '手写板' : '线上短信'})`,
|
||||
eventType: 'TASK_CREATED',
|
||||
},
|
||||
]
|
||||
|
||||
if (task.status === 'signing') {
|
||||
events.push({
|
||||
id: `${task.id}-signing`,
|
||||
time: task.updatedAt,
|
||||
text: '已打开签名采集,等待患者完成签名',
|
||||
eventType: 'SIGNING_STARTED',
|
||||
})
|
||||
}
|
||||
|
||||
if (task.signedAt) {
|
||||
events.push({
|
||||
id: `${task.id}-signed`,
|
||||
time: task.signedAt,
|
||||
text: `患者 ${task.patientName} 完成签名,系统自动合成并双留存`,
|
||||
eventType: 'SIGNED',
|
||||
})
|
||||
}
|
||||
|
||||
if (task.status === 'void') {
|
||||
events.push({
|
||||
id: `${task.id}-voided`,
|
||||
time: task.updatedAt,
|
||||
text: `任务已作废${task.voidReason ? `,原因:${task.voidReason}` : ''},全程可审计`,
|
||||
eventType: 'VOIDED',
|
||||
})
|
||||
}
|
||||
|
||||
return events
|
||||
}
|
||||
|
||||
export function getSigningStatusLabel(status: SigningTaskStatus) {
|
||||
const labels: Record<SigningTaskStatus, string> = {
|
||||
pending: '待签署',
|
||||
@@ -555,6 +1116,7 @@ export function getSigningStatusLabel(status: SigningTaskStatus) {
|
||||
rejected: '已拒签',
|
||||
expired: '已超时',
|
||||
void: '已作废',
|
||||
failed: '处理失败',
|
||||
}
|
||||
|
||||
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 = '本部院区' | '东院区' | '西院区'
|
||||
|
||||
@@ -9,23 +9,31 @@ export interface WorkbenchOverviewQuery {
|
||||
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 {
|
||||
id: string
|
||||
type: VisitType
|
||||
visitNo: string
|
||||
department: string
|
||||
departmentId?: string | null
|
||||
doctorName?: string
|
||||
visitDate: string
|
||||
visitedAt?: string
|
||||
campusId?: string
|
||||
sourceSystem?: string
|
||||
isCurrent?: boolean
|
||||
}
|
||||
|
||||
export interface PatientProfile {
|
||||
id: string
|
||||
name: string
|
||||
sex: '男' | '女'
|
||||
sex: PatientSex
|
||||
age: number
|
||||
idCard: string
|
||||
phone: string
|
||||
@@ -34,9 +42,13 @@ export interface PatientProfile {
|
||||
|
||||
export interface SigningTemplate {
|
||||
id: string
|
||||
versionId: string
|
||||
name: string
|
||||
code: string
|
||||
version: string
|
||||
department: string
|
||||
departmentId?: string | null
|
||||
campusId?: string
|
||||
category: string
|
||||
description: string
|
||||
supportedMethods: SigningMethod[]
|
||||
@@ -108,9 +120,10 @@ export interface SigningTaskQuery extends PageQuery {
|
||||
export type SigningDateRange = 'today' | 'yesterday' | '3d' | '7d' | 'all'
|
||||
|
||||
export interface SigningTaskRecord extends WorkbenchTask {
|
||||
templateVersionId: string
|
||||
campus: WorkbenchCampus
|
||||
patientId: string
|
||||
sex: '男' | '女'
|
||||
sex: PatientSex
|
||||
age: number
|
||||
documentId: string
|
||||
signerName: string
|
||||
@@ -119,6 +132,8 @@ export interface SigningTaskRecord extends WorkbenchTask {
|
||||
expiresAt: string
|
||||
visitType: VisitType
|
||||
visitDate: string
|
||||
rowVersion?: number
|
||||
backendStatus?: BackendSigningTaskStatus
|
||||
signatureDataUrl?: string
|
||||
signedAt?: string
|
||||
voidReason?: string
|
||||
@@ -126,15 +141,197 @@ export interface SigningTaskRecord extends WorkbenchTask {
|
||||
|
||||
export type SigningTaskListResponse = PageResult<SigningTaskRecord>
|
||||
|
||||
export interface CreateSigningTaskRequest {
|
||||
/** 页面提交模型。真实接口请求会在 API 边界转换为 CreateSigningTaskRequest。 */
|
||||
export interface CreateSigningTaskInput {
|
||||
campus: WorkbenchCampus
|
||||
patientId: string
|
||||
visitId: string
|
||||
documentId: string
|
||||
documentName: string
|
||||
templateVersionId: string
|
||||
method: SigningMethod
|
||||
visitType: VisitType
|
||||
visitNo: 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,
|
||||
patient,
|
||||
patientId,
|
||||
patientPhoneRevealed,
|
||||
resetForNextTask,
|
||||
resetForm,
|
||||
selectMethod,
|
||||
@@ -42,10 +41,8 @@ const {
|
||||
selectedTemplateId,
|
||||
selectedVisit,
|
||||
selectedVisitId,
|
||||
smsPhone,
|
||||
submit: submitForm,
|
||||
submitting,
|
||||
togglePhone,
|
||||
visits,
|
||||
} = useSigningTaskForm({
|
||||
campus: computed(() => appStore.selectedCampus),
|
||||
@@ -61,7 +58,7 @@ async function submitTask(keepOpen: boolean) {
|
||||
}
|
||||
|
||||
emit('created', task)
|
||||
ElMessage.success(`已创建 1 个签署任务${task.method === 'sms' ? ',短信已发送' : ''}`)
|
||||
ElMessage.success(`已创建 1 个签署任务${task.method === 'sms' ? ',已生成短信投递意图' : ''}`)
|
||||
|
||||
if (keepOpen) {
|
||||
resetForNextTask()
|
||||
@@ -141,10 +138,7 @@ watch(
|
||||
<span class="patient-field"><small>身份证号</small>{{ patient.idCard }}</span>
|
||||
<span class="patient-field">
|
||||
<small>手机号</small>
|
||||
{{ patientPhoneRevealed ? patient.phone : maskPhone(patient.phone) }}
|
||||
<button type="button" class="inline-button" @click="togglePhone">
|
||||
{{ patientPhoneRevealed ? '隐藏明文' : '显示明文' }}
|
||||
</button>
|
||||
{{ maskPhone(patient.phone) }}
|
||||
</span>
|
||||
<span class="patient-field"><small>患者 ID</small>{{ patient.id }}</span>
|
||||
<span v-if="selectedVisit?.isCurrent" class="in-hospital">在院</span>
|
||||
@@ -289,15 +283,9 @@ watch(
|
||||
<div class="sms-row">
|
||||
<span class="sms-icon">📱</span>
|
||||
<strong>短信将发送至</strong>
|
||||
<input
|
||||
v-model="smsPhone"
|
||||
placeholder="请确认手机号"
|
||||
aria-label="短信接收手机号"
|
||||
@input="errors.phone = ''"
|
||||
/>
|
||||
<span>{{ patient ? maskPhone(patient.phone) : '定位患者后显示' }}</span>
|
||||
</div>
|
||||
<p>可修改为患者其他号码,修改将记录留痕</p>
|
||||
<p v-if="errors.phone" class="field-error">{{ errors.phone }}</p>
|
||||
<p>使用患者权威记录中的手机号,具体投递结果以服务端返回为准。</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -537,15 +525,6 @@ watch(
|
||||
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 {
|
||||
padding: 1px 7px;
|
||||
color: #fff;
|
||||
@@ -840,10 +819,6 @@ watch(
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.sms-row input {
|
||||
width: 170px;
|
||||
}
|
||||
|
||||
.sms-confirm p {
|
||||
padding-left: 28px;
|
||||
margin: 6px 0 0;
|
||||
|
||||
@@ -35,8 +35,6 @@ export function useSigningTaskForm(options: UseSigningTaskFormOptions) {
|
||||
const selectedTemplateId = ref('')
|
||||
const selectedVisitId = ref('')
|
||||
const method = ref<SigningMethod>('pad')
|
||||
const smsPhone = ref('')
|
||||
const patientPhoneRevealed = ref(false)
|
||||
const loadingTemplates = ref(false)
|
||||
const locating = ref(false)
|
||||
const submitting = ref(false)
|
||||
@@ -45,7 +43,6 @@ export function useSigningTaskForm(options: UseSigningTaskFormOptions) {
|
||||
patient: '',
|
||||
visit: '',
|
||||
document: '',
|
||||
phone: '',
|
||||
})
|
||||
|
||||
const selectedVisit = computed(
|
||||
@@ -107,7 +104,6 @@ export function useSigningTaskForm(options: UseSigningTaskFormOptions) {
|
||||
errors.patient = ''
|
||||
errors.visit = ''
|
||||
errors.document = ''
|
||||
errors.phone = ''
|
||||
}
|
||||
|
||||
function resetForm() {
|
||||
@@ -120,8 +116,6 @@ export function useSigningTaskForm(options: UseSigningTaskFormOptions) {
|
||||
selectedTemplateId.value = initialTemplate?.id ?? ''
|
||||
selectedVisitId.value = ''
|
||||
method.value = initialTemplate?.supportedMethods[0] ?? 'pad'
|
||||
smsPhone.value = ''
|
||||
patientPhoneRevealed.value = false
|
||||
clearErrors()
|
||||
}
|
||||
|
||||
@@ -171,7 +165,6 @@ export function useSigningTaskForm(options: UseSigningTaskFormOptions) {
|
||||
|
||||
if (!profile) {
|
||||
errors.patient = '未找到该患者,请核对输入内容'
|
||||
smsPhone.value = ''
|
||||
return
|
||||
}
|
||||
|
||||
@@ -179,7 +172,6 @@ export function useSigningTaskForm(options: UseSigningTaskFormOptions) {
|
||||
visits.value = profile.visits
|
||||
selectedVisitId.value =
|
||||
profile.visits.find((visit) => visit.isCurrent)?.id ?? profile.visits[0]?.id ?? ''
|
||||
smsPhone.value = profile.phone
|
||||
} catch {
|
||||
errors.patient = '患者信息查询失败,请稍后重试'
|
||||
} finally {
|
||||
@@ -207,7 +199,6 @@ export function useSigningTaskForm(options: UseSigningTaskFormOptions) {
|
||||
}
|
||||
|
||||
method.value = nextMethod
|
||||
errors.phone = ''
|
||||
}
|
||||
|
||||
function maskPhone(value: string) {
|
||||
@@ -218,11 +209,6 @@ export function useSigningTaskForm(options: UseSigningTaskFormOptions) {
|
||||
return `${value.slice(0, 3)}****${value.slice(-4)}`
|
||||
}
|
||||
|
||||
function togglePhone() {
|
||||
patientPhoneRevealed.value = !patientPhoneRevealed.value
|
||||
ElMessage.info('明文查看已记录审计日志')
|
||||
}
|
||||
|
||||
function validate() {
|
||||
clearErrors()
|
||||
let valid = true
|
||||
@@ -242,11 +228,6 @@ export function useSigningTaskForm(options: UseSigningTaskFormOptions) {
|
||||
valid = false
|
||||
}
|
||||
|
||||
if (method.value === 'sms' && !smsPhone.value.trim()) {
|
||||
errors.phone = '请确认短信接收手机号'
|
||||
valid = false
|
||||
}
|
||||
|
||||
return valid
|
||||
}
|
||||
|
||||
@@ -264,11 +245,11 @@ export function useSigningTaskForm(options: UseSigningTaskFormOptions) {
|
||||
visitId: selectedVisit.value.id,
|
||||
documentId: selectedTemplate.value.id,
|
||||
documentName: selectedTemplate.value.name,
|
||||
templateVersionId: selectedTemplate.value.versionId,
|
||||
method: method.value,
|
||||
visitType: selectedVisit.value.type,
|
||||
visitNo: selectedVisit.value.visitNo,
|
||||
department: selectedVisit.value.department,
|
||||
phone: method.value === 'sms' ? smsPhone.value.trim() : undefined,
|
||||
})
|
||||
} finally {
|
||||
submitting.value = false
|
||||
@@ -287,7 +268,6 @@ export function useSigningTaskForm(options: UseSigningTaskFormOptions) {
|
||||
method,
|
||||
patient,
|
||||
patientId,
|
||||
patientPhoneRevealed,
|
||||
resetForNextTask,
|
||||
resetForm,
|
||||
selectMethod,
|
||||
@@ -297,11 +277,9 @@ export function useSigningTaskForm(options: UseSigningTaskFormOptions) {
|
||||
selectedTemplateId,
|
||||
selectedVisit,
|
||||
selectedVisitId,
|
||||
smsPhone,
|
||||
submit,
|
||||
submitting,
|
||||
templates,
|
||||
togglePhone,
|
||||
visits,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -104,8 +104,10 @@ function showEditMessage(template: DocumentTemplate) {
|
||||
function toSigningTemplate(template: DocumentTemplate): SigningTemplate {
|
||||
return {
|
||||
id: template.id,
|
||||
versionId: template.id,
|
||||
name: template.name,
|
||||
code: template.code,
|
||||
version: template.version,
|
||||
department: template.department,
|
||||
category: template.category,
|
||||
description: template.description,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import type { SigningTaskRecord } from '@/api/workbench/types'
|
||||
import type { SigningTaskEvent, SigningTaskRecord } from '@/api/workbench/types'
|
||||
|
||||
import SigningDocumentPreview from './SigningDocumentPreview.vue'
|
||||
import TaskAuditTimeline from './TaskAuditTimeline.vue'
|
||||
@@ -8,6 +8,10 @@ import type { SigningTaskAction } from '../types'
|
||||
defineProps<{
|
||||
task: SigningTaskRecord | null
|
||||
loading: boolean
|
||||
auditEvents: SigningTaskEvent[]
|
||||
auditLoading: boolean
|
||||
auditError: boolean
|
||||
allowLocalSigning: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -27,23 +31,33 @@ const emit = defineEmits<{
|
||||
<span class="task-action-meta">任务 {{ task.id }} · {{ task.patientName }}</span>
|
||||
|
||||
<template v-if="task.status === 'pending' && task.method === 'pad'">
|
||||
<button type="button" class="action-button" @click="emit('action', 'pad-sign')">
|
||||
✍ 手写板签署
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="action-button action-button--ghost"
|
||||
@click="emit('action', 'switch-method')"
|
||||
>
|
||||
转为短信发送
|
||||
</button>
|
||||
<template v-if="allowLocalSigning">
|
||||
<button type="button" class="action-button" @click="emit('action', 'pad-sign')">
|
||||
✍ 手写板签署
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="action-button action-button--ghost"
|
||||
@click="emit('action', 'switch-method')"
|
||||
>
|
||||
转为短信发送
|
||||
</button>
|
||||
</template>
|
||||
<span v-else class="action-hint">手写板设备签署接口待接入</span>
|
||||
</template>
|
||||
|
||||
<template v-else-if="task.status === 'pending' && task.method === 'sms'">
|
||||
<button type="button" class="action-button" @click="emit('action', 'online-sign')">
|
||||
<button
|
||||
v-if="allowLocalSigning"
|
||||
type="button"
|
||||
class="action-button"
|
||||
@click="emit('action', 'online-sign')"
|
||||
>
|
||||
📱 查看线上签署
|
||||
</button>
|
||||
<span v-else class="action-hint">线上签署页面接口待接入</span>
|
||||
<button
|
||||
v-if="allowLocalSigning"
|
||||
type="button"
|
||||
class="action-button action-button--ghost"
|
||||
@click="emit('action', 'switch-method')"
|
||||
@@ -59,7 +73,7 @@ const emit = defineEmits<{
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<template v-else-if="task.status === 'expired'">
|
||||
<template v-else-if="task.status === 'expired' || task.status === 'failed'">
|
||||
<button type="button" class="action-button" @click="emit('action', 'reopen')">
|
||||
↻ 重新发起
|
||||
</button>
|
||||
@@ -90,7 +104,12 @@ const emit = defineEmits<{
|
||||
</template>
|
||||
|
||||
<button
|
||||
v-if="task.status === 'pending' || task.status === 'expired' || task.status === 'signing'"
|
||||
v-if="
|
||||
task.status === 'pending' ||
|
||||
task.status === 'expired' ||
|
||||
task.status === 'signing' ||
|
||||
task.status === 'failed'
|
||||
"
|
||||
type="button"
|
||||
class="action-button action-button--danger"
|
||||
@click="emit('action', 'void')"
|
||||
@@ -100,7 +119,7 @@ const emit = defineEmits<{
|
||||
</div>
|
||||
|
||||
<SigningDocumentPreview :task="task" />
|
||||
<TaskAuditTimeline :task="task" />
|
||||
<TaskAuditTimeline :events="auditEvents" :loading="auditLoading" :error="auditError" />
|
||||
</template>
|
||||
|
||||
<div v-else class="detail-empty">
|
||||
@@ -131,6 +150,15 @@ const emit = defineEmits<{
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.action-hint {
|
||||
padding: 8px 12px;
|
||||
color: var(--mut);
|
||||
font-size: 12px;
|
||||
background: #f7fafb;
|
||||
border: 1px dashed var(--line);
|
||||
border-radius: 7px;
|
||||
}
|
||||
|
||||
.action-button {
|
||||
flex-shrink: 0;
|
||||
padding: 8px 16px;
|
||||
|
||||
@@ -19,6 +19,7 @@ const statusLabels: Record<SigningTaskRecord['status'], string> = {
|
||||
rejected: '已拒签',
|
||||
expired: '已超时',
|
||||
void: '已作废',
|
||||
failed: '处理失败',
|
||||
}
|
||||
|
||||
function getStatusClass(task: SigningTaskRecord) {
|
||||
@@ -217,6 +218,11 @@ function getVisitClass(type: SigningTaskRecord['visitType']) {
|
||||
background: var(--err-l);
|
||||
}
|
||||
|
||||
.task-status--failed {
|
||||
color: #a15c19;
|
||||
background: #fff3df;
|
||||
}
|
||||
|
||||
.task-status--void {
|
||||
color: #8a9aa3;
|
||||
background: #eceef0;
|
||||
|
||||
@@ -1,49 +1,34 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import type { SigningTaskEvent } from '@/api/workbench/types'
|
||||
|
||||
import type { SigningTaskRecord } from '@/api/workbench/types'
|
||||
|
||||
const props = defineProps<{
|
||||
task: SigningTaskRecord
|
||||
defineProps<{
|
||||
events: SigningTaskEvent[]
|
||||
loading: boolean
|
||||
error: boolean
|
||||
}>()
|
||||
|
||||
const events = computed(() => {
|
||||
const result = [
|
||||
{
|
||||
time: props.task.updatedAt,
|
||||
text: `张文静发起签署任务(${props.task.method === 'pad' ? '手写板' : '线上短信'})`,
|
||||
},
|
||||
]
|
||||
|
||||
if (props.task.status === 'signing') {
|
||||
result.push({ time: props.task.updatedAt, text: '已打开签名采集,等待患者完成签名' })
|
||||
}
|
||||
|
||||
if (props.task.signedAt) {
|
||||
result.push({
|
||||
time: props.task.signedAt,
|
||||
text: `患者 ${props.task.patientName} 完成签名,系统自动合成并双留存`,
|
||||
})
|
||||
}
|
||||
|
||||
if (props.task.status === 'void') {
|
||||
result.push({
|
||||
time: props.task.updatedAt,
|
||||
text: `任务已作废${props.task.voidReason ? `,原因:${props.task.voidReason}` : ''},全程可审计`,
|
||||
})
|
||||
}
|
||||
|
||||
return result
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="audit-card">
|
||||
<strong>操作留痕</strong>
|
||||
<ol class="audit-list">
|
||||
<li v-for="event in events" :key="`${event.time}-${event.text}`">
|
||||
|
||||
<div v-if="loading" class="audit-state">正在加载操作留痕…</div>
|
||||
<div v-else-if="error" class="audit-state audit-state--error">
|
||||
操作留痕加载失败,请稍后刷新重试
|
||||
</div>
|
||||
<div v-else-if="!events.length" class="audit-state">暂无操作留痕</div>
|
||||
|
||||
<ol v-else class="audit-list">
|
||||
<li v-for="event in events" :key="event.id">
|
||||
<time>{{ event.time }}</time>
|
||||
<span>{{ event.text }}</span>
|
||||
<span class="audit-event-content">
|
||||
<span>{{ event.text }}</span>
|
||||
<small v-if="event.attemptNo || event.reason">
|
||||
<template v-if="event.attemptNo">第 {{ event.attemptNo }} 次</template>
|
||||
<template v-if="event.attemptNo && event.reason"> · </template>
|
||||
<template v-if="event.reason">{{ event.reason }}</template>
|
||||
</small>
|
||||
</span>
|
||||
</li>
|
||||
</ol>
|
||||
</section>
|
||||
@@ -62,6 +47,16 @@ const events = computed(() => {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.audit-state {
|
||||
padding: 8px 0 1px;
|
||||
color: var(--mut);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.audit-state--error {
|
||||
color: var(--err);
|
||||
}
|
||||
|
||||
.audit-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -83,4 +78,15 @@ const events = computed(() => {
|
||||
flex-shrink: 0;
|
||||
color: var(--mut);
|
||||
}
|
||||
|
||||
.audit-event-content {
|
||||
display: inline-flex;
|
||||
gap: 4px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.audit-event-content small {
|
||||
color: var(--mut);
|
||||
font-size: 11px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -6,8 +6,10 @@ import { useRoute } from 'vue-router'
|
||||
import {
|
||||
completeSigningTask,
|
||||
getSigningTaskDetail,
|
||||
getSigningTaskEvents,
|
||||
getSigningTasks,
|
||||
getSigningTemplates,
|
||||
isSigningMockEnabled,
|
||||
reopenSigningTask,
|
||||
resendSigningSms,
|
||||
updateSigningTaskMethod,
|
||||
@@ -16,6 +18,7 @@ import {
|
||||
import type {
|
||||
SigningDateRange,
|
||||
SigningMethod,
|
||||
SigningTaskEvent,
|
||||
SigningTaskRecord,
|
||||
SigningTaskStatus,
|
||||
SigningTemplate,
|
||||
@@ -43,6 +46,7 @@ const STATUS_VALUES: SigningTaskStatus[] = [
|
||||
'rejected',
|
||||
'expired',
|
||||
'void',
|
||||
'failed',
|
||||
]
|
||||
const DATE_RANGE_VALUES: SigningDateRange[] = ['today', 'yesterday', '3d', '7d', 'all']
|
||||
|
||||
@@ -53,6 +57,7 @@ const statusLabels: Record<SigningTaskStatus, string> = {
|
||||
rejected: '已拒签',
|
||||
expired: '已超时',
|
||||
void: '已作废',
|
||||
failed: '处理失败',
|
||||
}
|
||||
|
||||
const filter = reactive<SigningFilterForm>({
|
||||
@@ -70,9 +75,12 @@ const tasks = ref<SigningTaskRecord[]>([])
|
||||
const total = ref(0)
|
||||
const selectedTaskId = ref<string | null>(null)
|
||||
const selectedTask = ref<SigningTaskRecord | null>(null)
|
||||
const taskEvents = ref<SigningTaskEvent[]>([])
|
||||
const templates = ref<SigningTemplate[]>([])
|
||||
const loading = ref(true)
|
||||
const detailLoading = ref(false)
|
||||
const eventsLoading = ref(false)
|
||||
const eventsError = ref(false)
|
||||
const newDialogVisible = ref(false)
|
||||
const signatureDialogVisible = ref(false)
|
||||
const onlineDialogVisible = ref(false)
|
||||
@@ -121,6 +129,11 @@ const documentOptions = computed<SigningFilterOption[]>(() => [
|
||||
...templates.value.map((template) => ({ label: template.name, value: template.id })),
|
||||
])
|
||||
|
||||
const signingApiOptions = computed(() => ({
|
||||
campus: appStore.selectedCampus,
|
||||
templates: templates.value,
|
||||
}))
|
||||
|
||||
function getQueryString(value: unknown) {
|
||||
return typeof value === 'string' ? value : ''
|
||||
}
|
||||
@@ -154,26 +167,47 @@ async function loadTaskDetail(id: string | null) {
|
||||
|
||||
if (!id) {
|
||||
selectedTask.value = null
|
||||
taskEvents.value = []
|
||||
detailLoading.value = false
|
||||
eventsLoading.value = false
|
||||
eventsError.value = false
|
||||
return
|
||||
}
|
||||
|
||||
detailLoading.value = true
|
||||
eventsLoading.value = true
|
||||
eventsError.value = false
|
||||
taskEvents.value = []
|
||||
|
||||
try {
|
||||
const detail = await getSigningTaskDetail(id)
|
||||
const [detailResult, eventsResult] = await Promise.allSettled([
|
||||
getSigningTaskDetail(id, signingApiOptions.value),
|
||||
getSigningTaskEvents(id),
|
||||
])
|
||||
|
||||
if (requestId === detailRequestId) {
|
||||
selectedTask.value = detail
|
||||
if (detailResult.status === 'rejected') {
|
||||
throw detailResult.reason
|
||||
}
|
||||
|
||||
selectedTask.value = detailResult.value
|
||||
|
||||
if (eventsResult.status === 'fulfilled') {
|
||||
taskEvents.value = eventsResult.value
|
||||
} else {
|
||||
eventsError.value = true
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
if (requestId === detailRequestId) {
|
||||
selectedTask.value = null
|
||||
taskEvents.value = []
|
||||
ElMessage.error('签署任务详情加载失败,请稍后重试')
|
||||
}
|
||||
} finally {
|
||||
if (requestId === detailRequestId) {
|
||||
detailLoading.value = false
|
||||
eventsLoading.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -183,11 +217,14 @@ async function loadTasks(preferredTaskId?: string) {
|
||||
loading.value = true
|
||||
|
||||
try {
|
||||
const response = await getSigningTasks({
|
||||
...filter,
|
||||
page: 1,
|
||||
pageSize: 50,
|
||||
})
|
||||
const response = await getSigningTasks(
|
||||
{
|
||||
...filter,
|
||||
page: 1,
|
||||
pageSize: 50,
|
||||
},
|
||||
signingApiOptions.value,
|
||||
)
|
||||
|
||||
if (requestId !== listRequestId) {
|
||||
return
|
||||
@@ -249,11 +286,21 @@ async function handleTaskAction(action: SigningTaskAction) {
|
||||
}
|
||||
|
||||
if (action === 'pad-sign') {
|
||||
if (!isSigningMockEnabled) {
|
||||
ElMessage.info('手写板设备签署接口待接入')
|
||||
return
|
||||
}
|
||||
|
||||
signatureDialogVisible.value = true
|
||||
return
|
||||
}
|
||||
|
||||
if (action === 'online-sign') {
|
||||
if (!isSigningMockEnabled) {
|
||||
ElMessage.info('线上签署页面接口待接入')
|
||||
return
|
||||
}
|
||||
|
||||
onlineDialogVisible.value = true
|
||||
return
|
||||
}
|
||||
@@ -280,6 +327,11 @@ async function handleTaskAction(action: SigningTaskAction) {
|
||||
|
||||
try {
|
||||
if (action === 'switch-method') {
|
||||
if (!isSigningMockEnabled) {
|
||||
ElMessage.info('当前后端未提供切换签署方式接口,请作废后重新创建任务')
|
||||
return
|
||||
}
|
||||
|
||||
const method: SigningMethod = task.method === 'pad' ? 'sms' : 'pad'
|
||||
await refreshTask(
|
||||
await updateSigningTaskMethod(task.id, method),
|
||||
@@ -289,12 +341,24 @@ async function handleTaskAction(action: SigningTaskAction) {
|
||||
}
|
||||
|
||||
if (action === 'resend-sms') {
|
||||
await refreshTask(await resendSigningSms(task.id), '短信已重新发送')
|
||||
await refreshTask(
|
||||
await resendSigningSms(task.id, {
|
||||
...signingApiOptions.value,
|
||||
expectedRowVersion: task.rowVersion,
|
||||
}),
|
||||
'短信已重新发送',
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
if (action === 'reopen') {
|
||||
await refreshTask(await reopenSigningTask(task.id), '签署任务已重新发起')
|
||||
await refreshTask(
|
||||
await reopenSigningTask(task.id, {
|
||||
...signingApiOptions.value,
|
||||
expectedRowVersion: task.rowVersion,
|
||||
}),
|
||||
'签署任务已重新发起',
|
||||
)
|
||||
}
|
||||
} catch {
|
||||
ElMessage.error('任务操作失败,请稍后重试')
|
||||
@@ -339,7 +403,13 @@ async function confirmVoidTask(reason: string) {
|
||||
}
|
||||
|
||||
try {
|
||||
await refreshTask(await voidSigningTask(task.id, reason), '签署任务已作废')
|
||||
await refreshTask(
|
||||
await voidSigningTask(task.id, reason, {
|
||||
...signingApiOptions.value,
|
||||
expectedRowVersion: task.rowVersion,
|
||||
}),
|
||||
'签署任务已作废',
|
||||
)
|
||||
voidDialogVisible.value = false
|
||||
} catch {
|
||||
ElMessage.error('任务作废失败,请稍后重试')
|
||||
@@ -350,6 +420,11 @@ async function handleOnlineResend() {
|
||||
await handleTaskAction('resend-sms')
|
||||
}
|
||||
|
||||
async function initializePage() {
|
||||
await loadTemplates()
|
||||
await loadTasks()
|
||||
}
|
||||
|
||||
async function handleTaskCreated(task: SigningTaskRecord) {
|
||||
await loadTasks(task.id)
|
||||
}
|
||||
@@ -375,7 +450,7 @@ watch(
|
||||
|
||||
onMounted(() => {
|
||||
syncFilterFromRoute()
|
||||
void Promise.all([loadTemplates(), loadTasks()])
|
||||
void initializePage()
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -412,7 +487,15 @@ onMounted(() => {
|
||||
:loading="loading"
|
||||
@select="selectTask"
|
||||
/>
|
||||
<SigningTaskDetail :task="selectedTask" :loading="detailLoading" @action="handleTaskAction" />
|
||||
<SigningTaskDetail
|
||||
:task="selectedTask"
|
||||
:loading="detailLoading"
|
||||
:audit-events="taskEvents"
|
||||
:audit-loading="eventsLoading"
|
||||
:audit-error="eventsError"
|
||||
:allow-local-signing="isSigningMockEnabled"
|
||||
@action="handleTaskAction"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<NewSigningTaskDialog
|
||||
|
||||
Reference in New Issue
Block a user