feat(签署方式): 扩展为手写板/短信/Pad/公众号四种并集中口径

This commit is contained in:
yelan
2026-09-25 12:09:58 +08:00
parent bd89a4d31a
commit 8ec55e48c9
26 changed files with 1864 additions and 688 deletions
+2 -1
View File
@@ -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'
+70 -1
View File
@@ -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 {
+18 -8
View File
@@ -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))
+102 -15
View File
@@ -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',
},
]
+21 -2
View File
@@ -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