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
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,
})),
+141
View File
@@ -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
+16 -4
View File
@@ -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
}