原型 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 错。
354 lines
7.4 KiB
Vue
354 lines
7.4 KiB
Vue
<script setup lang="ts">
|
|
import { formatSigningMethod, readSigningMethod } from '@/utils/signing-method'
|
|
|
|
import type { ReportTask, ReportTaskStatus } from '../types'
|
|
|
|
defineProps<{
|
|
rows: ReportTask[]
|
|
page: number
|
|
pageSize: number
|
|
total: number
|
|
pages: number
|
|
loading: boolean
|
|
error: string
|
|
pageSizeOptions: number[]
|
|
}>()
|
|
|
|
const emit = defineEmits<{
|
|
view: [row: ReportTask]
|
|
'page-change': [page: number]
|
|
'page-size-change': [size: number]
|
|
retry: []
|
|
}>()
|
|
|
|
const statusLabels: Record<ReportTaskStatus, string> = {
|
|
signed: '已签署',
|
|
confirming: '待科室确认',
|
|
pending: '待签署',
|
|
signing: '签署中',
|
|
expired: '已超时',
|
|
void: '已作废',
|
|
failed: '处理失败',
|
|
}
|
|
|
|
function getStatusLabel(row: ReportTask) {
|
|
if (row.status === 'pending') {
|
|
return `${statusLabels[row.status]}·${readSigningMethod(row.method).short}`
|
|
}
|
|
|
|
return statusLabels[row.status]
|
|
}
|
|
|
|
function getStatusClass(status: ReportTaskStatus) {
|
|
return `status-${status}`
|
|
}
|
|
|
|
function formatTime(date: Date | undefined) {
|
|
if (!date) {
|
|
return '—'
|
|
}
|
|
|
|
const pad = (value: number) => String(value).padStart(2, '0')
|
|
return `${date.getMonth() + 1}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}`
|
|
}
|
|
|
|
function formatDuration(row: ReportTask) {
|
|
const seconds =
|
|
row.durationSeconds !== undefined
|
|
? row.durationSeconds
|
|
: row.durationMinutes !== undefined
|
|
? row.durationMinutes * 60
|
|
: undefined
|
|
|
|
if (seconds === undefined || !Number.isFinite(seconds)) {
|
|
return '—'
|
|
}
|
|
|
|
const roundedSeconds = Math.round(seconds)
|
|
const minutes = Math.floor(roundedSeconds / 60)
|
|
const restSeconds = roundedSeconds % 60
|
|
|
|
if (!minutes) {
|
|
return String(restSeconds) + '秒'
|
|
}
|
|
|
|
return restSeconds
|
|
? String(minutes) + '分' + String(restSeconds) + '秒'
|
|
: String(minutes) + '分钟'
|
|
}
|
|
|
|
function getPageCount(pages: number) {
|
|
return Math.max(pages, 1)
|
|
}
|
|
|
|
function handlePreviousPage(page: number) {
|
|
if (page > 1) {
|
|
emit('page-change', page - 1)
|
|
}
|
|
}
|
|
|
|
function handleNextPage(page: number, pages: number) {
|
|
if (page < getPageCount(pages)) {
|
|
emit('page-change', page + 1)
|
|
}
|
|
}
|
|
|
|
function handlePageSizeChange(event: Event) {
|
|
const value = Number((event.target as HTMLSelectElement).value)
|
|
|
|
if (Number.isFinite(value)) {
|
|
emit('page-size-change', value)
|
|
}
|
|
}
|
|
</script>
|
|
|
|
<template>
|
|
<div v-if="error" class="table-error" role="alert">
|
|
<span>{{ error }}</span>
|
|
<button type="button" @click="emit('retry')">重试</button>
|
|
</div>
|
|
|
|
<div v-else class="table-container" :class="{ 'is-loading': loading }" :aria-busy="loading">
|
|
<div class="table-scroll">
|
|
<table class="detail-table">
|
|
<thead>
|
|
<tr>
|
|
<th>任务号</th>
|
|
<th>患者</th>
|
|
<th>文档</th>
|
|
<th>科室</th>
|
|
<th>方式</th>
|
|
<th>状态</th>
|
|
<th>发起时间</th>
|
|
<th>签署完成</th>
|
|
<th>耗时</th>
|
|
<th>操作</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<tr v-for="row in rows" :key="row.id">
|
|
<td>{{ row.id }}</td>
|
|
<td>
|
|
<strong>{{ row.patientName }}</strong>
|
|
<span class="patient-id">{{ row.patientId }}</span>
|
|
</td>
|
|
<td class="document-name" :title="row.documentName">{{ row.documentName }}</td>
|
|
<td>{{ row.department }}</td>
|
|
<td>{{ formatSigningMethod(row.method) }}</td>
|
|
<td>
|
|
<span class="status-tag" :class="getStatusClass(row.status)">
|
|
{{ getStatusLabel(row) }}
|
|
</span>
|
|
</td>
|
|
<td>{{ formatTime(row.createdAt) }}</td>
|
|
<td>{{ formatTime(row.signedAt) }}</td>
|
|
<td>{{ formatDuration(row) }}</td>
|
|
<td>
|
|
<button type="button" class="view-button" @click="emit('view', row)">查看</button>
|
|
</td>
|
|
</tr>
|
|
<tr v-if="!rows.length">
|
|
<td colspan="10" class="empty-cell">暂无符合条件的签署明细</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
|
|
<div v-if="total" class="table-footer">
|
|
<span>共 {{ total }} 条</span>
|
|
<label>
|
|
每页
|
|
<select :value="pageSize" aria-label="选择每页条数" @change="handlePageSizeChange">
|
|
<option v-for="option in pageSizeOptions" :key="option" :value="option">
|
|
{{ option }}
|
|
</option>
|
|
</select>
|
|
条
|
|
</label>
|
|
<button
|
|
type="button"
|
|
class="page-button"
|
|
:disabled="page <= 1 || loading"
|
|
@click="handlePreviousPage(page)"
|
|
>
|
|
上一页
|
|
</button>
|
|
<span>第 {{ page }} / {{ getPageCount(pages) }} 页</span>
|
|
<button
|
|
type="button"
|
|
class="page-button"
|
|
:disabled="page >= getPageCount(pages) || loading"
|
|
@click="handleNextPage(page, pages)"
|
|
>
|
|
下一页
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<style scoped>
|
|
.table-scroll {
|
|
width: 100%;
|
|
overflow-x: auto;
|
|
}
|
|
|
|
.table-container {
|
|
position: relative;
|
|
}
|
|
|
|
.table-container.is-loading {
|
|
min-height: 120px;
|
|
pointer-events: none;
|
|
opacity: 0.55;
|
|
}
|
|
|
|
.detail-table {
|
|
width: 100%;
|
|
min-width: 1000px;
|
|
font-size: 13px;
|
|
border-collapse: collapse;
|
|
}
|
|
|
|
.detail-table th {
|
|
padding: 9px 12px;
|
|
color: var(--mut);
|
|
font-size: 12.5px;
|
|
font-weight: 700;
|
|
text-align: left;
|
|
white-space: nowrap;
|
|
background: #f5f9fa;
|
|
border-bottom: 1px solid var(--line);
|
|
}
|
|
|
|
.detail-table td {
|
|
padding: 9px 12px;
|
|
vertical-align: middle;
|
|
border-bottom: 1px solid #eef4f6;
|
|
}
|
|
|
|
.detail-table tbody tr:hover td {
|
|
background: #f7fbfc;
|
|
}
|
|
|
|
.patient-id {
|
|
display: block;
|
|
margin-top: 2px;
|
|
color: var(--mut);
|
|
font-size: 11px;
|
|
}
|
|
|
|
.document-name {
|
|
max-width: 230px;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.status-tag {
|
|
display: inline-block;
|
|
padding: 2px 10px;
|
|
font-size: 11.5px;
|
|
font-weight: 700;
|
|
white-space: nowrap;
|
|
border-radius: 20px;
|
|
}
|
|
|
|
.status-signed {
|
|
color: var(--ok);
|
|
background: var(--ok-l);
|
|
}
|
|
|
|
.status-pending {
|
|
color: var(--brand);
|
|
background: var(--brand-l);
|
|
}
|
|
|
|
.status-signing {
|
|
color: var(--info);
|
|
background: var(--info-l);
|
|
}
|
|
|
|
.status-expired {
|
|
color: var(--err);
|
|
background: var(--err-l);
|
|
}
|
|
|
|
.status-void {
|
|
color: #8a9aa3;
|
|
background: #eceef0;
|
|
}
|
|
|
|
.status-failed {
|
|
color: var(--err);
|
|
background: #fff0f0;
|
|
}
|
|
|
|
.view-button {
|
|
padding: 4px 10px;
|
|
color: var(--brand);
|
|
font-size: 12px;
|
|
background: #fff;
|
|
border: 1px solid var(--brand);
|
|
border-radius: 5px;
|
|
}
|
|
|
|
.view-button:hover {
|
|
background: var(--brand-l);
|
|
}
|
|
|
|
.empty-cell {
|
|
padding: 40px 12px !important;
|
|
color: var(--mut);
|
|
text-align: center;
|
|
}
|
|
|
|
.table-error {
|
|
display: flex;
|
|
gap: 12px;
|
|
align-items: center;
|
|
justify-content: center;
|
|
min-height: 90px;
|
|
color: var(--err);
|
|
font-size: 12px;
|
|
}
|
|
|
|
.table-error button,
|
|
.page-button {
|
|
padding: 5px 10px;
|
|
color: var(--brand);
|
|
font-size: 12px;
|
|
background: #fff;
|
|
border: 1px solid var(--brand);
|
|
border-radius: 5px;
|
|
}
|
|
|
|
.table-footer {
|
|
display: flex;
|
|
gap: 12px;
|
|
align-items: center;
|
|
justify-content: flex-end;
|
|
padding-top: 12px;
|
|
color: var(--mut);
|
|
font-size: 12px;
|
|
}
|
|
|
|
.table-footer label {
|
|
display: inline-flex;
|
|
gap: 4px;
|
|
align-items: center;
|
|
}
|
|
|
|
.table-footer select {
|
|
padding: 3px 5px;
|
|
color: var(--ink);
|
|
background: #fff;
|
|
border: 1px solid var(--line);
|
|
border-radius: 4px;
|
|
}
|
|
|
|
.page-button:disabled {
|
|
cursor: not-allowed;
|
|
opacity: 0.45;
|
|
}
|
|
</style>
|