feat(签署任务): 新增「待科室确认」节点整条链路

原型 V10.2 的 needConfirm:检验 / 基因检测 / 自费类文书要求患者签完之后
由科室再复核签章,避免漏签补签。此前仓库完全没有这个节点,现在补全。

状态模型
- SigningTaskStatus 增加 confirming(待科室确认),BackendSigningTaskStatus 增加
  WAITING_CONFIRM;文档侧 DocumentRecord / TemplateResponseDto / SigningTemplate /
  CreateSigningTaskInput 增加 needConfirm。
- normalizeTaskStatus、mapSigningTask、mapDocument、toBackendTaskQuery 的 statusMap、
  mapAvailableTemplate、readLocalSigningTemplates 同步;needConfirm 取不到一律按 false,
  不凭空给任务多插一道人工节点。

流转
- completeSigningTask 按 needConfirm 决定落 confirming 还是 signed;
  新增 confirmDepartmentSigning(科室复核签章 → 已签署)。
  真实模式暂未接入(后端要重做),如实提示"待接入",不前端自己改状态。
- 任务事件与记录页事件补「已推送至 X 确认队列」「科室复核签章完成,文档归档」两条留痕。

工作台
- 列表徽标走 .task-status--confirming(原型 tg-cfm 橙),状态筛选加「待科室确认」。
- 详情动作按原型给:🏛 科室复核签章(确认完成)+ 催办 + 在线预览 + 下载已签 PDF;
  该状态下不给「作废」(原型只在待签署/已超时给)。
- 新增「科室确认节点」卡(待患者签署后 / 待科室确认 / ✓ 已确认三分支)——
  原型放在文书签名区那一格,这里做成紧跟文书的独立卡,真实模板与回退纸样都能显示。
- confirming 时文书只读(患者已签完,不该再改填写项)。
- 签署完成提示语按 needConfirm 分流:需科室确认的说「已流转至科室确认节点」,
  否则说「签署完成,文档已归档」—— 一律说"完成"会让人以为可以关页面了。
- 新增权限码 sign:task:confirm / sign:task:remind。

记录查询与报表
- 记录查询状态筛选与演示数据加该状态(2 条样本)。
- 报表 STATUS_LABELS / STATUS_COLUMNS / ReportStatusCounts / ReportGroupRow 加这一栏,
  单独成列不并进「已签署」——并进去会让已签署虚高,科室复核本来就是这类文书的卡点。
- 时效样本按「有签署用时」取,待科室确认同样计入;总览统计块加一块。

演示数据
- 工作台补 task-009(confirming + needConfirm),否则该状态的徽标、动作、
  筛选、报表栏在演示环境里一次都走不到。
- 注意:演示文库的麻精同意书**没有** needConfirm(原型里也确实没有),
  所以「新建任务 → 签署 → 落待科室确认」这条流转靠探针夹具验证,没有改产品种子。

