Compare commits

..

3 Commits

Author SHA1 Message Date
yelan
f4f40f0596 docs(signing): 补充真实接口接入说明 2026-08-31 16:09:15 +08:00
yelan
9c1b2f53a6 feat(signing): 接入工作台任务操作与审计 2026-08-31 16:08:47 +08:00
yelan
40b0a5d040 feat(signing): 接入患者与任务真实接口 2026-08-31 16:08:17 +08:00
10 changed files with 1008 additions and 161 deletions

View File

@@ -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/document-permissions.ts`
- `api/management/settings.ts` - `api/management/settings.ts`
API 请求和响应类型按业务域放在 `api/workbench/types.ts``api/management/types.ts`;页面展示和交互类型放在对应 View 目录的 `types.ts`;全局复用类型放在 `types/common.ts`
所有接口统一使用 `utils/request.ts` 导出的单例请求实例。登录接口已接入 MEDISIGN 后端: 所有接口统一使用 `utils/request.ts` 导出的单例请求实例。登录接口已接入 MEDISIGN 后端:
- `POST /api/v1/auth/login`:账号密码登录; - `POST /api/v1/auth/login`:账号密码登录;
@@ -134,8 +132,20 @@ API 请求和响应类型按业务域放在 `api/workbench/types.ts`、`api/mana
- `POST /api/v1/auth/logout`:注销当前会话; - `POST /api/v1/auth/logout`:注销当前会话;
- 后续请求自动携带 `X-Token` 请求头。 - 后续请求自动携带 `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 模式仍可用于演示完整交互。

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,

View File

@@ -1,5 +1,5 @@
<script setup lang="ts"> <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 SigningDocumentPreview from './SigningDocumentPreview.vue'
import TaskAuditTimeline from './TaskAuditTimeline.vue' import TaskAuditTimeline from './TaskAuditTimeline.vue'
@@ -8,6 +8,10 @@ import type { SigningTaskAction } from '../types'
defineProps<{ defineProps<{
task: SigningTaskRecord | null task: SigningTaskRecord | null
loading: boolean loading: boolean
auditEvents: SigningTaskEvent[]
auditLoading: boolean
auditError: boolean
allowLocalSigning: boolean
}>() }>()
const emit = defineEmits<{ const emit = defineEmits<{
@@ -27,23 +31,33 @@ const emit = defineEmits<{
<span class="task-action-meta">任务 {{ task.id }} · {{ task.patientName }}</span> <span class="task-action-meta">任务 {{ task.id }} · {{ task.patientName }}</span>
<template v-if="task.status === 'pending' && task.method === 'pad'"> <template v-if="task.status === 'pending' && task.method === 'pad'">
<button type="button" class="action-button" @click="emit('action', 'pad-sign')"> <template v-if="allowLocalSigning">
手写板签署 <button type="button" class="action-button" @click="emit('action', 'pad-sign')">
</button> 手写板签署
<button </button>
type="button" <button
class="action-button action-button--ghost" type="button"
@click="emit('action', 'switch-method')" class="action-button action-button--ghost"
> @click="emit('action', 'switch-method')"
转为短信发送 >
</button> 转为短信发送
</button>
</template>
<span v-else class="action-hint">手写板设备签署接口待接入</span>
</template> </template>
<template v-else-if="task.status === 'pending' && task.method === 'sms'"> <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> </button>
<span v-else class="action-hint">线上签署页面接口待接入</span>
<button <button
v-if="allowLocalSigning"
type="button" type="button"
class="action-button action-button--ghost" class="action-button action-button--ghost"
@click="emit('action', 'switch-method')" @click="emit('action', 'switch-method')"
@@ -59,7 +73,7 @@ const emit = defineEmits<{
</button> </button>
</template> </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 type="button" class="action-button" @click="emit('action', 'reopen')">
重新发起 重新发起
</button> </button>
@@ -90,7 +104,12 @@ const emit = defineEmits<{
</template> </template>
<button <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" type="button"
class="action-button action-button--danger" class="action-button action-button--danger"
@click="emit('action', 'void')" @click="emit('action', 'void')"
@@ -100,7 +119,7 @@ const emit = defineEmits<{
</div> </div>
<SigningDocumentPreview :task="task" /> <SigningDocumentPreview :task="task" />
<TaskAuditTimeline :task="task" /> <TaskAuditTimeline :events="auditEvents" :loading="auditLoading" :error="auditError" />
</template> </template>
<div v-else class="detail-empty"> <div v-else class="detail-empty">
@@ -131,6 +150,15 @@ const emit = defineEmits<{
font-size: 12px; 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 { .action-button {
flex-shrink: 0; flex-shrink: 0;
padding: 8px 16px; padding: 8px 16px;

View File

@@ -19,6 +19,7 @@ const statusLabels: Record<SigningTaskRecord['status'], string> = {
rejected: '已拒签', rejected: '已拒签',
expired: '已超时', expired: '已超时',
void: '已作废', void: '已作废',
failed: '处理失败',
} }
function getStatusClass(task: SigningTaskRecord) { function getStatusClass(task: SigningTaskRecord) {
@@ -217,6 +218,11 @@ function getVisitClass(type: SigningTaskRecord['visitType']) {
background: var(--err-l); background: var(--err-l);
} }
.task-status--failed {
color: #a15c19;
background: #fff3df;
}
.task-status--void { .task-status--void {
color: #8a9aa3; color: #8a9aa3;
background: #eceef0; background: #eceef0;

View File

@@ -1,49 +1,34 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue' import type { SigningTaskEvent } from '@/api/workbench/types'
import type { SigningTaskRecord } from '@/api/workbench/types' defineProps<{
events: SigningTaskEvent[]
const props = defineProps<{ loading: boolean
task: SigningTaskRecord 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> </script>
<template> <template>
<section class="audit-card"> <section class="audit-card">
<strong>操作留痕</strong> <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> <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> </li>
</ol> </ol>
</section> </section>
@@ -62,6 +47,16 @@ const events = computed(() => {
font-size: 12px; font-size: 12px;
} }
.audit-state {
padding: 8px 0 1px;
color: var(--mut);
font-size: 12px;
}
.audit-state--error {
color: var(--err);
}
.audit-list { .audit-list {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -83,4 +78,15 @@ const events = computed(() => {
flex-shrink: 0; flex-shrink: 0;
color: var(--mut); 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> </style>

View File

@@ -6,8 +6,10 @@ import { useRoute } from 'vue-router'
import { import {
completeSigningTask, completeSigningTask,
getSigningTaskDetail, getSigningTaskDetail,
getSigningTaskEvents,
getSigningTasks, getSigningTasks,
getSigningTemplates, getSigningTemplates,
isSigningMockEnabled,
reopenSigningTask, reopenSigningTask,
resendSigningSms, resendSigningSms,
updateSigningTaskMethod, updateSigningTaskMethod,
@@ -16,6 +18,7 @@ import {
import type { import type {
SigningDateRange, SigningDateRange,
SigningMethod, SigningMethod,
SigningTaskEvent,
SigningTaskRecord, SigningTaskRecord,
SigningTaskStatus, SigningTaskStatus,
SigningTemplate, SigningTemplate,
@@ -43,6 +46,7 @@ const STATUS_VALUES: SigningTaskStatus[] = [
'rejected', 'rejected',
'expired', 'expired',
'void', 'void',
'failed',
] ]
const DATE_RANGE_VALUES: SigningDateRange[] = ['today', 'yesterday', '3d', '7d', 'all'] const DATE_RANGE_VALUES: SigningDateRange[] = ['today', 'yesterday', '3d', '7d', 'all']
@@ -53,6 +57,7 @@ const statusLabels: Record<SigningTaskStatus, string> = {
rejected: '已拒签', rejected: '已拒签',
expired: '已超时', expired: '已超时',
void: '已作废', void: '已作废',
failed: '处理失败',
} }
const filter = reactive<SigningFilterForm>({ const filter = reactive<SigningFilterForm>({
@@ -70,9 +75,12 @@ const tasks = ref<SigningTaskRecord[]>([])
const total = ref(0) const total = ref(0)
const selectedTaskId = ref<string | null>(null) const selectedTaskId = ref<string | null>(null)
const selectedTask = ref<SigningTaskRecord | null>(null) const selectedTask = ref<SigningTaskRecord | null>(null)
const taskEvents = ref<SigningTaskEvent[]>([])
const templates = ref<SigningTemplate[]>([]) const templates = ref<SigningTemplate[]>([])
const loading = ref(true) const loading = ref(true)
const detailLoading = ref(false) const detailLoading = ref(false)
const eventsLoading = ref(false)
const eventsError = ref(false)
const newDialogVisible = ref(false) const newDialogVisible = ref(false)
const signatureDialogVisible = ref(false) const signatureDialogVisible = ref(false)
const onlineDialogVisible = ref(false) const onlineDialogVisible = ref(false)
@@ -121,6 +129,11 @@ const documentOptions = computed<SigningFilterOption[]>(() => [
...templates.value.map((template) => ({ label: template.name, value: template.id })), ...templates.value.map((template) => ({ label: template.name, value: template.id })),
]) ])
const signingApiOptions = computed(() => ({
campus: appStore.selectedCampus,
templates: templates.value,
}))
function getQueryString(value: unknown) { function getQueryString(value: unknown) {
return typeof value === 'string' ? value : '' return typeof value === 'string' ? value : ''
} }
@@ -154,26 +167,47 @@ async function loadTaskDetail(id: string | null) {
if (!id) { if (!id) {
selectedTask.value = null selectedTask.value = null
taskEvents.value = []
detailLoading.value = false detailLoading.value = false
eventsLoading.value = false
eventsError.value = false
return return
} }
detailLoading.value = true detailLoading.value = true
eventsLoading.value = true
eventsError.value = false
taskEvents.value = []
try { try {
const detail = await getSigningTaskDetail(id) const [detailResult, eventsResult] = await Promise.allSettled([
getSigningTaskDetail(id, signingApiOptions.value),
getSigningTaskEvents(id),
])
if (requestId === detailRequestId) { 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 { } catch {
if (requestId === detailRequestId) { if (requestId === detailRequestId) {
selectedTask.value = null selectedTask.value = null
taskEvents.value = []
ElMessage.error('签署任务详情加载失败,请稍后重试') ElMessage.error('签署任务详情加载失败,请稍后重试')
} }
} finally { } finally {
if (requestId === detailRequestId) { if (requestId === detailRequestId) {
detailLoading.value = false detailLoading.value = false
eventsLoading.value = false
} }
} }
} }
@@ -183,11 +217,14 @@ async function loadTasks(preferredTaskId?: string) {
loading.value = true loading.value = true
try { try {
const response = await getSigningTasks({ const response = await getSigningTasks(
...filter, {
page: 1, ...filter,
pageSize: 50, page: 1,
}) pageSize: 50,
},
signingApiOptions.value,
)
if (requestId !== listRequestId) { if (requestId !== listRequestId) {
return return
@@ -249,11 +286,21 @@ async function handleTaskAction(action: SigningTaskAction) {
} }
if (action === 'pad-sign') { if (action === 'pad-sign') {
if (!isSigningMockEnabled) {
ElMessage.info('手写板设备签署接口待接入')
return
}
signatureDialogVisible.value = true signatureDialogVisible.value = true
return return
} }
if (action === 'online-sign') { if (action === 'online-sign') {
if (!isSigningMockEnabled) {
ElMessage.info('线上签署页面接口待接入')
return
}
onlineDialogVisible.value = true onlineDialogVisible.value = true
return return
} }
@@ -280,6 +327,11 @@ async function handleTaskAction(action: SigningTaskAction) {
try { try {
if (action === 'switch-method') { if (action === 'switch-method') {
if (!isSigningMockEnabled) {
ElMessage.info('当前后端未提供切换签署方式接口,请作废后重新创建任务')
return
}
const method: SigningMethod = task.method === 'pad' ? 'sms' : 'pad' const method: SigningMethod = task.method === 'pad' ? 'sms' : 'pad'
await refreshTask( await refreshTask(
await updateSigningTaskMethod(task.id, method), await updateSigningTaskMethod(task.id, method),
@@ -289,12 +341,24 @@ async function handleTaskAction(action: SigningTaskAction) {
} }
if (action === 'resend-sms') { if (action === 'resend-sms') {
await refreshTask(await resendSigningSms(task.id), '短信已重新发送') await refreshTask(
await resendSigningSms(task.id, {
...signingApiOptions.value,
expectedRowVersion: task.rowVersion,
}),
'短信已重新发送',
)
return return
} }
if (action === 'reopen') { if (action === 'reopen') {
await refreshTask(await reopenSigningTask(task.id), '签署任务已重新发起') await refreshTask(
await reopenSigningTask(task.id, {
...signingApiOptions.value,
expectedRowVersion: task.rowVersion,
}),
'签署任务已重新发起',
)
} }
} catch { } catch {
ElMessage.error('任务操作失败,请稍后重试') ElMessage.error('任务操作失败,请稍后重试')
@@ -339,7 +403,13 @@ async function confirmVoidTask(reason: string) {
} }
try { try {
await refreshTask(await voidSigningTask(task.id, reason), '签署任务已作废') await refreshTask(
await voidSigningTask(task.id, reason, {
...signingApiOptions.value,
expectedRowVersion: task.rowVersion,
}),
'签署任务已作废',
)
voidDialogVisible.value = false voidDialogVisible.value = false
} catch { } catch {
ElMessage.error('任务作废失败,请稍后重试') ElMessage.error('任务作废失败,请稍后重试')
@@ -350,6 +420,11 @@ async function handleOnlineResend() {
await handleTaskAction('resend-sms') await handleTaskAction('resend-sms')
} }
async function initializePage() {
await loadTemplates()
await loadTasks()
}
async function handleTaskCreated(task: SigningTaskRecord) { async function handleTaskCreated(task: SigningTaskRecord) {
await loadTasks(task.id) await loadTasks(task.id)
} }
@@ -375,7 +450,7 @@ watch(
onMounted(() => { onMounted(() => {
syncFilterFromRoute() syncFilterFromRoute()
void Promise.all([loadTemplates(), loadTasks()]) void initializePage()
}) })
</script> </script>
@@ -412,7 +487,15 @@ onMounted(() => {
:loading="loading" :loading="loading"
@select="selectTask" @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> </div>
<NewSigningTaskDialog <NewSigningTaskDialog