feat(签署方式): 扩展为手写板/短信/Pad/公众号四种并集中口径
This commit is contained in:
@@ -310,7 +310,8 @@ export interface OrganizationResponseDto {
|
||||
export type ReportBackendTaskStatus =
|
||||
'CREATED' | 'WAITING_SIGN' | 'SIGNED' | 'EXPIRED' | 'VOIDED' | 'GENERATING' | 'FAILED'
|
||||
|
||||
export type ReportBackendSignMethod = 'PAD' | 'SMS'
|
||||
/** 与 workbench 的 BackendSigningMethod 同口径:四种方式平铺,无通道归并 */
|
||||
export type ReportBackendSignMethod = 'PAD' | 'SMS' | 'IPAD' | 'WX'
|
||||
|
||||
export type ReportTimeDimension = 'CREATED' | 'SIGNED' | 'EXPIRED'
|
||||
|
||||
|
||||
@@ -79,6 +79,27 @@ const mockCampusOverviews: Record<WorkbenchCampus, MockCampusOverview> = {
|
||||
method: 'sms',
|
||||
updatedAt: '2026-08-27 09:40',
|
||||
},
|
||||
{
|
||||
// Pad 移动端:待办里要四种方式都有样本,徽标配色才看得出区别
|
||||
id: 'T0818004',
|
||||
patientId: 'P202610003',
|
||||
patientName: '赵某某',
|
||||
documentName: '住院须知及告知书',
|
||||
department: '消化内科',
|
||||
status: 'pending',
|
||||
method: 'ipad',
|
||||
updatedAt: '2026-08-27 09:05',
|
||||
},
|
||||
{
|
||||
id: 'T0818005',
|
||||
patientId: 'P202610007',
|
||||
patientName: '孙浩然',
|
||||
documentName: '内镜检查知情同意书',
|
||||
department: '普外科',
|
||||
status: 'pending',
|
||||
method: 'wx',
|
||||
updatedAt: '2026-08-27 08:50',
|
||||
},
|
||||
],
|
||||
ranking: [
|
||||
{ id: 'doc-001', documentName: '住院患者知情同意书', department: '全院通用', baseCount: 58 },
|
||||
@@ -137,6 +158,26 @@ const mockCampusOverviews: Record<WorkbenchCampus, MockCampusOverview> = {
|
||||
method: 'sms',
|
||||
updatedAt: '2026-08-27 08:30',
|
||||
},
|
||||
{
|
||||
id: 'T0818012',
|
||||
patientId: 'P202610003',
|
||||
patientName: '赵某某',
|
||||
documentName: '住院须知及告知书',
|
||||
department: '消化内科',
|
||||
status: 'pending',
|
||||
method: 'ipad',
|
||||
updatedAt: '2026-08-27 09:05',
|
||||
},
|
||||
{
|
||||
id: 'T0818013',
|
||||
patientId: 'P202610007',
|
||||
patientName: '孙浩然',
|
||||
documentName: '内镜检查知情同意书',
|
||||
department: '普外科',
|
||||
status: 'pending',
|
||||
method: 'wx',
|
||||
updatedAt: '2026-08-27 08:50',
|
||||
},
|
||||
],
|
||||
ranking: [
|
||||
{
|
||||
@@ -215,6 +256,26 @@ const mockCampusOverviews: Record<WorkbenchCampus, MockCampusOverview> = {
|
||||
method: 'pad',
|
||||
updatedAt: '2026-08-27 08:05',
|
||||
},
|
||||
{
|
||||
id: 'T0818024',
|
||||
patientId: 'P202610003',
|
||||
patientName: '赵某某',
|
||||
documentName: '住院须知及告知书',
|
||||
department: '消化内科',
|
||||
status: 'pending',
|
||||
method: 'ipad',
|
||||
updatedAt: '2026-08-27 09:05',
|
||||
},
|
||||
{
|
||||
id: 'T0818025',
|
||||
patientId: 'P202610007',
|
||||
patientName: '孙浩然',
|
||||
documentName: '内镜检查知情同意书',
|
||||
department: '普外科',
|
||||
status: 'pending',
|
||||
method: 'wx',
|
||||
updatedAt: '2026-08-27 08:50',
|
||||
},
|
||||
],
|
||||
ranking: [
|
||||
{
|
||||
@@ -281,8 +342,16 @@ function mapTodoStatus(status: BackendSigningTaskStatus): WorkbenchTodoStatus {
|
||||
}
|
||||
}
|
||||
|
||||
const METHOD_BY_BACKEND: Record<BackendSigningMethod, SigningMethod> = {
|
||||
PAD: 'pad',
|
||||
SMS: 'sms',
|
||||
IPAD: 'ipad',
|
||||
WX: 'wx',
|
||||
}
|
||||
|
||||
function mapSigningMethod(method: BackendSigningMethod): SigningMethod {
|
||||
return method === 'SMS' ? 'sms' : 'pad'
|
||||
// 后端理论上只回这四个;未知取值按手写板兜底,避免整行渲染不出来
|
||||
return METHOD_BY_BACKEND[method] ?? 'pad'
|
||||
}
|
||||
|
||||
function mapWorkbenchOverview(dto: WorkbenchOverviewResponseDto): WorkbenchOverviewResponse {
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
import { DEMO_SIGNATURE_DATA_URL } from '@/utils/demo-signature'
|
||||
import { mockFlags } from '@/utils/mock-flags'
|
||||
import { buildFieldSpanHtml } from '@/utils/signing-fields'
|
||||
import { formatSigningMethod, toBackendSigningMethod } from '@/utils/signing-method'
|
||||
|
||||
import {
|
||||
campusOfDepartment,
|
||||
@@ -128,10 +129,13 @@ const MOCK_RECORD_SEEDS: MockRecordSeed[] = [
|
||||
{ patient: '赵美英', sex: '女', age: 58, visitType: '住院', doc: 'mock-doc-cardiac', status: 'signed', method: 'pad', days: 0, at: '09:12', minutes: 7 },
|
||||
{ patient: '李淑芬', sex: '女', age: 67, visitType: '门诊', doc: 'mock-doc-mri', status: 'signed', method: 'sms', days: 0, at: '10:05', minutes: 4 },
|
||||
{ patient: '周雅琴', sex: '女', age: 29, visitType: '门诊', doc: 'mock-doc-endoscopy', status: 'pending', method: 'sms', days: 0, at: '11:20' },
|
||||
{ patient: '赵某某', sex: '男', age: 52, visitType: '住院', doc: 'mock-doc-admission', status: 'pending', method: 'ipad', days: 0, at: '09:05', source: '工作台发起' },
|
||||
{ patient: '孙浩然', sex: '男', age: 54, visitType: '住院', doc: 'mock-doc-endoscopy', status: 'pending', method: 'wx', days: 0, at: '08:50' },
|
||||
// 昨天
|
||||
{ patient: '孙浩然', sex: '男', age: 54, visitType: '住院', doc: 'mock-doc-narcotic', status: 'signed', method: 'pad', days: 1, at: '08:40', minutes: 6, source: '工作台发起' },
|
||||
{ patient: '王建国', sex: '男', age: 71, visitType: '住院', doc: 'mock-doc-blood', status: 'signed', method: 'sms', days: 1, at: '14:15', minutes: 9 },
|
||||
{ patient: '陈志强', sex: '男', age: 61, visitType: '住院', doc: 'mock-doc-admission', status: 'void', method: 'pad', days: 1, at: '16:02', source: '工作台发起', voidReason: '文书选择错误,已重新发起' },
|
||||
{ patient: '罗文静', sex: '女', age: 44, visitType: '住院', doc: 'mock-doc-mri', status: 'signed', method: 'ipad', days: 1, at: '11:35', minutes: 5 },
|
||||
// 近 3 天
|
||||
{ patient: '吴桂芳', sex: '女', age: 73, visitType: '住院', doc: 'mock-doc-surgery', status: 'signed', method: 'pad', days: 2, at: '09:35', minutes: 5 },
|
||||
{ patient: '刘伟', sex: '男', age: 45, visitType: '门诊', doc: 'mock-doc-cardiac', status: 'signed', method: 'sms', days: 2, at: '15:48', minutes: 3, source: '工作台发起' },
|
||||
@@ -142,6 +146,7 @@ const MOCK_RECORD_SEEDS: MockRecordSeed[] = [
|
||||
{ patient: '马晓东', sex: '男', age: 33, visitType: '其他', doc: 'mock-doc-emergency', status: 'failed', method: 'pad', days: 5, at: '20:18', source: '工作台发起' },
|
||||
{ patient: '何秀英', sex: '女', age: 79, visitType: '住院', doc: 'mock-doc-surgery', status: 'signed', method: 'sms', days: 6, at: '09:05', minutes: 12 },
|
||||
{ patient: '林俊杰', sex: '男', age: 41, visitType: '门诊', doc: 'mock-doc-narcotic', status: 'signing', method: 'pad', days: 6, at: '14:52' },
|
||||
{ patient: '梁俊豪', sex: '男', age: 35, visitType: '住院', doc: 'mock-doc-surgery', status: 'signed', method: 'wx', days: 6, at: '10:28', minutes: 4 },
|
||||
// 近 30 天
|
||||
{ patient: '罗美玲', sex: '女', age: 62, visitType: '门诊', doc: 'mock-doc-admission', status: 'expired', method: 'sms', days: 11, at: '17:30' },
|
||||
{ patient: '高文斌', sex: '男', age: 57, visitType: '住院', doc: 'mock-doc-cardiac', status: 'signed', method: 'pad', days: 14, at: '10:10', minutes: 7 },
|
||||
@@ -447,25 +452,30 @@ export async function getSigningRecordDetail(
|
||||
* 如果每次刷新都在变,就没法用来核对签署链路了。
|
||||
*/
|
||||
function buildMockEvents(record: SigningRecord): SigningTaskEvent[] {
|
||||
const channel = record.method === 'pad' ? '手写板' : '短信'
|
||||
const backendChannel = toBackendSigningMethod(record.method)
|
||||
// 线上方式(短信 / 公众号)才有一条「已发送」留痕,现场方式是设备上直接采集
|
||||
const isOnline = record.method === 'sms' || record.method === 'wx'
|
||||
const createdAt = record.createdAt ?? record.updatedAt
|
||||
const events: SigningTaskEvent[] = [
|
||||
{
|
||||
id: `${record.id}-created`,
|
||||
time: createdAt,
|
||||
text: `张文静创建签署任务(${channel})`,
|
||||
text: `张文静创建签署任务(${formatSigningMethod(record.method)})`,
|
||||
eventType: 'TASK_CREATED',
|
||||
channel: record.method === 'pad' ? 'PAD' : 'SMS',
|
||||
channel: backendChannel,
|
||||
},
|
||||
]
|
||||
|
||||
if (record.method === 'sms') {
|
||||
if (isOnline) {
|
||||
events.push({
|
||||
id: `${record.id}-sent`,
|
||||
time: createdAt,
|
||||
text: `已向 ${record.patientName} 预留手机号发送签署短信`,
|
||||
text:
|
||||
record.method === 'wx'
|
||||
? `已向 ${record.patientName} 推送公众号签署通知`
|
||||
: `已向 ${record.patientName} 预留手机号发送签署短信`,
|
||||
eventType: 'SEND_PREPARED',
|
||||
channel: 'SMS',
|
||||
channel: backendChannel,
|
||||
attemptNo: 1,
|
||||
})
|
||||
}
|
||||
@@ -476,7 +486,7 @@ function buildMockEvents(record: SigningRecord): SigningTaskEvent[] {
|
||||
time: record.updatedAt,
|
||||
text: '已打开签名采集,等待患者完成签名',
|
||||
eventType: 'SIGNING_STARTED',
|
||||
channel: 'PAD',
|
||||
channel: backendChannel,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -486,7 +496,7 @@ function buildMockEvents(record: SigningRecord): SigningTaskEvent[] {
|
||||
time: record.signedAt,
|
||||
text: `患者 ${record.signerName} 完成签名,系统已合成文书并双留存`,
|
||||
eventType: 'SIGNED',
|
||||
channel: record.method === 'pad' ? 'PAD' : 'SMS',
|
||||
channel: backendChannel,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -182,6 +182,36 @@ function pickStatus(random: () => number): SigningTaskStatus {
|
||||
return 'signed'
|
||||
}
|
||||
|
||||
/**
|
||||
* 四种签署方式的抽样权重。
|
||||
*
|
||||
* 现场两种(手写板 / Pad)占比高,线上两种(短信 / 公众号)略低 —— 四种都要出现,
|
||||
* 否则「签署方式分布」这个报表维度会缺行,演示时看不到新方式。
|
||||
* 权重和不必是 100,`pickMethod` 会自己算总和。
|
||||
*/
|
||||
const METHOD_WEIGHTS: readonly { method: SigningMethod; weight: number }[] = [
|
||||
{ method: 'pad', weight: 38 },
|
||||
{ method: 'ipad', weight: 24 },
|
||||
{ method: 'sms', weight: 26 },
|
||||
{ method: 'wx', weight: 12 },
|
||||
]
|
||||
|
||||
const METHOD_TOTAL_WEIGHT = METHOD_WEIGHTS.reduce((sum, item) => sum + item.weight, 0)
|
||||
|
||||
function pickMethod(random: () => number): SigningMethod {
|
||||
let roll = random() * METHOD_TOTAL_WEIGHT
|
||||
|
||||
for (const item of METHOD_WEIGHTS) {
|
||||
roll -= item.weight
|
||||
|
||||
if (roll <= 0) {
|
||||
return item.method
|
||||
}
|
||||
}
|
||||
|
||||
return 'pad'
|
||||
}
|
||||
|
||||
function pad(value: number): string {
|
||||
return String(value).padStart(2, '0')
|
||||
}
|
||||
@@ -219,7 +249,8 @@ function buildTasks(): ReportTaskRow[] {
|
||||
const patient = pick(random, DEMO_PATIENTS)
|
||||
const document = pick(random, mockDocumentCatalog)
|
||||
const status = pickStatus(random)
|
||||
const method: SigningMethod = random() < 0.45 ? 'pad' : 'sms'
|
||||
// 四种方式按现场/线上偏重的权重抽,保证「签署方式分布」四个维度都有量
|
||||
const method = pickMethod(random)
|
||||
|
||||
// 越靠近今天的任务越少:真实签署量不会是均匀分布,均匀分布的趋势图一眼假
|
||||
const daysAgo = Math.floor(DEMO_SPAN_DAYS * Math.pow(random(), 1.35))
|
||||
|
||||
@@ -10,6 +10,7 @@ import { mockFlags } from '@/utils/mock-flags'
|
||||
import { readLocalSigningTemplates } from '@/utils/mock-document-library'
|
||||
import { MOCK_STORAGE_KEYS, readMockState, writeMockState } from '@/utils/mock-storage'
|
||||
import { request } from '@/utils/request'
|
||||
import { ALL_SIGNING_METHODS, formatSigningMethod, toBackendSigningMethod } from '@/utils/signing-method'
|
||||
import { resendSigningSms as resendSigningSmsDelivery } from './deliveries'
|
||||
|
||||
import type {
|
||||
@@ -51,7 +52,8 @@ const mockPatients: PatientProfile[] = [
|
||||
sex: '男',
|
||||
age: 46,
|
||||
idCard: '310***********1234',
|
||||
phone: '13800005678',
|
||||
phone: '138****5678',
|
||||
phoneFull: '13800005678',
|
||||
visits: [
|
||||
{
|
||||
id: 'visit-001',
|
||||
@@ -76,7 +78,8 @@ const mockPatients: PatientProfile[] = [
|
||||
sex: '女',
|
||||
age: 37,
|
||||
idCard: '310***********6721',
|
||||
phone: '13900006789',
|
||||
phone: '139****6789',
|
||||
phoneFull: '13900006789',
|
||||
visits: [
|
||||
{
|
||||
id: 'visit-003',
|
||||
@@ -94,7 +97,8 @@ const mockPatients: PatientProfile[] = [
|
||||
sex: '男',
|
||||
age: 52,
|
||||
idCard: '310***********2846',
|
||||
phone: '13700007890',
|
||||
phone: '137****7890',
|
||||
phoneFull: '13700007890',
|
||||
visits: [
|
||||
{
|
||||
id: 'visit-004',
|
||||
@@ -112,7 +116,8 @@ const mockPatients: PatientProfile[] = [
|
||||
sex: '女',
|
||||
age: 29,
|
||||
idCard: '310***********9152',
|
||||
phone: '13600008901',
|
||||
phone: '136****8901',
|
||||
phoneFull: '13600008901',
|
||||
visits: [
|
||||
{
|
||||
id: 'visit-005',
|
||||
@@ -130,7 +135,8 @@ const mockPatients: PatientProfile[] = [
|
||||
sex: '男',
|
||||
age: 61,
|
||||
idCard: '310***********3488',
|
||||
phone: '13500009012',
|
||||
phone: '135****9012',
|
||||
phoneFull: '13500009012',
|
||||
visits: [
|
||||
{
|
||||
id: 'visit-006',
|
||||
@@ -252,6 +258,73 @@ const defaultMockRecords: SigningTaskRecord[] = [
|
||||
expiresAt: '2026-08-30 14:20',
|
||||
updatedAt: '2026-08-27 08:20',
|
||||
},
|
||||
{
|
||||
// Pad 移动端(床旁/诊间):原型种子里就有这一条,待签署,用来验证 Pad 方式的状态徽标与详情动作
|
||||
id: 'task-006',
|
||||
templateVersionId: 'tpl-002-v1',
|
||||
campus: '本部院区',
|
||||
patientId: 'P202610003',
|
||||
patientName: '赵某某',
|
||||
sex: '男',
|
||||
age: 52,
|
||||
visitNo: 'ZY20260826027',
|
||||
visitType: '住院',
|
||||
visitDate: '2026-08-26',
|
||||
documentId: 'tpl-002',
|
||||
documentName: '住院须知及告知书',
|
||||
department: '消化内科',
|
||||
status: 'pending',
|
||||
signerName: '赵某某',
|
||||
method: 'ipad',
|
||||
source: '工作台发起',
|
||||
expiresAt: '2026-08-30 18:00',
|
||||
updatedAt: '2026-08-27 09:05',
|
||||
},
|
||||
{
|
||||
// 微信公众号:四种方式里最晚加的一条,演示数据里必须有一条,
|
||||
// 否则「公众号」的筛选项与状态徽标永远没有样本可看
|
||||
id: 'task-007',
|
||||
templateVersionId: 'tpl-003-v3',
|
||||
campus: '西院区',
|
||||
patientId: 'P202610005',
|
||||
patientName: '陈志强',
|
||||
sex: '男',
|
||||
age: 61,
|
||||
visitNo: 'ZY20260826011',
|
||||
visitType: '住院',
|
||||
visitDate: '2026-08-26',
|
||||
documentId: 'tpl-003',
|
||||
documentName: '内镜检查知情同意书',
|
||||
department: '普外科',
|
||||
status: 'pending',
|
||||
signerName: '陈志强',
|
||||
method: 'wx',
|
||||
source: '电子病历',
|
||||
expiresAt: '2026-08-31 10:00',
|
||||
updatedAt: '2026-08-27 09:40',
|
||||
},
|
||||
{
|
||||
// 短信线上:待签署的四种方式各要一条,列表徽标的四种配色才都能看到
|
||||
id: 'task-008',
|
||||
templateVersionId: 'tpl-004-v1',
|
||||
campus: '东院区',
|
||||
patientId: 'P202610002',
|
||||
patientName: '王某某',
|
||||
sex: '女',
|
||||
age: 37,
|
||||
visitNo: 'JZ20260827018',
|
||||
visitType: '门诊',
|
||||
visitDate: '2026-08-27',
|
||||
documentId: 'tpl-004',
|
||||
documentName: 'MRI 磁共振检查知情同意书',
|
||||
department: '放射科',
|
||||
status: 'pending',
|
||||
signerName: '王某某',
|
||||
method: 'sms',
|
||||
source: '工作台发起',
|
||||
expiresAt: '2026-08-31 18:00',
|
||||
updatedAt: '2026-08-27 09:50',
|
||||
},
|
||||
]
|
||||
|
||||
/**
|
||||
@@ -405,9 +478,15 @@ export function getMockSigningTask(taskId: string): SigningTaskRecord | null {
|
||||
return task ? cloneTask(task) : null
|
||||
}
|
||||
|
||||
/** 演示患者清单(仅 Mock 模式):给签署弹窗做“点一下就能定位”的输入提示 */
|
||||
export function getMockPatientHints(): { id: string; name: string }[] {
|
||||
return useMockData ? mockPatients.map((item) => ({ id: item.id, name: item.name })) : []
|
||||
/**
|
||||
* 演示患者清单(仅 Mock 模式)。
|
||||
*
|
||||
* 给发起签署弹窗做「候选患者」下拉:原型允许按 患者ID / 姓名 / 证件号 / 就诊号
|
||||
* 模糊匹配,因此这里返回完整档案(克隆一份,避免调用方就地改动演示数据),
|
||||
* 而不是只给 id + 姓名。真实模式返回空数组,候选下拉自然不出现。
|
||||
*/
|
||||
export function getMockPatients(): PatientProfile[] {
|
||||
return useMockData ? mockPatients.map(clonePatient) : []
|
||||
}
|
||||
|
||||
function isNotFoundError(error: unknown) {
|
||||
@@ -467,8 +546,16 @@ function normalizeVisitType(value: string): VisitType {
|
||||
return '其他'
|
||||
}
|
||||
|
||||
const METHOD_BY_BACKEND: Record<BackendSigningMethod, SigningMethod> = {
|
||||
PAD: 'pad',
|
||||
SMS: 'sms',
|
||||
IPAD: 'ipad',
|
||||
WX: 'wx',
|
||||
}
|
||||
|
||||
function normalizeSigningMethod(value: BackendSigningMethod): SigningMethod {
|
||||
return value === 'SMS' ? 'sms' : 'pad'
|
||||
// 后端理论上只回这四个;出现未知取值时按手写板兜底,避免整行渲染不出来
|
||||
return METHOD_BY_BACKEND[value] ?? 'pad'
|
||||
}
|
||||
|
||||
function normalizeTaskStatus(value: BackendSigningTaskStatus): SigningTaskStatus {
|
||||
@@ -531,8 +618,8 @@ function mapAvailableTemplate(dto: AvailableTemplateVersionResponseDto): Signing
|
||||
campusId: dto.campusId,
|
||||
category: dto.category || '未分类',
|
||||
description: `已发布版本 ${dto.versionNo}`,
|
||||
// 可用模板接口没有返回通道 ACL;具体通道仍由后端在创建时校验。
|
||||
supportedMethods: ['pad', 'sms'],
|
||||
// 可用模板接口不返回方式 ACL;四种方式都放行,实际允许范围由后端在创建时校验。
|
||||
supportedMethods: [...ALL_SIGNING_METHODS],
|
||||
// 接口不返回正文,只能带出哈希;正文由 hydrateTemplateContents 按需补回
|
||||
contentSha256: dto.contentSha256,
|
||||
contentHtml: cachedContent || undefined,
|
||||
@@ -677,7 +764,7 @@ function createTemplateFromInput(input: CreateSigningTaskInput): SigningTemplate
|
||||
department: input.department,
|
||||
category: '—',
|
||||
description: '',
|
||||
supportedMethods: ['pad', 'sms'],
|
||||
supportedMethods: [...ALL_SIGNING_METHODS],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -708,7 +795,7 @@ function toBackendTaskQuery(query: SigningTaskQuery, options: SigningApiOptions
|
||||
}
|
||||
|
||||
if (query.method && query.method !== 'all') {
|
||||
params.signMethod = query.method === 'sms' ? 'SMS' : 'PAD'
|
||||
params.signMethod = toBackendSigningMethod(query.method)
|
||||
}
|
||||
|
||||
if (query.visitType && query.visitType !== 'all') {
|
||||
@@ -1041,7 +1128,7 @@ export async function createSigningTask(input: CreateSigningTaskInput): Promise<
|
||||
patientId: input.patientId,
|
||||
visitId: input.visitId,
|
||||
templateVersionId: input.templateVersionId,
|
||||
signMethod: input.method === 'sms' ? 'SMS' : 'PAD',
|
||||
signMethod: toBackendSigningMethod(input.method),
|
||||
idempotencyKey: createIdempotencyKey(),
|
||||
}
|
||||
const response = await request.post<ApiResponseOf<SignTaskResponseDto>>(
|
||||
@@ -1244,7 +1331,7 @@ export async function getSigningTaskEvents(id: string): Promise<SigningTaskEvent
|
||||
{
|
||||
id: `${task.id}-created`,
|
||||
time: task.updatedAt,
|
||||
text: `张文静发起签署任务(${task.method === 'pad' ? '手写板' : '线上短信'})`,
|
||||
text: `张文静发起签署任务(${formatSigningMethod(task.method)})`,
|
||||
eventType: 'TASK_CREATED',
|
||||
},
|
||||
]
|
||||
|
||||
@@ -44,7 +44,14 @@ export interface PatientProfile {
|
||||
sex: PatientSex
|
||||
age: number
|
||||
idCard: string
|
||||
/** 展示用手机号。真实接口只返回脱敏值,演示数据同样存脱敏值,两边口径一致。 */
|
||||
phone: string
|
||||
/**
|
||||
* 完整手机号,仅演示数据提供。
|
||||
* 「显示明文」与短信投递预填需要完整号码,而患者接口按合规要求只回脱敏值,
|
||||
* 真实模式拿不到时保持 undefined,页面据此隐藏入口——不拿脱敏值冒充明文。
|
||||
*/
|
||||
phoneFull?: string
|
||||
visits: PatientVisit[]
|
||||
}
|
||||
|
||||
@@ -92,7 +99,15 @@ export interface WorkbenchTask {
|
||||
export type WorkbenchTodoStatus =
|
||||
'created' | 'pending' | 'generating' | 'overdue' | 'signed' | 'voided' | 'failed'
|
||||
|
||||
export type SigningMethod = 'pad' | 'sms'
|
||||
/**
|
||||
* 签署方式(原型 V10.2 的四种,四个平铺取值)。
|
||||
*
|
||||
* 现场两种(手写板原笔迹 / Pad 移动端)与线上两种(短信链接 / 微信公众号)。
|
||||
* **原型里没有「通道」这一层** —— 状态标签、筛选项、详情动作都按具体取值精确分支,
|
||||
* 所以这里也不做任何归并;文案与取值口径集中在 utils/signing-method,
|
||||
* 各页面不要再自己写 `method === 'pad' ? … : …`。
|
||||
*/
|
||||
export type SigningMethod = 'pad' | 'sms' | 'ipad' | 'wx'
|
||||
|
||||
export interface WorkbenchTodoTask {
|
||||
id: string
|
||||
@@ -330,7 +345,11 @@ export interface SigningTaskActionOptions extends SigningApiOptions {
|
||||
export type BackendSigningTaskStatus =
|
||||
'CREATED' | 'WAITING_SIGN' | 'SIGNED' | 'EXPIRED' | 'VOIDED' | 'GENERATING' | 'FAILED'
|
||||
|
||||
export type BackendSigningMethod = 'PAD' | 'SMS'
|
||||
/**
|
||||
* 后端签署方式,与前端 SigningMethod 一一对应(大小写之差)。
|
||||
* 原型 V10.2 就是四个平铺取值,没有「通道」归并,这里保持一致。
|
||||
*/
|
||||
export type BackendSigningMethod = 'PAD' | 'SMS' | 'IPAD' | 'WX'
|
||||
|
||||
export type BackendVisitType = 'OUTPATIENT' | 'INPATIENT' | 'CHECKUP' | string
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -5,6 +5,7 @@ import { sendSigningSms } from '@/api/workbench/deliveries'
|
||||
import { collectFieldDefs } from '@/utils/signing-fields'
|
||||
import {
|
||||
createSigningTask,
|
||||
getMockPatients,
|
||||
getPatientProfile,
|
||||
getSigningTemplates,
|
||||
isSigningMockEnabled,
|
||||
@@ -23,14 +24,65 @@ export interface UseSigningTaskFormOptions {
|
||||
initialTemplate?: MaybeRefOrGetter<SigningTemplate | null | undefined>
|
||||
}
|
||||
|
||||
interface TemplateCategoryGroup {
|
||||
/** 左侧科室行(原型 #ntTree 左列) */
|
||||
export interface DocumentTreeDepartmentRow {
|
||||
name: string
|
||||
templates: SigningTemplate[]
|
||||
/** 该科室命中文书总数(含分类内) */
|
||||
count: number
|
||||
open: boolean
|
||||
active: boolean
|
||||
categories: Array<{ name: string; count: number; active: boolean }>
|
||||
}
|
||||
|
||||
interface TemplateDepartmentGroup {
|
||||
name: string
|
||||
categories: TemplateCategoryGroup[]
|
||||
/** 右侧文档行;搜索态下带出「科室 · 分类」小标题(与原型一致) */
|
||||
export interface DocumentListRow {
|
||||
template: SigningTemplate
|
||||
breadcrumb: string
|
||||
}
|
||||
|
||||
/** 关联处方(原型 V6.4:按就诊号确定性生成,仅演示数据) */
|
||||
export interface LinkedPrescription {
|
||||
no: string
|
||||
description: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 演示处方池。原型 RX_POOL 逐条对齐。
|
||||
* 真实模式没有处方接口,因此只在 Mock 模式下生成,绝不凭空造一条看起来真实的处方。
|
||||
*/
|
||||
const PRESCRIPTION_POOL = [
|
||||
{ item: '碘佛醇注射液 100ml', usage: '静脉注射 · 检查用' },
|
||||
{ item: '阿莫西林胶囊 0.25g×24', usage: '口服 每日3次' },
|
||||
{ item: '布洛芬缓释胶囊 0.3g×20', usage: '口服 每日2次' },
|
||||
{ item: '奥美拉唑肠溶片 20mg×14', usage: '口服 每日1次 · 晨服' },
|
||||
{ item: '0.9%氯化钠注射液 250ml', usage: '静脉滴注 · 检查前水化' },
|
||||
]
|
||||
|
||||
/**
|
||||
* 按就诊号确定性生成演示处方。
|
||||
*
|
||||
* 用哈希而不是随机:同一就诊号每次打开弹窗必须带出同一条处方,
|
||||
* 否则「关联处方」会随刷新变来变去,演示时没法解释。
|
||||
*/
|
||||
function buildPrescription(visit: PatientVisit | null): LinkedPrescription | null {
|
||||
if (!visit) {
|
||||
return null
|
||||
}
|
||||
|
||||
let seed = 0
|
||||
|
||||
for (let index = 0; index < visit.visitNo.length; index += 1) {
|
||||
seed = (seed * 31 + visit.visitNo.charCodeAt(index)) % 9973
|
||||
}
|
||||
|
||||
const first = PRESCRIPTION_POOL[seed % PRESCRIPTION_POOL.length]
|
||||
const second = PRESCRIPTION_POOL[(seed + 2) % PRESCRIPTION_POOL.length]
|
||||
const extra = seed % 3 === 0 ? `;${second.item}(${second.usage})` : ''
|
||||
|
||||
return {
|
||||
no: `Rx${visit.visitNo.replace(/\D/g, '').slice(-8)}`,
|
||||
description: `${first.item}(${first.usage})${extra}`,
|
||||
}
|
||||
}
|
||||
|
||||
export function useSigningTaskForm(options: UseSigningTaskFormOptions) {
|
||||
@@ -46,6 +98,15 @@ export function useSigningTaskForm(options: UseSigningTaskFormOptions) {
|
||||
const loadingTemplates = ref(false)
|
||||
const locating = ref(false)
|
||||
const submitting = ref(false)
|
||||
/** 手机号是否明文展示(原型 togglePh);明文查看会记审计日志,因此每次开弹窗都要复位 */
|
||||
const phonePlain = ref(false)
|
||||
/** 候选患者下拉是否展开(原型 #ntSug 的 .on) */
|
||||
const suggestionOpen = ref(false)
|
||||
/** 左列展开的科室(原型 ntOpen 的 'd:' 键) */
|
||||
const expandedDepartments = ref<string[]>([])
|
||||
/** 左列选中的科室与分类(原型 ntDeptSel / ntCatSel) */
|
||||
const selectedDepartment = ref('')
|
||||
const selectedCategory = ref('')
|
||||
|
||||
const errors = reactive({
|
||||
patient: '',
|
||||
@@ -54,6 +115,9 @@ export function useSigningTaskForm(options: UseSigningTaskFormOptions) {
|
||||
sms: '',
|
||||
})
|
||||
|
||||
/** 演示候选患者(真实模式为空数组,候选下拉自然不出现) */
|
||||
const patientCandidates = getMockPatients()
|
||||
|
||||
const selectedVisit = computed(
|
||||
() => visits.value.find((visit) => visit.id === selectedVisitId.value) ?? null,
|
||||
)
|
||||
@@ -62,7 +126,35 @@ export function useSigningTaskForm(options: UseSigningTaskFormOptions) {
|
||||
() => templates.value.find((template) => template.id === selectedTemplateId.value) ?? null,
|
||||
)
|
||||
|
||||
const filteredTemplateGroups = computed<TemplateDepartmentGroup[]>(() => {
|
||||
/** 关联处方:随选中就诊带出(仅演示数据) */
|
||||
const prescription = computed<LinkedPrescription | null>(() =>
|
||||
isSigningMockEnabled ? buildPrescription(selectedVisit.value) : null,
|
||||
)
|
||||
|
||||
/**
|
||||
* 候选患者:原型 patMatches 的匹配口径——患者ID / 姓名 / 证件号 / 就诊号,
|
||||
* 任一命中即算匹配;关键字为空时列出全部候选。
|
||||
*/
|
||||
const patientSuggestions = computed<PatientProfile[]>(() => {
|
||||
const keyword = patientId.value.trim()
|
||||
|
||||
if (!keyword) {
|
||||
return patientCandidates
|
||||
}
|
||||
|
||||
const lower = keyword.toLowerCase()
|
||||
|
||||
return patientCandidates.filter(
|
||||
(item) =>
|
||||
item.id.toLowerCase().includes(lower) ||
|
||||
item.name.includes(keyword) ||
|
||||
item.idCard.includes(keyword) ||
|
||||
item.visits.some((visit) => visit.visitNo.toLowerCase().includes(lower)),
|
||||
)
|
||||
})
|
||||
|
||||
/** 命中关键字的科室 → 分类 → 文书(原型 renderNtTree 的组装段) */
|
||||
const matchedDepartments = computed(() => {
|
||||
const keyword = documentSearch.value.trim().toLowerCase()
|
||||
const grouped = new Map<string, Map<string, SigningTemplate[]>>()
|
||||
|
||||
@@ -85,13 +177,86 @@ export function useSigningTaskForm(options: UseSigningTaskFormOptions) {
|
||||
grouped.set(template.department, categories)
|
||||
})
|
||||
|
||||
return [...grouped.entries()].map(([department, categories]) => ({
|
||||
name: department,
|
||||
categories: [...categories.entries()].map(([category, categoryTemplates]) => ({
|
||||
name: category,
|
||||
templates: categoryTemplates,
|
||||
})),
|
||||
}))
|
||||
return [...grouped.entries()].map(([name, categories]) => {
|
||||
const categoryRows = [...categories.entries()].map(([categoryName, items]) => ({
|
||||
name: categoryName,
|
||||
templates: items,
|
||||
}))
|
||||
|
||||
return {
|
||||
name,
|
||||
categories: categoryRows,
|
||||
templates: categoryRows.flatMap((category) => category.templates),
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
/** 搜索态:所有科室展开、不区分选中;否则按用户展开/选中状态渲染 */
|
||||
const searching = computed(() => documentSearch.value.trim().length > 0)
|
||||
|
||||
const departmentRows = computed<DocumentTreeDepartmentRow[]>(() =>
|
||||
matchedDepartments.value.map((department) => {
|
||||
const open = searching.value || expandedDepartments.value.includes(department.name)
|
||||
|
||||
return {
|
||||
name: department.name,
|
||||
count: department.templates.length,
|
||||
open,
|
||||
active:
|
||||
!searching.value &&
|
||||
selectedDepartment.value === department.name &&
|
||||
!selectedCategory.value,
|
||||
categories: department.categories.map((category) => ({
|
||||
name: category.name,
|
||||
count: category.templates.length,
|
||||
active:
|
||||
!searching.value &&
|
||||
selectedDepartment.value === department.name &&
|
||||
selectedCategory.value === category.name,
|
||||
})),
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
/** 右列数据源与标题(原型 renderNtTree 的 rightDocs / rightLbl) */
|
||||
const documentList = computed<{ label: string; rows: DocumentListRow[] }>(() => {
|
||||
if (searching.value) {
|
||||
return {
|
||||
label: '搜索结果',
|
||||
rows: matchedDepartments.value.flatMap((department) =>
|
||||
department.categories.flatMap((category) =>
|
||||
category.templates.map((template) => ({
|
||||
template,
|
||||
breadcrumb: `${department.name} · ${category.name}`,
|
||||
})),
|
||||
),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
const current = matchedDepartments.value.find(
|
||||
(department) => department.name === selectedDepartment.value,
|
||||
)
|
||||
|
||||
if (!current) {
|
||||
return { label: '', rows: [] }
|
||||
}
|
||||
|
||||
if (selectedCategory.value) {
|
||||
const category = current.categories.find((item) => item.name === selectedCategory.value)
|
||||
|
||||
if (category) {
|
||||
return {
|
||||
label: `${current.name} · ${category.name}`,
|
||||
rows: category.templates.map((template) => ({ template, breadcrumb: '' })),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
label: current.name,
|
||||
rows: current.templates.map((template) => ({ template, breadcrumb: '' })),
|
||||
}
|
||||
})
|
||||
|
||||
function getInitialTemplate() {
|
||||
@@ -109,6 +274,61 @@ export function useSigningTaskForm(options: UseSigningTaskFormOptions) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 首次进入默认选中第一个有文书的科室并展开(原型 renderNtTree 的 _init 分支),
|
||||
* 这样右列一进来就有内容,不会是一片空白等人去点。
|
||||
*/
|
||||
function initDocumentTree() {
|
||||
const first = matchedDepartments.value.find((department) => department.templates.length)
|
||||
|
||||
if (!first) {
|
||||
return
|
||||
}
|
||||
|
||||
selectedDepartment.value = first.name
|
||||
selectedCategory.value = ''
|
||||
expandedDepartments.value = [first.name]
|
||||
}
|
||||
|
||||
/** 从文档库带文书进来时:展开并选中该文书所属科室,右列直接展示 */
|
||||
function focusTemplateDepartment(template: SigningTemplate) {
|
||||
selectedDepartment.value = template.department
|
||||
selectedCategory.value = ''
|
||||
|
||||
if (!expandedDepartments.value.includes(template.department)) {
|
||||
expandedDepartments.value = [...expandedDepartments.value, template.department]
|
||||
}
|
||||
}
|
||||
|
||||
function toggleDepartment(name: string) {
|
||||
const expanded = expandedDepartments.value.includes(name)
|
||||
|
||||
// 已选中且已展开的科室再点一次 = 收起;否则展开并选中,同时清掉分类选中
|
||||
if (selectedDepartment.value === name && !selectedCategory.value && expanded) {
|
||||
expandedDepartments.value = expandedDepartments.value.filter((item) => item !== name)
|
||||
return
|
||||
}
|
||||
|
||||
if (!expanded) {
|
||||
expandedDepartments.value = [...expandedDepartments.value, name]
|
||||
}
|
||||
|
||||
selectedDepartment.value = name
|
||||
selectedCategory.value = ''
|
||||
}
|
||||
|
||||
function toggleCategory(departmentName: string, categoryName: string) {
|
||||
if (!expandedDepartments.value.includes(departmentName)) {
|
||||
expandedDepartments.value = [...expandedDepartments.value, departmentName]
|
||||
}
|
||||
|
||||
const sameCategory =
|
||||
selectedDepartment.value === departmentName && selectedCategory.value === categoryName
|
||||
|
||||
selectedDepartment.value = departmentName
|
||||
selectedCategory.value = sameCategory ? '' : categoryName
|
||||
}
|
||||
|
||||
function clearErrors() {
|
||||
errors.patient = ''
|
||||
errors.visit = ''
|
||||
@@ -127,6 +347,11 @@ export function useSigningTaskForm(options: UseSigningTaskFormOptions) {
|
||||
selectedVisitId.value = ''
|
||||
method.value = initialTemplate?.supportedMethods[0] ?? 'pad'
|
||||
smsDestination.value = ''
|
||||
phonePlain.value = false
|
||||
suggestionOpen.value = false
|
||||
selectedDepartment.value = ''
|
||||
selectedCategory.value = ''
|
||||
expandedDepartments.value = []
|
||||
clearErrors()
|
||||
}
|
||||
|
||||
@@ -139,6 +364,7 @@ export function useSigningTaskForm(options: UseSigningTaskFormOptions) {
|
||||
smsDestination.value = ''
|
||||
errors.document = ''
|
||||
errors.sms = ''
|
||||
initDocumentTree()
|
||||
}
|
||||
|
||||
async function loadTemplates() {
|
||||
@@ -155,6 +381,29 @@ export function useSigningTaskForm(options: UseSigningTaskFormOptions) {
|
||||
}
|
||||
|
||||
includeInitialTemplate()
|
||||
|
||||
const initialTemplate = getInitialTemplate()
|
||||
|
||||
if (initialTemplate) {
|
||||
focusTemplateDepartment(initialTemplate)
|
||||
} else {
|
||||
initDocumentTree()
|
||||
}
|
||||
}
|
||||
|
||||
function openSuggestions() {
|
||||
suggestionOpen.value = true
|
||||
}
|
||||
|
||||
function closeSuggestions() {
|
||||
suggestionOpen.value = false
|
||||
}
|
||||
|
||||
/** 点候选行直接定位(原型 pickPat) */
|
||||
async function pickPatient(candidate: PatientProfile) {
|
||||
patientId.value = candidate.id
|
||||
closeSuggestions()
|
||||
await locatePatient()
|
||||
}
|
||||
|
||||
async function locatePatient() {
|
||||
@@ -165,26 +414,49 @@ export function useSigningTaskForm(options: UseSigningTaskFormOptions) {
|
||||
visits.value = []
|
||||
selectedVisitId.value = ''
|
||||
selectedTemplateId.value = getInitialTemplate()?.id ?? ''
|
||||
phonePlain.value = false
|
||||
|
||||
if (!keyword) {
|
||||
errors.patient = '请输入患者 ID、门诊号或住院号'
|
||||
openSuggestions()
|
||||
errors.patient = '请输入患者 ID / 姓名 / 就诊号'
|
||||
return
|
||||
}
|
||||
|
||||
locating.value = true
|
||||
|
||||
try {
|
||||
const profile = await getPatientProfile(keyword)
|
||||
let profile = await getPatientProfile(keyword)
|
||||
|
||||
if (!profile) {
|
||||
const matches = patientSuggestions.value
|
||||
|
||||
// 原型口径:模糊命中唯一一个就直接定位,命中多个让人从候选列表里挑
|
||||
if (matches.length === 1) {
|
||||
profile = matches[0]
|
||||
} else if (matches.length > 1) {
|
||||
openSuggestions()
|
||||
errors.patient = `匹配到 ${matches.length} 位患者,请从候选列表中选择`
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if (!profile) {
|
||||
closeSuggestions()
|
||||
errors.patient = '未找到该患者,请核对输入内容'
|
||||
return
|
||||
}
|
||||
|
||||
closeSuggestions()
|
||||
patientId.value = profile.id
|
||||
patient.value = profile
|
||||
visits.value = profile.visits
|
||||
selectedVisitId.value =
|
||||
profile.visits.find((visit) => visit.isCurrent)?.id ?? profile.visits[0]?.id ?? ''
|
||||
|
||||
// 已经选了短信方式才定位患者时,把投递号码一并带出(原型 locatePatient 同款)
|
||||
if (method.value === 'sms' && !smsDestination.value.trim()) {
|
||||
smsDestination.value = profile.phoneFull ?? ''
|
||||
}
|
||||
} catch {
|
||||
errors.patient = '患者信息查询失败,请稍后重试'
|
||||
} finally {
|
||||
@@ -197,6 +469,16 @@ export function useSigningTaskForm(options: UseSigningTaskFormOptions) {
|
||||
errors.visit = ''
|
||||
}
|
||||
|
||||
/** 原型 togglePh:明文查看会留审计痕迹,因此每次切换都提示一次 */
|
||||
function togglePhonePlain() {
|
||||
if (!patient.value?.phoneFull) {
|
||||
return
|
||||
}
|
||||
|
||||
phonePlain.value = !phonePlain.value
|
||||
ElMessage.info(phonePlain.value ? '明文查看已记录审计日志' : '已恢复脱敏显示')
|
||||
}
|
||||
|
||||
/**
|
||||
* 文书必须带签名位才能发起签署:否则签名会随证据固化,但文书上没有落位的地方,
|
||||
* 签出来的文书看不出患者签过。
|
||||
@@ -214,21 +496,43 @@ export function useSigningTaskForm(options: UseSigningTaskFormOptions) {
|
||||
|
||||
const signatureMissingMessage = '该文书没有签名占位符,请先在文档库中补上签名位再发起'
|
||||
|
||||
/** 原型 toggleNtDoc:单选,再点一次取消选中 */
|
||||
function selectTemplate(template: SigningTemplate) {
|
||||
selectedTemplateId.value = template.id
|
||||
errors.document = hasSignatureSlot(template) ? '' : signatureMissingMessage
|
||||
const nextId = selectedTemplateId.value === template.id ? '' : template.id
|
||||
|
||||
if (!template.supportedMethods.includes(method.value)) {
|
||||
selectedTemplateId.value = nextId
|
||||
errors.document = nextId && !hasSignatureSlot(template) ? signatureMissingMessage : ''
|
||||
|
||||
if (nextId && !template.supportedMethods.includes(method.value)) {
|
||||
method.value = template.supportedMethods[0] ?? 'pad'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 文书模板声明的可签署方式,按**具体取值**精确判断(四个平铺值,没有通道归并)。
|
||||
* 演示模板一律声明全部四种;真实模式由文档库配置决定。
|
||||
*/
|
||||
function isMethodAllowed(nextMethod: SigningMethod) {
|
||||
const template = selectedTemplate.value
|
||||
|
||||
if (!template) {
|
||||
return true
|
||||
}
|
||||
|
||||
return template.supportedMethods.includes(nextMethod)
|
||||
}
|
||||
|
||||
function selectMethod(nextMethod: SigningMethod) {
|
||||
if (selectedTemplate.value && !selectedTemplate.value.supportedMethods.includes(nextMethod)) {
|
||||
if (!isMethodAllowed(nextMethod)) {
|
||||
return
|
||||
}
|
||||
|
||||
method.value = nextMethod
|
||||
|
||||
// 短信方式要确认投递号码:有完整号码就预填,没有则留空由操作人填写
|
||||
if (nextMethod === 'sms' && !smsDestination.value.trim()) {
|
||||
smsDestination.value = patient.value?.phoneFull ?? ''
|
||||
}
|
||||
}
|
||||
|
||||
function maskPhone(value: string) {
|
||||
@@ -261,7 +565,7 @@ export function useSigningTaskForm(options: UseSigningTaskFormOptions) {
|
||||
valid = false
|
||||
}
|
||||
|
||||
if (!isSigningMockEnabled && method.value === 'sms') {
|
||||
if (method.value === 'sms') {
|
||||
const destination = smsDestination.value.trim().replace(/\s+/g, '')
|
||||
|
||||
if (!/^\+?[1-9]\d{6,14}$/.test(destination)) {
|
||||
@@ -309,17 +613,25 @@ export function useSigningTaskForm(options: UseSigningTaskFormOptions) {
|
||||
}
|
||||
|
||||
return {
|
||||
closeSuggestions,
|
||||
departmentRows,
|
||||
documentList,
|
||||
documentSearch,
|
||||
errors,
|
||||
filteredTemplateGroups,
|
||||
isMethodAllowed,
|
||||
loadingTemplates,
|
||||
loadTemplates,
|
||||
locatePatient,
|
||||
locating,
|
||||
maskPhone,
|
||||
method,
|
||||
openSuggestions,
|
||||
patient,
|
||||
patientId,
|
||||
patientSuggestions,
|
||||
phonePlain,
|
||||
pickPatient,
|
||||
prescription,
|
||||
resetForNextTask,
|
||||
resetForm,
|
||||
selectMethod,
|
||||
@@ -332,7 +644,11 @@ export function useSigningTaskForm(options: UseSigningTaskFormOptions) {
|
||||
smsDestination,
|
||||
submit,
|
||||
submitting,
|
||||
suggestionOpen,
|
||||
templates,
|
||||
toggleCategory,
|
||||
toggleDepartment,
|
||||
togglePhonePlain,
|
||||
visits,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,8 +17,9 @@
|
||||
* 只声明实际用到的字段,不绑定文档库页面的类型,避免 utils 反向依赖 views。
|
||||
*/
|
||||
|
||||
import type { SigningFieldDef, SigningMethod, SigningTemplate } from '@/api/workbench/types'
|
||||
import type { SigningFieldDef, SigningTemplate } from '@/api/workbench/types'
|
||||
|
||||
import { ALL_SIGNING_METHODS } from './signing-method'
|
||||
import { buildFieldSpanHtml } from './signing-fields'
|
||||
import { MOCK_STORAGE_KEYS, readMockState, writeMockState } from './mock-storage'
|
||||
|
||||
@@ -311,7 +312,7 @@ export function readLocalSigningTemplates(): SigningTemplate[] {
|
||||
campusId: document.campusId ?? undefined,
|
||||
category: category.name,
|
||||
description: document.description ?? '',
|
||||
supportedMethods: ['pad', 'sms'] as SigningMethod[],
|
||||
supportedMethods: [...ALL_SIGNING_METHODS],
|
||||
contentHtml: document.contentHtml,
|
||||
contentSha256: document.contentSha256,
|
||||
})),
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
/**
|
||||
* 签署方式的取值口径与文案(原型 V10.2 的四种)。
|
||||
*
|
||||
* 为什么要单独成文件:文案在发起签署弹窗、任务卡、详情动作、记录表、报表维度五处都要用,
|
||||
* 各写一份会随口径漂移而悄悄不一致(新增方式后列表静默显示成短信)。
|
||||
*
|
||||
* **原型里没有「通道」这一层**,四个取值都是平铺的,各处按具体取值精确分支;
|
||||
* 唯一存在归并的地方是任务卡的「待签署」标签(`readPendingTag`),
|
||||
* 那也照抄原型的三分支,不是「现场/线上」两分。
|
||||
*/
|
||||
import type { BackendSigningMethod, SigningMethod } from '@/api/workbench/types'
|
||||
|
||||
export interface SigningMethodDescriptor {
|
||||
key: SigningMethod
|
||||
/** 发起签署弹窗里的完整文案(原型 #mdNew 的 D 区四张卡片) */
|
||||
title: string
|
||||
/** 任务卡与列表用的短文案 */
|
||||
short: string
|
||||
/** 筛选项文案(记录查询与报表两处沿用各自原型的写法) */
|
||||
filterLabel: string
|
||||
/** 短文案前的图标(原型 methodLabel 同款) */
|
||||
icon: string
|
||||
/**
|
||||
* 原型 methodLabel 的完整写法(任务详情、报表维度、导出用)。
|
||||
*
|
||||
* 单独给字段而不是拼 `${icon} ${short}签署`:原型里 `ipad` 是「📲 Pad 签署」——
|
||||
* 拉丁词与「签署」之间**有空格**,而中文词(手写板 / 短信 / 公众号)之间没有。
|
||||
* 拼接会在 Pad 上丢空格,与原型不一致(报表维度那一行会变成「📲 Pad签署」)。
|
||||
*/
|
||||
label: string
|
||||
}
|
||||
|
||||
/** 顺序与原型 #mdNew 的 D 区一致:现场两种在前,线上两种在后 */
|
||||
export const SIGNING_METHODS: readonly SigningMethodDescriptor[] = [
|
||||
{
|
||||
key: 'pad',
|
||||
title: '手写板原笔迹签署(现场)',
|
||||
short: '手写板',
|
||||
filterLabel: '手写板',
|
||||
icon: '✍️',
|
||||
label: '✍️ 手写板签署',
|
||||
},
|
||||
{
|
||||
key: 'sms',
|
||||
title: '短信链接签署(患者移动端 H5)',
|
||||
short: '短信',
|
||||
filterLabel: '短信线上',
|
||||
icon: '📱',
|
||||
label: '📱 短信签署',
|
||||
},
|
||||
{
|
||||
key: 'ipad',
|
||||
title: 'Pad 移动端签署(床旁/诊间)',
|
||||
short: 'Pad',
|
||||
filterLabel: 'Pad',
|
||||
icon: '📲',
|
||||
label: '📲 Pad 签署',
|
||||
},
|
||||
{
|
||||
key: 'wx',
|
||||
title: '微信公众号签署(患者微信内签署)',
|
||||
short: '公众号',
|
||||
filterLabel: '公众号',
|
||||
icon: '💬',
|
||||
label: '💬 公众号签署',
|
||||
},
|
||||
]
|
||||
|
||||
const METHOD_BY_KEY = new Map<SigningMethod, SigningMethodDescriptor>(
|
||||
SIGNING_METHODS.map((item) => [item.key, item]),
|
||||
)
|
||||
|
||||
/**
|
||||
* 全部四种方式。演示数据里模板一律声明全部允许 ——
|
||||
* 原型没有「按文书限制签署方式」这个概念,`supportedMethods` 是本仓库的扩展位。
|
||||
*/
|
||||
export const ALL_SIGNING_METHODS: readonly SigningMethod[] = SIGNING_METHODS.map((item) => item.key)
|
||||
|
||||
/** 取方式描述。遇到未知取值按现场手写板兜底,避免列表出现空白单元格 */
|
||||
export function readSigningMethod(method: SigningMethod): SigningMethodDescriptor {
|
||||
return METHOD_BY_KEY.get(method) ?? SIGNING_METHODS[0]
|
||||
}
|
||||
|
||||
/** 前端取值 → 后端取值,一一对应,不做任何归并 */
|
||||
export function toBackendSigningMethod(method: SigningMethod): BackendSigningMethod {
|
||||
return method.toUpperCase() as BackendSigningMethod
|
||||
}
|
||||
|
||||
/** 带图标的短文案,如「✍️ 手写板」 */
|
||||
export function formatSigningMethod(method: SigningMethod): string {
|
||||
const descriptor = readSigningMethod(method)
|
||||
return `${descriptor.icon} ${descriptor.short}`
|
||||
}
|
||||
|
||||
/** 原型 methodLabel:✍️ 手写板签署 / 📲 Pad 签署 / 💬 公众号签署 / 📱 短信签署 */
|
||||
export function formatSigningMethodTitle(method: SigningMethod): string {
|
||||
return readSigningMethod(method).label
|
||||
}
|
||||
|
||||
/**
|
||||
* 任务卡的「待签署」标签。
|
||||
*
|
||||
* 配色照抄原型 renderTasks 的三分支:`pad` / `ipad` / 其余(`sms` 与 `wx` 共用短信紫)。
|
||||
* 文案按**具体取值**给出(原型对 `wx` 也写「待签署·短信」,那是它漏改的老文案;
|
||||
* 本仓库的取值是四个平铺值,写清楚是哪种更不容易看错)。
|
||||
*/
|
||||
export function readPendingTag(method: SigningMethod): { className: string; text: string } {
|
||||
const className = method === 'pad' ? 'tg-pad' : method === 'ipad' ? 'tg-ipad' : 'tg-sms'
|
||||
return { className, text: `待签署·${readSigningMethod(method).short}` }
|
||||
}
|
||||
|
||||
/**
|
||||
* 「方式」筛选项。原型两处的选项**并不一样**,所以分开维护,别合并成一份:
|
||||
* - 记录查询 `#qMethod`:3 项(手写板 / 短信线上 / Pad),**没有公众号**;
|
||||
* - 报表分析 `#repMethod`:4 项(手写板 / Pad / 短信 / 公众号),且短信写作「短信」。
|
||||
*/
|
||||
export interface SigningMethodFilterOption {
|
||||
value: SigningMethod
|
||||
label: string
|
||||
}
|
||||
|
||||
export const WORKBENCH_METHOD_FILTERS: readonly SigningMethodFilterOption[] = [
|
||||
{ value: 'pad', label: '手写板' },
|
||||
{ value: 'sms', label: '短信线上' },
|
||||
{ value: 'ipad', label: 'Pad' },
|
||||
]
|
||||
|
||||
export const REPORT_METHOD_FILTERS: readonly SigningMethodFilterOption[] = [
|
||||
{ value: 'pad', label: '手写板' },
|
||||
{ value: 'ipad', label: 'Pad' },
|
||||
{ value: 'sms', label: '短信' },
|
||||
{ value: 'wx', label: '公众号' },
|
||||
]
|
||||
|
||||
/**
|
||||
* 切换签署方式(原型 switchMethod:`m || (curTask.method === 'pad' ? 'sms' : 'pad')`)。
|
||||
* 原型就是「手写板 ↔ 短信」二选一,Pad / 公众号一律回到手写板。
|
||||
*/
|
||||
export function toggleSigningMethod(method: SigningMethod): SigningMethod {
|
||||
return method === 'pad' ? 'sms' : 'pad'
|
||||
}
|
||||
@@ -28,6 +28,7 @@ import { useLoginStore } from '@/stores/login'
|
||||
import { demoCampuses, demoDepartments } from '@/utils/mock-document-library'
|
||||
import { mockFlags } from '@/utils/mock-flags'
|
||||
import { clearMockState } from '@/utils/mock-storage'
|
||||
import { ALL_SIGNING_METHODS } from '@/utils/signing-method'
|
||||
|
||||
import DocumentFilterTabs from './components/DocumentFilterTabs.vue'
|
||||
import DocumentHistoryDialog from './components/DocumentHistoryDialog.vue'
|
||||
@@ -987,7 +988,7 @@ async function toSigningTemplate(template: DocumentTemplate): Promise<SigningTem
|
||||
campusId: template.campusId ?? undefined,
|
||||
category: template.category,
|
||||
description: template.description ?? '',
|
||||
supportedMethods: ['pad', 'sms'],
|
||||
supportedMethods: [...ALL_SIGNING_METHODS],
|
||||
contentHtml,
|
||||
contentSha256,
|
||||
}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { formatSigningMethod, readSigningMethod } from '@/utils/signing-method'
|
||||
|
||||
import type { ReportTask, ReportTaskStatus } from '../types'
|
||||
|
||||
defineProps<{
|
||||
@@ -30,7 +32,7 @@ const statusLabels: Record<ReportTaskStatus, string> = {
|
||||
|
||||
function getStatusLabel(row: ReportTask) {
|
||||
if (row.status === 'pending') {
|
||||
return `${statusLabels[row.status]}·${row.method === 'pad' ? '手写板' : '短信'}`
|
||||
return `${statusLabels[row.status]}·${readSigningMethod(row.method).short}`
|
||||
}
|
||||
|
||||
return statusLabels[row.status]
|
||||
@@ -131,7 +133,7 @@ function handlePageSizeChange(event: Event) {
|
||||
</td>
|
||||
<td class="document-name" :title="row.documentName">{{ row.documentName }}</td>
|
||||
<td>{{ row.department }}</td>
|
||||
<td>{{ row.method === 'pad' ? '✍️ 手写板' : '📱 短信' }}</td>
|
||||
<td>{{ formatSigningMethod(row.method) }}</td>
|
||||
<td>
|
||||
<span class="status-tag" :class="getStatusClass(row.status)">
|
||||
{{ getStatusLabel(row) }}
|
||||
|
||||
@@ -22,6 +22,11 @@ import type {
|
||||
ReportTasksQueryDto,
|
||||
} from '@/api/management/types'
|
||||
import { useLoginStore } from '@/stores/login'
|
||||
import {
|
||||
formatSigningMethodTitle,
|
||||
REPORT_METHOD_FILTERS,
|
||||
toBackendSigningMethod,
|
||||
} from '@/utils/signing-method'
|
||||
|
||||
import DepartmentDistributionChart from './components/DepartmentDistributionChart.vue'
|
||||
import ReportDetailTable from './components/ReportDetailTable.vue'
|
||||
@@ -102,8 +107,7 @@ const filters = reactive<ReportFilterForm>({
|
||||
|
||||
const methodOptions: ReportSelectOption<ReportMethodFilter>[] = [
|
||||
{ value: 'all', label: '全部方式' },
|
||||
{ value: 'pad', label: '手写板' },
|
||||
{ value: 'sms', label: '短信' },
|
||||
...REPORT_METHOD_FILTERS.map((option) => ({ value: option.value, label: option.label })),
|
||||
]
|
||||
|
||||
interface ReportDateRange {
|
||||
@@ -571,7 +575,7 @@ function getScopeFilter(): ReportFilterDto {
|
||||
}
|
||||
|
||||
if (filters.method !== 'all') {
|
||||
filter.signMethod = filters.method === 'sms' ? 'SMS' : 'PAD'
|
||||
filter.signMethod = toBackendSigningMethod(filters.method)
|
||||
}
|
||||
|
||||
const keyword = filters.keyword.trim()
|
||||
@@ -994,7 +998,7 @@ function downloadMockReport() {
|
||||
task.documentName,
|
||||
task.department,
|
||||
task.campus,
|
||||
task.method === 'pad' ? '手写板' : '短信',
|
||||
formatSigningMethodTitle(task.method),
|
||||
getReportStatusLabel(task.status),
|
||||
formatExportDate(task.createdAt),
|
||||
formatExportDate(task.signedAt),
|
||||
|
||||
@@ -169,6 +169,38 @@ export const reportTasks: ReportTask[] = [
|
||||
status: 'void',
|
||||
createdAt: daysAgo(5, 11, 45),
|
||||
},
|
||||
// Pad 与公众号两条:四种方式都要有样本,否则「方式」列与筛选选到 Pad / 公众号时
|
||||
// 永远只能得到空结果,看着像筛选坏了(其实是演示数据没覆盖)。
|
||||
{
|
||||
id: 'T0813002',
|
||||
patientName: '罗文静',
|
||||
patientId: 'P202610007',
|
||||
documentName: '床旁超声检查知情同意书',
|
||||
department: '放射科',
|
||||
campus: '东院区',
|
||||
category: 'consent',
|
||||
method: 'ipad',
|
||||
status: 'signed',
|
||||
createdAt: daysAgo(2, 8, 50),
|
||||
signedAt: daysAgo(2, 8, 58),
|
||||
expiredAt: daysAgo(1, 8, 50),
|
||||
durationMinutes: 8,
|
||||
},
|
||||
{
|
||||
id: 'T0813001',
|
||||
patientName: '梁俊豪',
|
||||
patientId: 'P202610008',
|
||||
documentName: '体检报告发放及知情同意书',
|
||||
department: '健康管理中心',
|
||||
campus: '东院区',
|
||||
category: 'consent',
|
||||
method: 'wx',
|
||||
status: 'signed',
|
||||
createdAt: daysAgo(3, 16, 10),
|
||||
signedAt: daysAgo(3, 16, 26),
|
||||
expiredAt: daysAgo(2, 16, 10),
|
||||
durationMinutes: 16,
|
||||
},
|
||||
]
|
||||
|
||||
export const reportDepartmentColors: Record<string, string> = {
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { SigningMethod } from '@/api/workbench/types'
|
||||
|
||||
export type ReportPeriodOption = '7d' | '14d' | 'month' | 'custom'
|
||||
|
||||
/** 院区由后端字典提供,保留 all 作为全院筛选值。 */
|
||||
@@ -5,7 +7,7 @@ export type ReportCampus = 'all' | (string & {})
|
||||
|
||||
export type ReportDocumentCategory = 'all' | (string & {})
|
||||
|
||||
export type ReportMethodFilter = 'all' | 'pad' | 'sms'
|
||||
export type ReportMethodFilter = 'all' | SigningMethod
|
||||
|
||||
export interface ReportFilterForm {
|
||||
period: ReportPeriodOption
|
||||
@@ -30,7 +32,7 @@ export interface ReportMetricCard {
|
||||
|
||||
export type ReportTaskStatus = 'signed' | 'pending' | 'signing' | 'expired' | 'void' | 'failed'
|
||||
|
||||
export type ReportSigningMethod = 'pad' | 'sms'
|
||||
export type ReportSigningMethod = SigningMethod
|
||||
|
||||
export interface ReportTask {
|
||||
id: string
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
|
||||
import type { ReportTaskRow } from '@/api/workbench/report-analytics'
|
||||
import type { SigningMethod, SigningTaskStatus } from '@/api/workbench/types'
|
||||
import { formatSigningMethodTitle, SIGNING_METHODS } from '@/utils/signing-method'
|
||||
|
||||
import type {
|
||||
ReportBarItem,
|
||||
@@ -31,10 +32,21 @@ export const OVERDUE_MINUTES = 2880
|
||||
/** 趋势图最多画多少根柱子:自定义区间可能跨好几个月,画不下也没人看 */
|
||||
const MAX_TREND_DAYS = 60
|
||||
|
||||
export const METHOD_LABELS: Record<SigningMethod, string> = {
|
||||
pad: '手写板',
|
||||
sms: '短信线上',
|
||||
}
|
||||
/**
|
||||
* 签署方式维度文案。
|
||||
* 口径统一走 utils/signing-method:四种方式各成一行,不能只列 pad / sms——
|
||||
* 报表维度是按任务实际取值聚合的,缺一行会让 Pad / 公众号的任务在图表里显示成空白。
|
||||
* 文案取原型的 methodLabel(`✍️ 手写板签署` 这种带图标的完整写法),
|
||||
* 与报表明细表的「方式」列一致。
|
||||
*
|
||||
* ⚠️ 这是**查表**,没有 `readSigningMethod` 那样的未知取值兜底:`METHOD_LABELS[未知值]`
|
||||
* 会得到 undefined,汇总矩阵会多出一行没有名字的分组。今天不会触发(`SigningMethod`
|
||||
* 是封闭联合,取值只来自本仓库的演示数据),但接真实接口时必须在边界上先把后端取值
|
||||
* 归一到这四个(见 api/workbench/signing.ts 的 normalizeSigningMethod),不要指望这里兜底。
|
||||
*/
|
||||
export const METHOD_LABELS: Record<SigningMethod, string> = Object.fromEntries(
|
||||
SIGNING_METHODS.map((option) => [option.key, formatSigningMethodTitle(option.key)]),
|
||||
) as Record<SigningMethod, string>
|
||||
|
||||
/**
|
||||
* 状态文案。六种状态与后端枚举一一对应,不映射成原型里的「待科室确认」——
|
||||
|
||||
@@ -5,6 +5,7 @@ import { ElMessage } from 'element-plus'
|
||||
import type { ReportTaskRow } from '@/api/workbench/report-analytics'
|
||||
import type { SigningMethod, WorkbenchCampus } from '@/api/workbench/types'
|
||||
import { downloadCsv, fileStamp } from '@/utils/csv-export'
|
||||
import { REPORT_METHOD_FILTERS } from '@/utils/signing-method'
|
||||
|
||||
import { filterReportTasks, formatDuration, METHOD_LABELS, STATUS_LABELS } from '../aggregate'
|
||||
import type { ReportDateRange, ReportExportColumn, ReportFilterForm, ReportFilterOption } from '../types'
|
||||
@@ -44,8 +45,7 @@ const dateRangeOptions: ReportFilterOption<ReportDateRange>[] = [
|
||||
|
||||
const methodOptions: ReportFilterOption<SigningMethod | 'all'>[] = [
|
||||
{ label: '全部方式', value: 'all' },
|
||||
{ label: '手写板', value: 'pad' },
|
||||
{ label: '短信线上', value: 'sms' },
|
||||
...REPORT_METHOD_FILTERS.map((option) => ({ label: option.label, value: option.value })),
|
||||
]
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import type { SigningMethod, WorkbenchCampus } from '@/api/workbench/types'
|
||||
import { REPORT_METHOD_FILTERS } from '@/utils/signing-method'
|
||||
|
||||
import { reportFilter, resetReportFilter } from '../filter-store'
|
||||
import type { ReportDateRange, ReportFilterOption } from '../types'
|
||||
@@ -32,10 +33,10 @@ const dateRangeOptions: ReportFilterOption<ReportDateRange>[] = [
|
||||
{ label: '自定义…', value: 'custom' },
|
||||
]
|
||||
|
||||
/** 原型 #repMethod 是 4 项(手写板 / Pad / 短信 / 公众号),与记录查询的 3 项不同 */
|
||||
const methodOptions: ReportFilterOption<SigningMethod | 'all'>[] = [
|
||||
{ label: '全部方式', value: 'all' },
|
||||
{ label: '手写板', value: 'pad' },
|
||||
{ label: '短信线上', value: 'sms' },
|
||||
...REPORT_METHOD_FILTERS.map((option) => ({ label: option.label, value: option.value })),
|
||||
]
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { readPendingTag } from '@/utils/signing-method'
|
||||
|
||||
import type { HomeTodoItem } from '../types'
|
||||
|
||||
defineProps<{
|
||||
@@ -15,7 +17,7 @@ function getStatusLabel(item: HomeTodoItem) {
|
||||
case 'created':
|
||||
return '待发送'
|
||||
case 'pending':
|
||||
return item.method === 'pad' ? '待签署·手写板' : '待签署·短信'
|
||||
return readPendingTag(item.method).text
|
||||
case 'generating':
|
||||
return '文书生成中'
|
||||
case 'overdue':
|
||||
@@ -35,7 +37,8 @@ function getStatusClass(item: HomeTodoItem) {
|
||||
}
|
||||
|
||||
if (item.status === 'pending') {
|
||||
return item.method === 'pad' ? 'todo-status--pad' : 'todo-status--sms'
|
||||
// 与签署工作台同口径:待签署徽标按具体方式分色
|
||||
return `todo-status--${readPendingTag(item.method).className.replace('tg-', '')}`
|
||||
}
|
||||
|
||||
if (item.status === 'signed') {
|
||||
@@ -203,6 +206,11 @@ function getHint(item: HomeTodoItem) {
|
||||
background: var(--brand-l);
|
||||
}
|
||||
|
||||
.todo-status--ipad {
|
||||
color: #2b6cb0;
|
||||
background: #e8f1fb;
|
||||
}
|
||||
|
||||
.todo-status--sms {
|
||||
color: #7a4dbb;
|
||||
background: #f3ecfb;
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { SigningMethod } from '@/api/workbench/types'
|
||||
|
||||
export type HomeMetricTone = 'brand' | 'success' | 'warning' | 'danger'
|
||||
|
||||
export type HomeMetricKey = 'todaySigned' | 'pending' | 'overdue' | 'templates'
|
||||
@@ -21,7 +23,8 @@ export interface HomeTrendPoint {
|
||||
export type HomeTodoStatus =
|
||||
'created' | 'pending' | 'generating' | 'overdue' | 'signed' | 'voided' | 'failed'
|
||||
|
||||
export type HomeSigningMethod = 'pad' | 'sms'
|
||||
/** 与签署任务同一套方式口径,否则新通道的任务映射到首页待办会被 TS 挡下 */
|
||||
export type HomeSigningMethod = SigningMethod
|
||||
|
||||
export interface HomeTodoItem {
|
||||
id: string
|
||||
|
||||
@@ -5,6 +5,7 @@ import type { SigningTaskEvent, SigningTaskStatus } from '@/api/workbench/types'
|
||||
import SigningDocumentInteractive from '@/components/signing/SigningDocumentInteractive.vue'
|
||||
import SigningDocumentPreview from '@/components/signing/SigningDocumentPreview.vue'
|
||||
import TaskAuditTimeline from '@/components/signing/TaskAuditTimeline.vue'
|
||||
import { formatSigningMethod } from '@/utils/signing-method'
|
||||
|
||||
import type { SigningRecordRow } from '../types'
|
||||
|
||||
@@ -95,7 +96,7 @@ function value(value: string | undefined) {
|
||||
<div><dt>院区</dt><dd>{{ value(record.campus) }}</dd></div>
|
||||
<div>
|
||||
<dt>签署方式</dt>
|
||||
<dd>{{ record.method === 'pad' ? '✍️ 手写板' : '📱 短信' }}</dd>
|
||||
<dd>{{ formatSigningMethod(record.method) }}</dd>
|
||||
</div>
|
||||
<div><dt>发起时间</dt><dd>{{ value(record.createdAt) }}</dd></div>
|
||||
<div><dt>签署完成</dt><dd>{{ value(record.signedAt) }}</dd></div>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { computed } from 'vue'
|
||||
|
||||
import type { SigningTaskStatus } from '@/api/workbench/types'
|
||||
import { formatSigningMethod, readSigningMethod } from '@/utils/signing-method'
|
||||
|
||||
import type { SigningRecordRow } from '../types'
|
||||
|
||||
@@ -38,14 +39,14 @@ const statusLabels: Record<SigningTaskStatus, string> = {
|
||||
/** 待签署要带出渠道:现场等着签的是手写板,等患者点链接的是短信,处理方式完全不同 */
|
||||
function getStatusLabel(row: SigningRecordRow) {
|
||||
if (row.status === 'pending') {
|
||||
return `${statusLabels[row.status]}·${row.method === 'pad' ? '手写板' : '短信'}`
|
||||
return `${statusLabels[row.status]}·${readSigningMethod(row.method).short}`
|
||||
}
|
||||
|
||||
return statusLabels[row.status]
|
||||
}
|
||||
|
||||
function getMethodLabel(row: SigningRecordRow) {
|
||||
return row.method === 'pad' ? '✍️ 手写板' : '📱 短信'
|
||||
return formatSigningMethod(row.method)
|
||||
}
|
||||
|
||||
function formatTime(value: string | undefined) {
|
||||
|
||||
@@ -22,6 +22,7 @@ import type {
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { useLoginStore } from '@/stores/login'
|
||||
import { downloadCsv, fileStamp, saveBlob } from '@/utils/csv-export'
|
||||
import { readSigningMethod, WORKBENCH_METHOD_FILTERS } from '@/utils/signing-method'
|
||||
import { downloadSigningPdf } from '@/utils/signing-pdf'
|
||||
|
||||
import SigningRecordDetailDialog from './components/SigningRecordDetailDialog.vue'
|
||||
@@ -65,7 +66,7 @@ const EXPORT_COLUMNS: SigningRecordExportColumn[] = [
|
||||
{ label: '文档类别', value: (record) => record.category },
|
||||
{ label: '科室', value: (record) => record.department },
|
||||
{ label: '院区', value: (record) => record.campus },
|
||||
{ label: '签署方式', value: (record) => (record.method === 'pad' ? '手写板' : '短信线上') },
|
||||
{ label: '签署方式', value: (record) => readSigningMethod(record.method).filterLabel },
|
||||
{ label: '状态', value: (record) => statusLabels[record.status] },
|
||||
{ label: '发起时间', value: (record) => record.createdAt ?? '' },
|
||||
{ label: '签署完成时间', value: (record) => record.signedAt ?? '' },
|
||||
@@ -132,10 +133,10 @@ const statusOptions: SigningRecordFilterOption<SigningRecordFilterForm['status']
|
||||
),
|
||||
]
|
||||
|
||||
/** 原型 #qMethod 只有 3 项(手写板 / 短信线上 / Pad),没有公众号 */
|
||||
const methodOptions: SigningRecordFilterOption<SigningMethod | 'all'>[] = [
|
||||
{ label: '全部方式', value: 'all' },
|
||||
{ label: '手写板', value: 'pad' },
|
||||
{ label: '短信线上', value: 'sms' },
|
||||
...WORKBENCH_METHOD_FILTERS.map((option) => ({ label: option.label, value: option.value })),
|
||||
]
|
||||
|
||||
/**
|
||||
|
||||
@@ -4,10 +4,12 @@ import { ref } from 'vue'
|
||||
import type {
|
||||
SigningAnswers,
|
||||
SigningArtifact,
|
||||
SigningMethod,
|
||||
SigningTaskEvent,
|
||||
SigningTaskRecord,
|
||||
} from '@/api/workbench/types'
|
||||
import type { SigningFieldProgress, SigningSignatureRequest } from '@/utils/signing-document'
|
||||
import { formatSigningMethodTitle, readSigningMethod } from '@/utils/signing-method'
|
||||
|
||||
import SigningDocumentInteractive from '@/components/signing/SigningDocumentInteractive.vue'
|
||||
import SigningDocumentPreview from '@/components/signing/SigningDocumentPreview.vue'
|
||||
@@ -38,7 +40,7 @@ defineProps<{
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
action: [action: SigningTaskAction]
|
||||
action: [action: SigningTaskAction, targetMethod?: SigningMethod]
|
||||
downloadArtifact: [artifact: SigningArtifact]
|
||||
selectionChange: [items: string[]]
|
||||
fieldChange: [answers: SigningAnswers, progress: SigningFieldProgress]
|
||||
@@ -47,6 +49,25 @@ const emit = defineEmits<{
|
||||
|
||||
const documentRef = ref<InstanceType<typeof SigningDocumentInteractive> | null>(null)
|
||||
|
||||
/**
|
||||
* 原型任务详情:主按钮 = 当前方式的签署入口,另外三种方式各给一个「转为 X」的次按钮。
|
||||
* 原型的四种方式都走这一套(`待签署` 下四个 if/else 分支,主按钮与其余三个各不相同),
|
||||
* 所以这里按**具体取值**分支,不做「现场 / 线上」归并。
|
||||
*/
|
||||
const ALTERNATE_METHODS: readonly SigningMethod[] = ['pad', 'sms', 'ipad', 'wx']
|
||||
|
||||
function alternateMethods(current: SigningMethod): SigningMethod[] {
|
||||
return ALTERNATE_METHODS.filter((item) => item !== current)
|
||||
}
|
||||
|
||||
/**
|
||||
* 现场设备签署(手写板 / Pad)走本地采集入口;线上(短信 / 公众号)走患者端页面。
|
||||
* 这只是**入口形态**的分组,不是取值归并 —— 按钮文案、状态标签仍按具体取值区分。
|
||||
*/
|
||||
function isOnsiteMethod(method: SigningMethod): boolean {
|
||||
return method === 'pad' || method === 'ipad'
|
||||
}
|
||||
|
||||
/** 供父级「导出 PDF」使用:转发到实际渲染文书的那一层 */
|
||||
function getDocumentElement() {
|
||||
return documentRef.value?.getDocumentElement() ?? null
|
||||
@@ -66,61 +87,63 @@ defineExpose({ getDocumentElement })
|
||||
<div class="task-actions">
|
||||
<span class="task-action-meta">任务 {{ task.id }} · {{ task.patientName }}</span>
|
||||
|
||||
<template v-if="task.status === 'pending' && task.method === 'pad'">
|
||||
<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 v-if="task.status === 'pending'">
|
||||
<!-- 主按钮 = 当前方式的签署入口(原型:手写板/Pad 走现场设备,短信/公众号走线上页面) -->
|
||||
<template v-if="isOnsiteMethod(task.method)">
|
||||
<template v-if="allowLocalSigning">
|
||||
<button type="button" class="action-button" @click="emit('action', 'pad-sign')">
|
||||
{{ formatSigningMethodTitle(task.method) }}
|
||||
</button>
|
||||
</template>
|
||||
<template v-else-if="canDeliveryWrite">
|
||||
<button
|
||||
type="button"
|
||||
class="action-button"
|
||||
:disabled="padSessionLoading"
|
||||
@click="emit('action', 'create-pad-session')"
|
||||
>
|
||||
{{
|
||||
padSessionLoading ? '创建会话中…' : `创建${readSigningMethod(task.method).short}会话`
|
||||
}}
|
||||
</button>
|
||||
<span v-if="padSessionStatus" class="action-hint">
|
||||
会话状态:{{ padSessionStatus }};等待设备适配器读取 challenge
|
||||
</span>
|
||||
<span v-else class="action-hint">创建会话后仍需接入厂商设备适配器完成签名采集</span>
|
||||
</template>
|
||||
</template>
|
||||
<template v-else-if="canDeliveryWrite">
|
||||
<template v-else>
|
||||
<button
|
||||
v-if="allowLocalSigning"
|
||||
type="button"
|
||||
class="action-button"
|
||||
:disabled="padSessionLoading"
|
||||
@click="emit('action', 'create-pad-session')"
|
||||
@click="emit('action', 'online-sign')"
|
||||
>
|
||||
{{ padSessionLoading ? '创建会话中…' : '创建手写板会话' }}
|
||||
查看{{ readSigningMethod(task.method).short }}签署
|
||||
</button>
|
||||
<span v-if="padSessionStatus" class="action-hint">
|
||||
会话状态:{{ padSessionStatus }};等待设备适配器读取 challenge
|
||||
</span>
|
||||
<span v-else class="action-hint">创建会话后仍需接入厂商设备适配器完成签名采集</span>
|
||||
<span v-else class="action-hint">线上签署页面接口待接入</span>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<template v-else-if="task.status === 'pending' && task.method === 'sms'">
|
||||
<!-- 其余三种方式:一键转换(原型四种方式互为切换入口) -->
|
||||
<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"
|
||||
v-for="target in alternateMethods(task.method)"
|
||||
:key="target"
|
||||
type="button"
|
||||
class="action-button action-button--ghost"
|
||||
@click="emit('action', 'switch-method')"
|
||||
@click="emit('action', 'switch-method', target)"
|
||||
>
|
||||
转手写板签署
|
||||
{{ readSigningMethod(target).short }}签署
|
||||
</button>
|
||||
|
||||
<button
|
||||
v-if="canDeliveryWrite"
|
||||
v-if="canDeliveryWrite && task.method === 'sms'"
|
||||
type="button"
|
||||
class="action-button action-button--ghost"
|
||||
@click="emit('action', 'resend-sms')"
|
||||
>
|
||||
重发短信
|
||||
</button>
|
||||
<span v-else-if="task.method === 'wx'" class="action-hint">公众号推送接口待接入</span>
|
||||
</template>
|
||||
|
||||
<template v-else-if="task.status === 'expired' && canReopen">
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { computed } from 'vue'
|
||||
|
||||
import type { SigningTaskRecord } from '@/api/workbench/types'
|
||||
import { formatSigningMethod, readPendingTag } from '@/utils/signing-method'
|
||||
|
||||
const props = defineProps<{
|
||||
tasks: SigningTaskRecord[]
|
||||
@@ -30,14 +31,20 @@ const statusLabels: Record<SigningTaskRecord['status'], string> = {
|
||||
|
||||
function getStatusClass(task: SigningTaskRecord) {
|
||||
if (task.status === 'pending') {
|
||||
return task.method === 'pad' ? 'task-status--pad' : 'task-status--sms'
|
||||
// 待签署徽标按具体方式分色(原型 tg-pad / tg-ipad / tg-sms 三分支)
|
||||
return `task-status--${readPendingTag(task.method).className.replace('tg-', '')}`
|
||||
}
|
||||
|
||||
return `task-status--${task.status}`
|
||||
}
|
||||
|
||||
function getMethodLabel(task: SigningTaskRecord) {
|
||||
return task.method === 'pad' ? '✍ 手写板' : '📱 短信'
|
||||
return formatSigningMethod(task.method)
|
||||
}
|
||||
|
||||
/** 待签署徽标带出具体方式:现场等着签的是手写板/Pad,等患者点链接的是短信/公众号 */
|
||||
function getPendingLabel(task: SigningTaskRecord) {
|
||||
return readPendingTag(task.method).text
|
||||
}
|
||||
|
||||
function getVisitClass(type: SigningTaskRecord['visitType']) {
|
||||
@@ -79,13 +86,7 @@ function getVisitClass(type: SigningTaskRecord['visitType']) {
|
||||
<span class="task-basic">{{ task.sex }} · {{ task.age }}岁</span>
|
||||
<span class="task-spacer" />
|
||||
<span class="task-status" :class="getStatusClass(task)">
|
||||
{{
|
||||
task.status === 'pending'
|
||||
? task.method === 'pad'
|
||||
? '待签署·手写板'
|
||||
: '待签署·短信'
|
||||
: statusLabels[task.status]
|
||||
}}
|
||||
{{ task.status === 'pending' ? getPendingLabel(task) : statusLabels[task.status] }}
|
||||
</span>
|
||||
</span>
|
||||
<span class="task-row task-row--document">📄 {{ task.documentName }}</span>
|
||||
@@ -224,6 +225,12 @@ function getVisitClass(type: SigningTaskRecord['visitType']) {
|
||||
background: var(--brand-l);
|
||||
}
|
||||
|
||||
/* 原型 .tg-ipad:平板方式单独一色,不与手写板混用 */
|
||||
.task-status--ipad {
|
||||
color: #2b6cb0;
|
||||
background: #e8f1fb;
|
||||
}
|
||||
|
||||
.task-status--sms {
|
||||
color: #7a4dbb;
|
||||
background: #f3ecfb;
|
||||
|
||||
@@ -39,6 +39,12 @@ import NewSigningTaskDialog from '@/components/signing/NewSigningTaskDialog.vue'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { useLoginStore } from '@/stores/login'
|
||||
import type { SigningFieldProgress, SigningSignatureRequest } from '@/utils/signing-document'
|
||||
import {
|
||||
formatSigningMethodTitle,
|
||||
readSigningMethod,
|
||||
toggleSigningMethod,
|
||||
WORKBENCH_METHOD_FILTERS,
|
||||
} from '@/utils/signing-method'
|
||||
import { downloadSigningPdf } from '@/utils/signing-pdf'
|
||||
|
||||
import OnlineSigningDialog from './components/OnlineSigningDialog.vue'
|
||||
@@ -156,10 +162,10 @@ const statusOptions: SigningFilterOption<SigningFilterForm['status']>[] = [
|
||||
...STATUS_VALUES.map((status) => ({ label: statusLabels[status], value: status })),
|
||||
]
|
||||
|
||||
/** 原型 #qMethod 只有 3 项(手写板 / 短信线上 / Pad),没有公众号 */
|
||||
const methodOptions: SigningFilterOption<SigningFilterForm['method']>[] = [
|
||||
{ label: '全部方式', value: 'all' },
|
||||
{ label: '手写板', value: 'pad' },
|
||||
{ label: '短信线上', value: 'sms' },
|
||||
...WORKBENCH_METHOD_FILTERS.map((option) => ({ label: option.label, value: option.value })),
|
||||
]
|
||||
|
||||
const visitTypeOptions: SigningFilterOption<VisitType | 'all'>[] = [
|
||||
@@ -512,7 +518,7 @@ async function refreshTask(updatedTask: SigningTaskRecord | null, successMessage
|
||||
ElMessage.success(successMessage)
|
||||
}
|
||||
|
||||
async function handleTaskAction(action: SigningTaskAction) {
|
||||
async function handleTaskAction(action: SigningTaskAction, targetMethod?: SigningMethod) {
|
||||
const task = selectedTask.value
|
||||
|
||||
if (!task) {
|
||||
@@ -535,7 +541,7 @@ async function handleTaskAction(action: SigningTaskAction) {
|
||||
|
||||
if (action === 'pad-sign') {
|
||||
if (!isSigningMockEnabled) {
|
||||
ElMessage.info('手写板设备签署接口待接入')
|
||||
ElMessage.info(`${readSigningMethod(task.method).short}设备签署接口待接入`)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -553,9 +559,11 @@ async function handleTaskAction(action: SigningTaskAction) {
|
||||
try {
|
||||
const session: SignDeliveryResponseDto = await createPadSigningSession(task.id)
|
||||
padSessionStatus.value = session.status
|
||||
ElMessage.success('手写板签署会话已创建,请由设备适配器继续采集签名')
|
||||
ElMessage.success(
|
||||
`${readSigningMethod(task.method).short}签署会话已创建,请由设备适配器继续采集签名`,
|
||||
)
|
||||
} catch {
|
||||
ElMessage.error('手写板签署会话创建失败,请稍后重试')
|
||||
ElMessage.error(`${readSigningMethod(task.method).short}签署会话创建失败,请稍后重试`)
|
||||
} finally {
|
||||
padSessionLoading.value = false
|
||||
}
|
||||
@@ -623,10 +631,11 @@ async function handleTaskAction(action: SigningTaskAction) {
|
||||
return
|
||||
}
|
||||
|
||||
const method: SigningMethod = task.method === 'pad' ? 'sms' : 'pad'
|
||||
// 详情页的次按钮会带上目标方式;没有目标时退回原型 switchMethod 的二选一
|
||||
const method: SigningMethod = targetMethod ?? toggleSigningMethod(task.method)
|
||||
await refreshTask(
|
||||
await updateSigningTaskMethod(task.id, method),
|
||||
method === 'sms' ? '已切换为短信线上签署' : '已切换为手写板签署',
|
||||
`已切换为${formatSigningMethodTitle(method)}`,
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user