验证:probe-dept-confirm.cjs(新建)36/36 PASS,含真实落笔签署后落待科室确认;
回归 probe-new-task 104/104、probe-method-domain 14/14、probe-workbench-lib 42/42、
probe-report-analytics 92/92(断言按七状态更新);vue-tsc 与 eslint 0 错。
This commit is contained in:
yelan
2026-09-26 20:09:00 +08:00
parent 82acc03442
commit 8c70f01e2e
25 changed files with 516 additions and 38 deletions
@@ -998,6 +998,7 @@ async function toSigningTemplate(template: DocumentTemplate): Promise<SigningTem
category: template.category,
description: template.description ?? '',
supportedMethods: [...ALL_SIGNING_METHODS],
needConfirm: template.needConfirm ?? false,
contentHtml,
contentSha256,
}
@@ -88,6 +88,7 @@ function toLocalDocument(document: DocumentRecord): LocalDocument {
// 否则刷新一次卡片上的标签就没了
examType: document.examType,
projectCodes: document.projectCodes,
needConfirm: document.needConfirm,
updatedAt: document.updatedAt,
contentHtml: document.contentHtml,
contentSha256: document.contentSha256,
@@ -23,6 +23,7 @@ const emit = defineEmits<{
const statusLabels: Record<ReportTaskStatus, string> = {
signed: '已签署',
confirming: '待科室确认',
pending: '待签署',
signing: '签署中',
expired: '已超时',
@@ -814,6 +814,7 @@ function mapReportTaskStatus(status: ReportTaskResponseDto['status']): ReportTas
CREATED: 'pending',
WAITING_SIGN: 'pending',
SIGNED: 'signed',
WAITING_CONFIRM: 'confirming',
EXPIRED: 'expired',
VOIDED: 'void',
GENERATING: 'signing',
@@ -1018,6 +1019,7 @@ function handleView(row: ReportTask) {
function getReportStatusLabel(status: ReportTaskStatus) {
const labels: Record<ReportTaskStatus, string> = {
signed: '已签署',
confirming: '待科室确认',
pending: '待签署',
signing: '签署中',
expired: '已超时',
@@ -60,6 +60,23 @@ export const reportTasks: ReportTask[] = [
expiredAt: daysAgo(-1, 9, 12),
durationMinutes: 7,
},
{
id: 'T0818009',
patientName: '王秀兰',
patientId: 'P202610007',
documentName: '内镜检查知情同意书',
department: '消化内科',
campus: '本部院区',
category: 'consent',
method: 'sms',
// 「待科室确认」样本:患者已签完,等科室复核签章 ——
// 没有这条样本,报表里的这一栏永远是 0,看不出它到底有没有接上
status: 'confirming',
createdAt: daysAgo(0, 10, 40),
signedAt: daysAgo(0, 10, 45),
expiredAt: daysAgo(-1, 10, 40),
durationMinutes: 5,
},
{
id: 'T0818002',
patientName: '李淑芬',
@@ -30,7 +30,14 @@ export interface ReportMetricCard {
tone: ReportMetricTone
}
export type ReportTaskStatus = 'signed' | 'pending' | 'signing' | 'expired' | 'void' | 'failed'
export type ReportTaskStatus =
| 'signed'
| 'confirming'
| 'pending'
| 'signing'
| 'expired'
| 'void'
| 'failed'
export type ReportSigningMethod = SigningMethod
+18 -4
View File
@@ -49,12 +49,16 @@ export const METHOD_LABELS: Record<SigningMethod, string> = Object.fromEntries(
) as Record<SigningMethod, string>
/**
* 状态文案。六种状态与后端枚举一一对应,不映射成原型里的「待科室确认」——
* 那个状态在后端枚举里不存在,硬映射会让报表与签署记录页对同一个任务给出不同说法。
* 状态文案。七种状态与后端枚举一一对应。
*
* `confirming`(待科室确认)是**节点状态**:患者已经签完、只差科室复核签章。
* 它在报表里单列一栏而不是并进「已签署」—— 并进去会让「已签署」虚高,
* 而科室复核本来就是这类文书的关键卡点,混掉就看不见卡在哪了。
*/
export const STATUS_LABELS: Record<SigningTaskStatus, string> = {
pending: '待签署',
signing: '签署中',
confirming: '待科室确认',
signed: '已签署',
expired: '已超时',
void: '已作废',
@@ -64,6 +68,7 @@ export const STATUS_LABELS: Record<SigningTaskStatus, string> = {
/** 汇总矩阵与统计块共用的列顺序:先「好」后「坏」,读起来符合预期 */
export const STATUS_COLUMNS: SigningTaskStatus[] = [
'signed',
'confirming',
'pending',
'signing',
'expired',
@@ -106,9 +111,18 @@ export function axisLabelOf(dateKey: string): string {
return `${Number(month)}/${Number(day)}`
}
/** 任务状态 → 六类计数里的字段名:状态枚举与计数字段一一对应,直接索引即可 */
/** 任务状态 → 计数里的字段名:状态枚举与计数字段一一对应,直接索引即可 */
export function emptyCounts(): ReportStatusCounts {
return { total: 0, signed: 0, pending: 0, signing: 0, expired: 0, void: 0, failed: 0 }
return {
total: 0,
signed: 0,
confirming: 0,
pending: 0,
signing: 0,
expired: 0,
void: 0,
failed: 0,
}
}
export function addToCounts(counts: ReportStatusCounts, status: ReportTaskRow['status']): void {
@@ -40,13 +40,23 @@ const totals = computed<ReportStatusCounts>(() =>
(sum, row) => ({
total: sum.total + row.total,
signed: sum.signed + row.signed,
confirming: sum.confirming + row.confirming,
pending: sum.pending + row.pending,
signing: sum.signing + row.signing,
expired: sum.expired + row.expired,
void: sum.void + row.void,
failed: sum.failed + row.failed,
}),
{ total: 0, signed: 0, pending: 0, signing: 0, expired: 0, void: 0, failed: 0 },
{
total: 0,
signed: 0,
confirming: 0,
pending: 0,
signing: 0,
expired: 0,
void: 0,
failed: 0,
},
),
)
@@ -39,6 +39,9 @@ const tiles = computed(() => [
{ label: '累计发起任务', value: counts.value.total, color: '#0e6e8c' },
{ label: '已签署', value: counts.value.signed, color: '#0f9d6c' },
{ label: '待签署', value: counts.value.pending, color: '#3a7bd5' },
// 待科室确认:患者已签、等科室复核签章。取色与任务徽标(原型 .tg-cfm)一致,
// 不沿用原型的 #7a4dbb —— 那个色在本仓库已经给了「签署中」,同色两块会看串
{ label: '待科室确认', value: counts.value.confirming, color: '#c07f00' },
{ label: '签署中', value: counts.value.signing, color: '#7a4dbb' },
{ label: '已超时', value: counts.value.expired, color: '#d9821f' },
{ label: '已作废', value: counts.value.void, color: '#d0534f' },
+2
View File
@@ -41,6 +41,8 @@ export interface ReportFilterOption<T = string> {
export interface ReportStatusCounts {
total: number
signed: number
/** 待科室确认:患者已签、等科室复核签章,不能并进 signed */
confirming: number
pending: number
signing: number
expired: number
@@ -28,6 +28,7 @@ const emit = defineEmits<{
const statusLabels: Record<SigningTaskStatus, string> = {
pending: '待签署',
signing: '签署中',
confirming: '待科室确认',
signed: '已签署',
expired: '已超时',
void: '已作废',
@@ -30,6 +30,7 @@ const pageCount = computed(() => Math.max(Math.ceil(props.total / props.pageSize
const statusLabels: Record<SigningTaskStatus, string> = {
pending: '待签署',
signing: '签署中',
confirming: '待科室确认',
signed: '已签署',
expired: '已超时',
void: '已作废',
@@ -44,6 +44,7 @@ const DEFAULT_PAGE_SIZE = 20
const statusLabels: Record<SigningTaskStatus, string> = {
pending: '待签署',
signing: '签署中',
confirming: '待科室确认',
signed: '已签署',
expired: '已超时',
void: '已作废',
@@ -128,9 +129,9 @@ const campusOptions = computed<SigningRecordFilterOption<WorkbenchCampus | 'all'
const statusOptions: SigningRecordFilterOption<SigningRecordFilterForm['status']>[] = [
{ label: '全部状态', value: 'all' },
...(['pending', 'signing', 'signed', 'expired', 'void', 'failed'] as SigningTaskStatus[]).map(
(status) => ({ label: statusLabels[status], value: status }),
),
...(
['pending', 'signing', 'confirming', 'signed', 'expired', 'void', 'failed'] as SigningTaskStatus[]
).map((status) => ({ label: statusLabels[status], value: status })),
]
/** 原型 #qMethod 只有 3 项(手写板 / 短信线上 / Pad),没有公众号 */
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { ref } from 'vue'
import { computed, ref } from 'vue'
import type {
SigningAnswers,
@@ -18,7 +18,7 @@ import LinkedPrescriptionCard from './LinkedPrescriptionCard.vue'
import SigningArtifactList from './SigningArtifactList.vue'
import type { SigningTaskAction } from '../types'
defineProps<{
const props = defineProps<{
task: SigningTaskRecord | null
loading: boolean
auditEvents: SigningTaskEvent[]
@@ -33,6 +33,10 @@ defineProps<{
canDeliveryWrite: boolean
canReopen: boolean
canVoid: boolean
/** 科室复核签章(sign:task:confirm):只有「待科室确认」的任务用得上 */
canConfirm: boolean
/** 催办(sign:task:remind) */
canRemind: boolean
canViewAudit: boolean
canViewArtifacts: boolean
canDownloadArtifact: boolean
@@ -50,6 +54,22 @@ const emit = defineEmits<{
const documentRef = ref<InstanceType<typeof SigningDocumentInteractive> | null>(null)
/**
* 科室确认节点的三种形态(原型文书签名区那一格的三分支):
* 待患者签署后 / 待科室确认 / ✓ 已确认。
*
* 只看任务状态,不另存一份「科室签没签」的布尔量 —— 两个来源迟早会打架。
*/
const confirmNodeState = computed<'idle' | 'waiting' | 'done'>(() => {
const status = props.task?.status
if (status === 'signed') {
return 'done'
}
return status === 'confirming' ? 'waiting' : 'idle'
})
/**
* 原型任务详情:主按钮 = 当前方式的签署入口,另外三种方式各给一个「转为 X」的次按钮。
* 原型的四种方式都走这一套(`待签署` 下四个 if/else 分支,主按钮与其余三个各不相同),
@@ -168,6 +188,43 @@ defineExpose({ getDocumentElement })
<span v-else-if="task.method === 'wx'" class="action-hint">公众号推送接口待接入</span>
</template>
<!-- 待科室确认:患者已签完,此刻要动的是科室那一道章(原型 confirmDept 的动作组)。
预览与下载放在这里,是因为科室复核时要先看患者签了什么 -->
<template v-else-if="task.status === 'confirming'">
<button
v-if="canConfirm"
type="button"
class="action-button"
@click="emit('action', 'confirm-dept')"
>
🏛 科室复核签章(确认完成)
</button>
<button
v-if="canRemind"
type="button"
class="action-button action-button--ghost"
@click="emit('action', 'remind')"
>
催办
</button>
<button
v-if="canViewArtifacts"
type="button"
class="action-button action-button--ghost"
@click="emit('action', 'preview-signed')"
>
在线预览
</button>
<button
v-if="canDownloadArtifact"
type="button"
class="action-button action-button--ghost"
@click="emit('action', 'download-pdf')"
>
下载已签 PDF
</button>
</template>
<template v-else-if="task.status === 'expired' && canReopen">
<button type="button" class="action-button" @click="emit('action', 'reopen')">
↻ 重新发起
@@ -243,13 +300,44 @@ defineExpose({ getDocumentElement })
v-if="task.documentContentHtml"
ref="documentRef"
:task="task"
:interactive="allowLocalSigning && task.status !== 'signed' && task.status !== 'void'"
:interactive="
allowLocalSigning &&
task.status !== 'signed' &&
task.status !== 'void' &&
task.status !== 'confirming'
"
:signature-injections="signatureInjections"
@selection-change="emit('selectionChange', $event)"
@field-change="(answers, progress) => emit('fieldChange', answers, progress)"
@signature-request="emit('signatureRequest', $event)"
/>
<SigningDocumentPreview v-else :task="task" />
<!-- 科室确认节点(原型:文书签名区里的「科室确认(签章)」那一格)。
它跟着文书走,不跟产物走 —— 科室复核的是患者签了什么,不是下载了什么 -->
<section
v-if="task.needConfirm"
class="dept-confirm"
:class="`is-${confirmNodeState}`"
aria-label="科室确认节点"
>
<span class="dept-confirm__label">科室确认(签章)</span>
<span v-if="confirmNodeState === 'done'" class="dept-confirm__tag is-done">
✓ {{ task.department }}已确认
</span>
<span v-else-if="confirmNodeState === 'waiting'" class="dept-confirm__tag is-waiting">
待科室确认
</span>
<span v-else class="dept-confirm__hint">待患者签署后</span>
<span class="dept-confirm__note">
{{
confirmNodeState === 'done'
? '科室复核签章完成,文档已归档至本次诊疗记录'
: confirmNodeState === 'waiting'
? '患者已签署,等待科室复核签章后归档'
: '本文书需科室复核签章,患者签署后自动流转'
}}
</span>
</section>
<!-- 顺序照原型:文书 → 签署文件(归档结果)→ 关联处方 → 操作留痕 -->
<SigningArtifactList
v-if="canViewArtifacts"
@@ -365,6 +453,68 @@ defineExpose({ getDocumentElement })
background: var(--err-l);
}
/* 科室确认节点:原型文书签名区里「科室确认(签章)」那一格的独立呈现。
三种形态用左边框颜色区分:待患者签署(灰)/ 待科室确认(橙)/ 已确认(绿) */
.dept-confirm {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
padding: 10px 14px;
margin-top: 14px;
background: var(--card);
border: 1px solid var(--line);
border-left: 3px solid #c0c8cd;
border-radius: var(--r);
box-shadow: var(--sh);
}
.dept-confirm.is-waiting {
border-left-color: #c07f00;
}
.dept-confirm.is-done {
border-left-color: var(--ok);
}
.dept-confirm__label {
color: var(--ink);
font-size: 13px;
font-weight: 700;
}
.dept-confirm__tag {
padding: 2px 9px;
font-size: 11px;
font-weight: 700;
border-radius: 12px;
}
/* 原型 .tag.tg-cfm */
.dept-confirm__tag.is-waiting {
color: #c07f00;
background: #fff3e0;
}
/* 原型 .tag.tg-done */
.dept-confirm__tag.is-done {
color: var(--ok);
background: var(--ok-l);
}
.dept-confirm__hint {
color: var(--mut);
font-size: 12px;
}
.dept-confirm__note {
flex: 1;
min-width: 180px;
color: var(--mut);
font-size: 11.5px;
text-align: right;
}
.detail-empty {
display: grid;
flex: 1;
@@ -23,6 +23,7 @@ const pageCount = computed(() => Math.max(Math.ceil(props.total / props.pageSize
const statusLabels: Record<SigningTaskRecord['status'], string> = {
pending: '待签署',
signing: '签署中',
confirming: '待科室确认',
signed: '已签署',
expired: '已超时',
void: '已作废',
@@ -260,6 +261,13 @@ function getVisitClass(type: SigningTaskRecord['visitType']) {
background: var(--info-l);
}
/* 待科室确认:原型 .tag.tg-cfm(橙底 #fff3e0 / 字色 #c07f00)。
与「已签署」的绿底刻意区分 —— 患者签完了,但还没归档 */
.task-status--confirming {
color: #c07f00;
background: #fff3e0;
}
.task-status--signed {
color: var(--ok);
background: var(--ok-l);
@@ -12,6 +12,7 @@ import {
import { createPadSigningSession } from '@/api/workbench/deliveries'
import {
completeSigningTask,
confirmDepartmentSigning,
getSigningTaskDetail,
getSigningTaskEvents,
getSigningTasks,
@@ -63,6 +64,7 @@ const loginStore = useLoginStore()
const STATUS_VALUES: SigningTaskStatus[] = [
'pending',
'signing',
'confirming',
'signed',
'expired',
'void',
@@ -73,6 +75,7 @@ const DATE_RANGE_VALUES: SigningDateRange[] = ['today', 'yesterday', '3d', '7d',
const statusLabels: Record<SigningTaskStatus, string> = {
pending: '待签署',
signing: '签署中',
confirming: '待科室确认',
signed: '已签署',
expired: '已超时',
void: '已作废',
@@ -151,6 +154,10 @@ const canCreateTask = computed(() => canWrite('sign:task:create'))
const canDeliveryWrite = computed(() => canWrite('sign:delivery:write'))
const canReopenTask = computed(() => canWrite('sign:task:reopen'))
const canVoidTask = computed(() => canWrite('sign:task:void'))
/** 科室复核签章:把「待科室确认」推到「已签署」,是正式留痕动作,与作废同级 */
const canConfirmTask = computed(() => canWrite('sign:task:confirm'))
/** 催办:提醒科室签署员尽快复核 */
const canRemindTask = computed(() => canWrite('sign:task:remind'))
const canViewEvents = computed(() => canRead('sign:task:event:query'))
const canViewArtifacts = computed(() => canRead('sign:artifact:query'))
const canDownloadArtifacts = computed(() => canRead('sign:artifact:download'))
@@ -536,6 +543,8 @@ async function handleTaskAction(action: SigningTaskAction, targetMethod?: Signin
(action === 'resend-sms' && canDeliveryWrite.value) ||
(action === 'reopen' && canReopenTask.value) ||
(action === 'void' && canVoidTask.value) ||
(action === 'confirm-dept' && canConfirmTask.value) ||
(action === 'remind' && canRemindTask.value) ||
(action === 'preview-signed' && canViewArtifacts.value) ||
((action === 'download-pdf' ||
action === 'download-signature' ||
@@ -599,6 +608,52 @@ async function handleTaskAction(action: SigningTaskAction, targetMethod?: Signin
return
}
/**
* 催办:提醒科室签署员尽快复核。
*
* 真实模式后端没有催办接口(要重做),按项目既有口径如实提示"待接入",
* 不前端假装发了一条提醒 —— 催办是要留痕的对外动作。
*/
if (action === 'remind') {
if (!isSigningMockEnabled) {
ElMessage.info('催办提醒接口待接入')
return
}
ElMessage.success(`已向${task.department}签署员发送催办提醒`)
return
}
if (action === 'confirm-dept') {
if (!isSigningMockEnabled) {
ElMessage.info('科室复核签章接口待接入')
return
}
const confirmed = await ElMessageBox.confirm(
`确认「${task.department}」已完成复核签章?确认后任务转为「已签署」,文档归档至本次诊疗记录。`,
'科室复核签章',
{ type: 'warning', confirmButtonText: '确认签章', cancelButtonText: '取消' },
)
.then(() => true)
.catch(() => false)
if (!confirmed) {
return
}
try {
await refreshTask(
await confirmDepartmentSigning(task.id),
'科室复核签章完成,文档已归档至本次诊疗记录',
)
} catch {
ElMessage.error('科室复核签章失败,请稍后重试')
}
return
}
if (action === 'reopen' && task.status !== 'expired' && task.status !== 'failed') {
ElMessage.info('只有已超时或处理失败的任务可以重新发起')
return
@@ -779,15 +834,13 @@ async function confirmPadSignature(signatureDataUrl: string) {
}
try {
await refreshTask(
await completeSigningTask(
task.id,
signatureDataUrl,
signingSelection.value,
signingAnswers.value,
),
'手写签署已完成',
const updated = await completeSigningTask(
task.id,
signatureDataUrl,
signingSelection.value,
signingAnswers.value,
)
await refreshTask(updated, signingCompletedMessage(updated))
signatureDialogVisible.value = false
} catch {
ElMessage.error('手写签署失败,请稍后重试')
@@ -802,21 +855,32 @@ async function completeOnlineSignature(signatureDataUrl: string) {
}
try {
await refreshTask(
await completeSigningTask(
task.id,
signatureDataUrl,
signingSelection.value,
signingAnswers.value,
),
'线上签署已完成',
const updated = await completeSigningTask(
task.id,
signatureDataUrl,
signingSelection.value,
signingAnswers.value,
)
await refreshTask(updated, signingCompletedMessage(updated))
onlineDialogVisible.value = false
} catch {
ElMessage.error('线上签署失败,请稍后重试')
}
}
/**
* 签署完成后的提示语。
*
* 需科室确认的任务签完**并没有结束**(原型同样区分这两种说法):
* 如果这里一律说「签署已完成」,操作人会以为可以关页面了,
* 而实际上文档还停在科室确认队列里没归档。
*/
function signingCompletedMessage(updated: SigningTaskRecord | null) {
return updated?.status === 'confirming'
? '患者已签署,已流转至科室确认节点,等待科室复核签章'
: '签署完成,文档已归档'
}
async function confirmVoidTask(reason: string) {
const task = selectedTask.value
@@ -933,6 +997,8 @@ onMounted(() => {
:can-delivery-write="canDeliveryWrite"
:can-reopen="canReopenTask"
:can-void="canVoidTask"
:can-confirm="canConfirmTask"
:can-remind="canRemindTask"
:can-view-audit="canViewEvents"
:can-view-artifacts="canViewArtifacts"
:can-download-artifact="canDownloadArtifacts"
@@ -47,6 +47,10 @@ export type SigningTaskAction =
| 'switch-method'
| 'reopen'
| 'void'
/** 科室复核签章:把「待科室确认」的任务推到「已签署」(原型 confirmDept) */
| 'confirm-dept'
/** 催办:提醒科室签署员尽快复核(原型 sign:task:remind) */
| 'remind'
| 'download-pdf'
| 'download-signature'
/** 在线预览已签署文书(原型 openPdfView 的弹窗预览) */