feat(签署记录): 新增签署记录查询页并复用签署任务接口

This commit is contained in:
yelan
2026-09-24 19:52:28 +08:00
parent 53c6f05969
commit c4718a3912
17 changed files with 2401 additions and 28 deletions
@@ -12,6 +12,7 @@ const loginStore = useLoginStore()
const componentRegistry: Record<string, Component> = {
'dashboard/index': defineAsyncComponent(() => import('@/views/workbench/home/index.vue')),
'sign/workbench/index': defineAsyncComponent(() => import('@/views/workbench/signing/index.vue')),
'sign/record/index': defineAsyncComponent(() => import('@/views/workbench/records/index.vue')),
'system/org/index': defineAsyncComponent(() => import('@/views/management/users/index.vue')),
'system/role/index': defineAsyncComponent(() => import('@/views/management/roles/index.vue')),
'doc/library/index': defineAsyncComponent(() => import('@/views/management/documents/index.vue')),
@@ -0,0 +1,404 @@
<script setup lang="ts">
import { ref } from 'vue'
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 type { SigningRecordRow } from '../types'
const props = defineProps<{
visible: boolean
record: SigningRecordRow | null
loading: boolean
events: SigningTaskEvent[]
eventsLoading: boolean
eventsError: boolean
canDownload: boolean
downloading: boolean
}>()
const emit = defineEmits<{
close: []
downloadPdf: []
}>()
const statusLabels: Record<SigningTaskStatus, string> = {
pending: '待签署',
signing: '签署中',
signed: '已签署',
expired: '已超时',
void: '已作废',
failed: '处理失败',
}
const documentRef = ref<InstanceType<typeof SigningDocumentInteractive> | null>(null)
/**
* 供页面导出 PDF 使用:把内嵌的文书容器透出去。
* 与签署工作台的详情组件同口径——导出的 PDF 必须是页面上看到的这一份渲染结果,
* 由页面自己去取元素,而不是在组件里另拼一份文书。
*/
function getDocumentElement() {
return documentRef.value?.getDocumentElement() ?? null
}
defineExpose({ getDocumentElement })
function handleVisibleChange(value: boolean) {
if (!value) {
emit('close')
}
}
function value(value: string | undefined) {
return value || '—'
}
</script>
<template>
<el-dialog
:model-value="props.visible"
width="1120px"
top="5vh"
class="record-dialog"
destroy-on-close
:close-on-click-modal="false"
@update:model-value="handleVisibleChange"
>
<template #header>
<div class="dialog-header">
<div class="dialog-header__title">
<strong>{{ record?.documentName ?? '签署记录详情' }}</strong>
<span v-if="record" class="dialog-header__meta">
{{ record.id }} · {{ record.patientName }} · {{ value(record.campus) }}
</span>
</div>
<span v-if="record" class="status-tag" :class="`status-${record.status}`">
{{ statusLabels[record.status] }}
</span>
</div>
</template>
<div v-if="loading" class="dialog-state">正在加载记录详情…</div>
<div v-else-if="!record" class="dialog-state">未能读取该签署记录,请稍后重试。</div>
<div v-else class="record-detail">
<aside class="record-detail__side">
<section class="info-block">
<h4>记录信息</h4>
<dl>
<div><dt>任务号</dt><dd>{{ record.id }}</dd></div>
<div><dt>文档类别</dt><dd>{{ value(record.category) }}</dd></div>
<div><dt>科室</dt><dd>{{ value(record.department) }}</dd></div>
<div><dt>院区</dt><dd>{{ value(record.campus) }}</dd></div>
<div>
<dt>签署方式</dt>
<dd>{{ record.method === 'pad' ? '✍️ 手写板' : '📱 短信' }}</dd>
</div>
<div><dt>发起时间</dt><dd>{{ value(record.createdAt) }}</dd></div>
<div><dt>签署完成</dt><dd>{{ value(record.signedAt) }}</dd></div>
<div><dt>失效时间</dt><dd>{{ value(record.expiresAt) }}</dd></div>
<div v-if="record.voidReason" class="info-block__wide">
<dt>作废原因</dt><dd>{{ record.voidReason }}</dd>
</div>
</dl>
</section>
<section class="info-block">
<h4>患者与就诊</h4>
<dl>
<div><dt>患者</dt><dd>{{ record.patientName }}({{ record.sex }} · {{ record.age }}岁)</dd></div>
<div><dt>患者 ID</dt><dd>{{ record.patientId }}</dd></div>
<div><dt>就诊号</dt><dd>{{ record.visitNo }}</dd></div>
<div><dt>就诊类型</dt><dd>{{ record.visitType }}</dd></div>
<div><dt>就诊日期</dt><dd>{{ value(record.visitDate) }}</dd></div>
<div><dt>任务来源</dt><dd>{{ value(record.source) }}</dd></div>
</dl>
</section>
<section class="info-block">
<h4>签署证据</h4>
<div class="signature-box">
<img v-if="record.signatureDataUrl" :src="record.signatureDataUrl" alt="签署人签名" />
<span v-else-if="record.status === 'void'">任务已作废,签名不保留</span>
<span v-else>未采集签名</span>
</div>
<dl>
<div><dt>签署人</dt><dd>{{ value(record.signerName) }}</dd></div>
<div class="info-block__wide">
<dt>正文哈希</dt>
<dd class="mono">{{ value(record.contentSha256) }}</dd>
</div>
</dl>
</section>
<TaskAuditTimeline
:events="props.events"
:loading="props.eventsLoading"
:error="props.eventsError"
/>
</aside>
<main class="record-detail__doc">
<SigningDocumentInteractive
v-if="record.documentContentHtml"
ref="documentRef"
:task="record"
/>
<SigningDocumentPreview v-else :task="record" />
</main>
</div>
<template #footer>
<div class="dialog-footer">
<span class="dialog-footer__note">
记录内容以服务端留存的签署证据为准,页面展示不作为签署效力判据。
</span>
<button type="button" class="action-button action-button--ghost" @click="emit('close')">
关闭
</button>
<button
type="button"
class="action-button"
:disabled="!canDownload || downloading || !record"
@click="emit('downloadPdf')"
>
{{ downloading ? '生成中…' : '下载签署后 PDF' }}
</button>
</div>
</template>
</el-dialog>
</template>
<style scoped>
.dialog-header {
display: flex;
gap: 12px;
align-items: center;
}
.dialog-header__title {
display: flex;
min-width: 0;
flex: 1;
flex-direction: column;
gap: 2px;
}
.dialog-header__title strong {
overflow: hidden;
color: var(--ink);
font-size: 15px;
text-overflow: ellipsis;
white-space: nowrap;
}
.dialog-header__meta {
color: var(--mut);
font-size: 12px;
}
.status-tag {
flex-shrink: 0;
padding: 3px 11px;
font-size: 11.5px;
font-weight: 700;
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: #a15c19;
background: #fff3df;
}
.dialog-state {
padding: 60px 0;
color: var(--mut);
font-size: 13px;
text-align: center;
}
/* 两栏各滚各的:左栏是信息与留痕,右栏是文书,互不牵连 */
.record-detail {
display: flex;
gap: 16px;
align-items: stretch;
height: 68vh;
min-height: 380px;
}
.record-detail__side {
display: flex;
width: 372px;
flex-shrink: 0;
flex-direction: column;
gap: 12px;
padding-right: 4px;
overflow-y: auto;
}
.record-detail__doc {
min-width: 0;
flex: 1;
padding-right: 4px;
overflow-y: auto;
}
.info-block {
padding: 12px 14px;
background: #f8fbfc;
border: 1px solid #edf3f5;
border-radius: 8px;
}
.info-block h4 {
margin: 0 0 8px;
color: var(--ink);
font-size: 12.5px;
}
.info-block dl {
display: grid;
gap: 6px 12px;
grid-template-columns: repeat(2, minmax(0, 1fr));
margin: 0;
}
.info-block dl > div {
min-width: 0;
}
.info-block__wide {
grid-column: 1 / -1;
}
.info-block dt {
color: var(--mut);
font-size: 11px;
}
.info-block dd {
margin: 2px 0 0;
overflow: hidden;
color: var(--ink);
font-size: 12px;
text-overflow: ellipsis;
white-space: nowrap;
}
.info-block .mono {
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 11px;
word-break: break-all;
white-space: normal;
}
.signature-box {
display: flex;
min-height: 76px;
align-items: center;
justify-content: center;
margin-bottom: 10px;
background: #fff;
border: 1px dashed var(--line);
border-radius: 8px;
}
.signature-box img {
max-width: 100%;
max-height: 72px;
}
.signature-box span {
color: var(--mut);
font-size: 12px;
}
.dialog-footer {
display: flex;
gap: 10px;
align-items: center;
}
.dialog-footer__note {
flex: 1;
color: var(--mut);
font-size: 11.5px;
text-align: left;
}
.action-button {
flex-shrink: 0;
padding: 8px 16px;
color: #fff;
font-size: 13px;
font-weight: 700;
background: var(--brand);
border-radius: 7px;
}
.action-button:hover {
background: var(--brand-d);
}
.action-button--ghost {
color: var(--brand);
background: #fff;
border: 1px solid var(--brand);
}
.action-button--ghost:hover {
background: var(--brand-l);
}
.action-button:disabled {
color: #9aabb3;
cursor: not-allowed;
background: #eef3f5;
}
@media (max-width: 1180px) {
.record-detail {
height: auto;
flex-direction: column;
}
.record-detail__side,
.record-detail__doc {
width: 100%;
overflow-y: visible;
}
.record-detail__doc {
max-height: 60vh;
overflow-y: auto;
}
}
</style>
@@ -0,0 +1,221 @@
<script setup lang="ts">
import { reactive, watch } from 'vue'
import type { SigningMethod, WorkbenchCampus } from '@/api/workbench/types'
import type { SigningRecordFilterForm, SigningRecordFilterOption } from '../types'
const props = defineProps<{
filter: SigningRecordFilterForm
campuses: SigningRecordFilterOption<WorkbenchCampus | 'all'>[]
statuses: SigningRecordFilterOption<SigningRecordFilterForm['status']>[]
methods: SigningRecordFilterOption<SigningMethod | 'all'>[]
departments: SigningRecordFilterOption[]
categories: SigningRecordFilterOption[]
/**
* 文档类别是否可筛。
* 真实模式下后端签署任务接口不支持按类别检索,这里如实禁用并给出原因,
* 不做"选了但被静默忽略"——那种筛选项比没有更误导人。
*/
categoryEnabled: boolean
canExport: boolean
exporting: boolean
}>()
const emit = defineEmits<{
search: [filter: SigningRecordFilterForm]
export: []
}>()
const draft = reactive<SigningRecordFilterForm>({ ...props.filter })
watch(
() => props.filter,
(value) => {
Object.assign(draft, value)
},
{ deep: true },
)
function submit() {
emit('search', { ...draft })
}
</script>
<template>
<form class="filter-card" @submit.prevent="submit">
<label class="filter-field">
<span>时间</span>
<select v-model="draft.dateRange" aria-label="筛选时间范围">
<option value="today">今日</option>
<option value="yesterday">昨日</option>
<option value="3d">近3天</option>
<option value="7d">近7天</option>
<option value="30d">近30天</option>
<option value="all">全部</option>
</select>
</label>
<label class="filter-field">
<span>院区</span>
<select v-model="draft.campus" aria-label="筛选院区">
<option v-for="option in campuses" :key="option.value" :value="option.value">
{{ option.label }}
</option>
</select>
</label>
<label class="filter-field">
<span>科室</span>
<select v-model="draft.department" aria-label="筛选科室">
<option v-for="option in departments" :key="option.value" :value="option.value">
{{ option.label }}
</option>
</select>
</label>
<label
class="filter-field"
:title="categoryEnabled ? '' : '后端签署记录接口暂不支持按文档类别检索'"
>
<span>文档类别</span>
<select v-model="draft.category" :disabled="!categoryEnabled" aria-label="筛选文档类别">
<option v-for="option in categories" :key="option.value" :value="option.value">
{{ option.label }}
</option>
</select>
</label>
<label class="filter-field">
<span>方式</span>
<select v-model="draft.method" aria-label="筛选签署方式">
<option v-for="option in methods" :key="option.value" :value="option.value">
{{ option.label }}
</option>
</select>
</label>
<label class="filter-field">
<span>状态</span>
<select v-model="draft.status" aria-label="筛选状态">
<option v-for="option in statuses" :key="option.value" :value="option.value">
{{ option.label }}
</option>
</select>
</label>
<label class="filter-field filter-field--keyword">
<span>关键词</span>
<input
v-model="draft.keyword"
placeholder="患者姓名 / 患者ID / 任务号 / 文档名"
aria-label="关键词"
/>
</label>
<span class="filter-spacer" />
<button type="submit" class="action-button action-button--ghost">查询</button>
<button
v-if="canExport"
type="button"
class="action-button action-button--ghost"
:disabled="exporting"
@click="emit('export')"
>
{{ exporting ? '导出中…' : '导出' }}
</button>
</form>
</template>
<style scoped>
.filter-card {
display: flex;
gap: 10px;
align-items: center;
flex-wrap: wrap;
padding: 14px 18px;
margin-bottom: 14px;
background: var(--card);
border-radius: var(--r);
box-shadow: var(--sh);
}
.filter-field {
display: flex;
min-width: 104px;
gap: 6px;
align-items: center;
color: var(--mut);
font-size: 12.5px;
}
.filter-field select,
.filter-field input {
width: auto;
min-width: 82px;
padding: 7px 9px;
color: var(--ink);
background: #fff;
border: 1px solid var(--line);
border-radius: 6px;
}
.filter-field select:focus,
.filter-field input:focus {
outline: none;
border-color: var(--brand);
}
.filter-field select:disabled {
color: #9aabb3;
cursor: not-allowed;
background: #f4f7f8;
}
.filter-field--keyword input {
width: 210px;
}
.filter-spacer {
flex: 1;
min-width: 12px;
}
.action-button {
flex-shrink: 0;
padding: 8px 16px;
color: #fff;
font-size: 13px;
font-weight: 700;
background: var(--brand);
border-radius: 7px;
transition: background 0.15s ease;
}
.action-button:hover {
background: var(--brand-d);
}
.action-button--ghost {
color: var(--brand);
background: #fff;
border: 1px solid var(--brand);
}
.action-button--ghost:hover {
background: var(--brand-l);
}
.action-button--ghost:disabled {
color: #9aabb3;
cursor: not-allowed;
background: #fff;
border-color: var(--line);
}
@media (max-width: 1200px) {
.filter-spacer {
display: none;
}
}
</style>
@@ -0,0 +1,340 @@
<script setup lang="ts">
import { computed } from 'vue'
import type { SigningTaskStatus } from '@/api/workbench/types'
import type { SigningRecordRow } from '../types'
const props = defineProps<{
rows: SigningRecordRow[]
total: number
page: number
pageSize: number
loading: boolean
error: string
canView: boolean
}>()
const emit = defineEmits<{
view: [row: SigningRecordRow]
'page-change': [page: number]
'page-size-change': [size: number]
retry: []
}>()
const pageSizeOptions = [20, 50, 100]
const pageCount = computed(() => Math.max(Math.ceil(props.total / props.pageSize), 1))
const statusLabels: Record<SigningTaskStatus, string> = {
pending: '待签署',
signing: '签署中',
signed: '已签署',
expired: '已超时',
void: '已作废',
failed: '处理失败',
}
/** 待签署要带出渠道:现场等着签的是手写板,等患者点链接的是短信,处理方式完全不同 */
function getStatusLabel(row: SigningRecordRow) {
if (row.status === 'pending') {
return `${statusLabels[row.status]}·${row.method === 'pad' ? '手写板' : '短信'}`
}
return statusLabels[row.status]
}
function getMethodLabel(row: SigningRecordRow) {
return row.method === 'pad' ? '✍️ 手写板' : '📱 短信'
}
function formatTime(value: string | undefined) {
return value || '—'
}
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="record-table">
<thead>
<tr>
<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 class="task-id">{{ row.id }}</td>
<td>
<strong>{{ row.patientName }}</strong>
<span class="patient-meta">{{ row.sex }} · {{ row.age }}岁</span>
<span class="patient-id">{{ row.patientId }}</span>
</td>
<td class="document-name" :title="row.documentName">
{{ row.documentName }}
<span class="document-category">{{ row.category || '—' }}</span>
</td>
<td>{{ row.department }}</td>
<td>{{ row.campus || '—' }}</td>
<td>{{ getMethodLabel(row) }}</td>
<td>
<span class="status-tag" :class="`status-${row.status}`">
{{ getStatusLabel(row) }}
</span>
</td>
<td>{{ formatTime(row.createdAt) }}</td>
<td>
<button
v-if="canView"
type="button"
class="view-button"
@click="emit('view', row)"
>
查看
</button>
<span v-else class="no-action">—</span>
</td>
</tr>
<tr v-if="!rows.length">
<td colspan="9" 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="page > 1 && emit('page-change', page - 1)"
>
上一页
</button>
<span>第 {{ page }} / {{ pageCount }} 页</span>
<button
type="button"
class="page-button"
:disabled="page >= pageCount || loading"
@click="emit('page-change', page + 1)"
>
下一页
</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;
}
.record-table {
width: 100%;
min-width: 1080px;
font-size: 13px;
border-collapse: collapse;
}
.record-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);
}
.record-table td {
padding: 9px 12px;
vertical-align: middle;
border-bottom: 1px solid #eef4f6;
}
.record-table tbody tr:hover td {
background: #f7fbfc;
}
.task-id {
font-size: 12px;
white-space: nowrap;
}
.patient-meta,
.patient-id {
display: block;
margin-top: 2px;
color: var(--mut);
font-size: 11px;
}
.document-name {
max-width: 250px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.document-category {
display: block;
margin-top: 2px;
color: var(--mut);
font-size: 11px;
}
.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: #a15c19;
background: #fff3df;
}
.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);
}
.no-action {
color: var(--mut);
font-size: 12px;
}
.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;
}
.page-button:disabled {
cursor: not-allowed;
opacity: 0.45;
}
.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;
}
</style>
@@ -0,0 +1,598 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import { ElMessage } from 'element-plus'
import { getCampuses, getDepartments } from '@/api/management/organization'
import { downloadSigningArtifact, getSigningArtifacts } from '@/api/workbench/artifacts'
import {
getAllSigningRecords,
getSigningRecordDetail,
getSigningRecordEvents,
getSigningRecords,
isSigningRecordsMockEnabled,
mockRecordCategories,
mockRecordDepartments,
} from '@/api/workbench/records'
import type {
SigningApiOptions,
SigningMethod,
SigningTaskEvent,
SigningTaskStatus,
WorkbenchCampus,
} from '@/api/workbench/types'
import { useAppStore } from '@/stores/app'
import { useLoginStore } from '@/stores/login'
import { downloadSigningPdf } from '@/utils/signing-pdf'
import SigningRecordDetailDialog from './components/SigningRecordDetailDialog.vue'
import SigningRecordFilterBar from './components/SigningRecordFilterBar.vue'
import SigningRecordTable from './components/SigningRecordTable.vue'
import type {
SigningRecordExportColumn,
SigningRecordFilterForm,
SigningRecordFilterOption,
SigningRecordRow,
} from './types'
const appStore = useAppStore()
const loginStore = useLoginStore()
const PAGE_SIZE_OPTIONS = [20, 50, 100]
const DEFAULT_PAGE_SIZE = 20
const statusLabels: Record<SigningTaskStatus, string> = {
pending: '待签署',
signing: '签署中',
signed: '已签署',
expired: '已超时',
void: '已作废',
failed: '处理失败',
}
/**
* 导出列。与表格保持同一口径,但比表格多出「签署人 / 完成时间 / 失效时间」——
* 导出是给台账用的,留痕要素不能只在页面上看得到。
*/
const EXPORT_COLUMNS: SigningRecordExportColumn[] = [
{ label: '任务号', value: (record) => record.id },
{ label: '患者姓名', value: (record) => record.patientName },
{ label: '患者ID', value: (record) => record.patientId },
{ label: '性别', value: (record) => record.sex },
{ label: '年龄', value: (record) => String(record.age) },
{ label: '就诊号', value: (record) => record.visitNo },
{ label: '就诊类型', value: (record) => record.visitType },
{ label: '文档名称', value: (record) => record.documentName },
{ label: '文档类别', value: (record) => record.category },
{ label: '科室', value: (record) => record.department },
{ label: '院区', value: (record) => record.campus },
{ label: '签署方式', value: (record) => (record.method === 'pad' ? '手写板' : '短信线上') },
{ label: '状态', value: (record) => statusLabels[record.status] },
{ label: '发起时间', value: (record) => record.createdAt ?? '' },
{ label: '签署完成时间', value: (record) => record.signedAt ?? '' },
{ label: '失效时间', value: (record) => record.expiresAt },
{ label: '签署人', value: (record) => record.signerName },
{ label: '作废原因', value: (record) => record.voidReason ?? '' },
]
const filter = reactive<SigningRecordFilterForm>({
// 记录页默认看近 7 天:历史全量一屏装不下,先给一个"最近发生了什么"的窗口
dateRange: '7d',
campus: 'all',
department: '',
category: '',
method: 'all',
status: 'all',
keyword: '',
})
const rows = ref<SigningRecordRow[]>([])
const total = ref(0)
const page = ref(1)
const pageSize = ref(DEFAULT_PAGE_SIZE)
const loading = ref(true)
const errorMessage = ref('')
const exporting = ref(false)
const detailVisible = ref(false)
const detailRecord = ref<SigningRecordRow | null>(null)
const detailLoading = ref(false)
const detailEvents = ref<SigningTaskEvent[]>([])
const detailEventsLoading = ref(false)
const detailEventsError = ref(false)
const pdfExporting = ref(false)
const detailDialogRef = ref<InstanceType<typeof SigningRecordDetailDialog> | null>(null)
const campusIds = ref<Record<string, string>>({})
const campusNames = ref<Record<string, WorkbenchCampus>>({})
const departmentIds = ref<Record<string, string>>({})
const departmentNames = ref<string[]>([])
let listRequestId = 0
let detailRequestId = 0
function canRead(permission: string) {
return isSigningRecordsMockEnabled || loginStore.hasPermission(permission)
}
const canView = computed(() => canRead('sign:record:view'))
const canExport = computed(
() => !loginStore.isReadOnly && canRead('sign:record:export'),
)
const canDownloadArtifact = computed(() => canRead('sign:artifact:download'))
const campusOptions = computed<SigningRecordFilterOption<WorkbenchCampus | 'all'>[]>(() => [
{ label: '全部院区', value: 'all' },
...appStore.campuses.map((campus) => ({ label: campus, value: campus })),
])
const statusOptions: SigningRecordFilterOption<SigningRecordFilterForm['status']>[] = [
{ label: '全部状态', value: 'all' },
...(['pending', 'signing', 'signed', 'expired', 'void', 'failed'] as SigningTaskStatus[]).map(
(status) => ({ label: statusLabels[status], value: status }),
),
]
const methodOptions: SigningRecordFilterOption<SigningMethod | 'all'>[] = [
{ label: '全部方式', value: 'all' },
{ label: '手写板', value: 'pad' },
{ label: '短信线上', value: 'sms' },
]
/**
* 科室下拉:演示模式取演示记录里出现过的科室,真实模式取组织接口的科室清单。
* 不用「当前页出现过的科室」——翻页会换掉可选项,筛完再想换一个科室就找不到了。
*/
const departmentOptions = computed<SigningRecordFilterOption[]>(() => {
const names = isSigningRecordsMockEnabled ? mockRecordDepartments : departmentNames.value
return [
{ label: '全部科室', value: '' },
...names.map((name) => ({ label: name, value: name })),
]
})
const categoryOptions = computed<SigningRecordFilterOption[]>(() => {
// 真实模式下后端签署任务接口不支持按类别检索,这里给不出可选项,由筛选条禁用该字段
const categories = isSigningRecordsMockEnabled
? mockRecordCategories
: [...new Set(rows.value.map((row) => row.category).filter(Boolean))]
return [
{ label: '全部类别', value: '' },
...categories.map((category) => ({ label: category, value: category })),
]
})
const signingApiOptions = computed<SigningApiOptions>(() => ({
campus: appStore.selectedCampus,
campusId: campusIds.value[appStore.selectedCampus],
campusIds: campusIds.value,
campusNames: campusNames.value,
departmentIds: departmentIds.value,
}))
/** 真实模式才需要:把院区 / 科室名称换成接口要的 id,并留一份院区名用于回显 */
async function loadOrganizationOptions() {
if (isSigningRecordsMockEnabled) {
return
}
const [campusesResult, departmentsResult] = await Promise.allSettled([
getCampuses(),
getDepartments(),
])
if (campusesResult.status === 'fulfilled') {
campusIds.value = Object.fromEntries(
campusesResult.value.map((campus) => [campus.name, campus.id]),
)
campusNames.value = Object.fromEntries(
campusesResult.value.map((campus) => [campus.id, campus.name]),
)
}
if (departmentsResult.status === 'fulfilled') {
departmentIds.value = Object.fromEntries(
departmentsResult.value.map((department) => [department.name, department.id]),
)
departmentNames.value = departmentsResult.value.map((department) => department.name)
}
}
async function loadRecords(resetPage = false) {
if (resetPage) {
page.value = 1
}
const requestId = ++listRequestId
loading.value = true
errorMessage.value = ''
try {
const response = await getSigningRecords(
{ ...filter, page: page.value, pageSize: pageSize.value },
signingApiOptions.value,
)
if (requestId !== listRequestId) {
return
}
rows.value = response.records
total.value = response.total
page.value = response.page
pageSize.value = response.pageSize
} catch {
if (requestId === listRequestId) {
rows.value = []
total.value = 0
errorMessage.value = '签署记录加载失败,请稍后重试'
}
} finally {
if (requestId === listRequestId) {
loading.value = false
}
}
}
function handleSearch(nextFilter: SigningRecordFilterForm) {
Object.assign(filter, nextFilter)
void loadRecords(true)
}
function handlePageChange(nextPage: number) {
const pageCount = Math.max(Math.ceil(total.value / pageSize.value), 1)
if (nextPage < 1 || nextPage > pageCount || nextPage === page.value) {
return
}
page.value = nextPage
void loadRecords()
}
function handlePageSizeChange(nextSize: number) {
if (!PAGE_SIZE_OPTIONS.includes(nextSize) || nextSize === pageSize.value) {
return
}
pageSize.value = nextSize
void loadRecords(true)
}
async function openRecord(row: SigningRecordRow) {
if (!canView.value) {
ElMessage.warning('当前账号没有查看签署记录的权限')
return
}
detailVisible.value = true
detailRecord.value = row
detailEvents.value = []
detailEventsError.value = false
detailLoading.value = true
detailEventsLoading.value = true
const requestId = ++detailRequestId
const [detailResult, eventsResult] = await Promise.allSettled([
getSigningRecordDetail(row.id, signingApiOptions.value),
getSigningRecordEvents(row.id),
])
if (requestId !== detailRequestId) {
return
}
if (detailResult.status === 'fulfilled' && detailResult.value) {
// 任务接口不返回文档类别与正文,正文靠详情接口补回;
// 类别沿用列表里带出的那一份,避免被详情里的空值覆盖
detailRecord.value = {
...row,
...detailResult.value,
category: detailResult.value.category || row.category,
}
}
if (eventsResult.status === 'fulfilled') {
detailEvents.value = eventsResult.value
} else {
detailEventsError.value = true
}
detailLoading.value = false
detailEventsLoading.value = false
}
function closeRecord() {
detailVisible.value = false
detailRecord.value = null
detailEvents.value = []
}
function saveBlob(blob: Blob, fileName: string) {
const url = URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = fileName
link.click()
window.setTimeout(() => URL.revokeObjectURL(url), 0)
}
async function handleDownloadPdf() {
const record = detailRecord.value
if (!record) {
return
}
if (!canDownloadArtifact.value) {
ElMessage.warning('当前账号没有下载签署文件的权限')
return
}
if (pdfExporting.value) {
return
}
pdfExporting.value = true
try {
// 正式文书以服务端产物为准:有签署后 PDF 就直接下载那一份
const artifacts = await getSigningArtifacts(record.id)
const signedPdf = artifacts.find((item) => item.artifactType === 'SIGNED_PDF')
if (signedPdf) {
saveBlob(await downloadSigningArtifact(signedPdf.id), signedPdf.fileName)
ElMessage.success('签署后 PDF 下载已开始')
return
}
if (!isSigningRecordsMockEnabled) {
ElMessage.info('该记录暂无服务端生成的签署后 PDF')
return
}
// 演示环境没有文件服务:按当前渲染结果现场生成一份,先让前端看到成品效果
const element = detailDialogRef.value?.getDocumentElement()
if (!element) {
ElMessage.info('该记录没有固化的文书正文,无法导出 PDF')
return
}
await downloadSigningPdf({ element, fileName: `${record.documentName}-${record.id}` })
ElMessage.success('签署后 PDF 已生成')
} catch {
ElMessage.error('签署后 PDF 下载失败,请稍后重试')
} finally {
pdfExporting.value = false
}
}
function escapeCsvField(value: string) {
return /[",\n\r]/.test(value) ? `"${value.replace(/"/g, '""')}"` : value
}
/**
* 导出当前筛选条件下的全部记录。
*
* 后端还没有签署记录导出接口,所以先在前端按当前条件取全量、生成 CSV。
* 待后端提供导出任务后应改为服务端导出:导出范围与导出人都要写进审计日志,
* 这一步在浏览器里做不到。
*/
async function handleExport() {
if (!canExport.value) {
ElMessage.warning('当前账号没有导出签署记录的权限')
return
}
if (!total.value) {
ElMessage.info('当前筛选条件下没有可导出的签署记录')
return
}
exporting.value = true
try {
const records = await getAllSigningRecords(
{ ...filter, page: 1, pageSize: pageSize.value },
signingApiOptions.value,
)
if (!records.length) {
ElMessage.info('当前筛选条件下没有可导出的签署记录')
return
}
const lines = [
EXPORT_COLUMNS.map((column) => escapeCsvField(column.label)).join(','),
...records.map((record) =>
EXPORT_COLUMNS.map((column) => escapeCsvField(column.value(record))).join(','),
),
]
const now = new Date()
const stamp = `${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, '0')}${String(
now.getDate(),
).padStart(2, '0')}${String(now.getHours()).padStart(2, '0')}${String(now.getMinutes()).padStart(
2,
'0',
)}`
// 带 BOM:Excel 直接打开 UTF-8 CSV 才不会把中文显示成乱码
saveBlob(
new Blob([`\uFEFF${lines.join('\r\n')}`], { type: 'text/csv;charset=utf-8' }),
`签署记录_${stamp}.csv`,
)
ElMessage.success(`已导出 ${records.length} 条签署记录`)
} catch {
ElMessage.error('导出失败,请稍后重试')
} finally {
exporting.value = false
}
}
async function initializePage() {
await appStore.loadCampuses()
await loadOrganizationOptions()
await loadRecords(true)
}
onMounted(() => {
void initializePage()
})
</script>
<template>
<section class="records-page">
<div class="page-intro">
<div>
<h1>签署记录查询</h1>
<p>按条件检索全量历史签署记录;工作台只保留当天签署操作,历史记录统一在这里查询。</p>
</div>
<div class="page-intro__meta">
<span>共 {{ total }} 条记录</span>
</div>
</div>
<SigningRecordFilterBar
:filter="filter"
:campuses="campusOptions"
:statuses="statusOptions"
:methods="methodOptions"
:departments="departmentOptions"
:categories="categoryOptions"
:category-enabled="isSigningRecordsMockEnabled"
:can-export="canExport"
:exporting="exporting"
@search="handleSearch"
@export="handleExport"
/>
<div class="record-card">
<div class="record-card__heading">
<strong>签署记录</strong>
<span class="record-card__count">
{{ total ? `共查询到 ${total} 条签署记录` : '没有符合条件的签署记录' }}
</span>
</div>
<SigningRecordTable
:rows="rows"
:total="total"
:page="page"
:page-size="pageSize"
:loading="loading"
:error="errorMessage"
:can-view="canView"
@view="openRecord"
@page-change="handlePageChange"
@page-size-change="handlePageSizeChange"
@retry="loadRecords()"
/>
<p class="record-card__note">
记录只读:已签署的正式文书不可直接修改,需要追加内容时请回到签署工作台新建签署任务;
历史记录可点击「查看」核对当时的文书、签名与操作留痕。
</p>
</div>
<SigningRecordDetailDialog
ref="detailDialogRef"
:visible="detailVisible"
:record="detailRecord"
:loading="detailLoading"
:events="detailEvents"
:events-loading="detailEventsLoading"
:events-error="detailEventsError"
:can-download="canDownloadArtifact"
:downloading="pdfExporting"
@close="closeRecord"
@download-pdf="handleDownloadPdf"
/>
</section>
</template>
<style scoped>
.records-page {
width: 100%;
max-width: 1600px;
margin: 0 auto;
}
.page-intro {
display: flex;
gap: 20px;
align-items: flex-end;
justify-content: space-between;
margin-bottom: 14px;
}
.page-intro h1 {
margin: 0;
color: var(--ink);
font-size: 19px;
letter-spacing: 0.5px;
}
.page-intro p {
margin: 4px 0 0;
color: var(--mut);
font-size: 12.5px;
}
.page-intro__meta {
display: flex;
gap: 8px;
align-items: center;
color: var(--mut);
font-size: 12px;
white-space: nowrap;
}
.page-intro__meta span {
padding: 5px 9px;
background: var(--card);
border: 1px solid var(--line);
border-radius: 6px;
}
.record-card {
padding: 14px 18px 12px;
background: var(--card);
border-radius: var(--r);
box-shadow: var(--sh);
}
.record-card__heading {
display: flex;
gap: 12px;
align-items: center;
margin-bottom: 10px;
}
.record-card__heading strong {
font-size: 13.5px;
}
.record-card__count {
margin-left: auto;
color: var(--mut);
font-size: 12px;
}
.record-card__note {
margin: 10px 0 0;
color: var(--mut);
font-size: 11.5px;
line-height: 1.7;
}
@media (max-width: 680px) {
.page-intro {
align-items: flex-start;
flex-direction: column;
gap: 10px;
}
}
</style>
@@ -0,0 +1,42 @@
import type {
SigningDateRange,
SigningMethod,
SigningRecord,
SigningTaskStatus,
WorkbenchCampus,
} from '@/api/workbench/types'
export type SigningRecordFilterStatus = 'all' | SigningTaskStatus
/**
* 页面筛选表单。
* 与查询条件的差别只有一处:把「未选择」显式写成 `all` / 空串,
* 让下拉与输入框直接 v-model 到具体值,不出现 undefined 造成的空选项。
*/
export interface SigningRecordFilterForm {
dateRange: SigningDateRange
campus: WorkbenchCampus | 'all'
department: string
category: string
method: SigningMethod | 'all'
status: SigningRecordFilterStatus
keyword: string
}
export interface SigningRecordFilterOption<T extends string = string> {
label: string
value: T
}
/** 表格行就是记录本身:记录页没有「选中后展开」的二级结构,不需要再包一层 */
export type SigningRecordRow = SigningRecord
/**
* 导出列定义。
* 表头文字与取值函数放在一起:导出文件里的字段一旦和页面表格两处各写一份,
* 改了页面忘了改导出,是这类页面最常见的不一致。
*/
export interface SigningRecordExportColumn {
label: string
value: (record: SigningRecord) => string
}
@@ -1,460 +0,0 @@
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue'
import type { SigningAnswers, SigningTaskRecord } from '@/api/workbench/types'
import {
applyAnswerToFieldHost,
applySelectedItemsToRoot,
applySignatureToRoot,
buildSigningDocumentHtml,
collectSelectedItemsFromRoot,
describeFieldProgress,
OPTION_TICK_SELECTOR,
readAnswersFromRoot,
SIGNATURE_ACTION_SELECTOR,
toggleOptionAt,
type SigningDocumentData,
type SigningFieldProgress,
type SigningSignatureRequest,
} from '@/utils/signing-document'
// 签署文书交互化渲染:填写项渲染(HIS 预填 + 现场确认)、签名回显、勾选收集见
// utils/signing-document 与 utils/signing-fields,与文书库的「签署预览」共用同一实现。
// 本组件只负责挂载 HTML、收集填写项取值并渲染签署态外壳。
const props = withDefaults(
defineProps<{
task: SigningTaskRecord
interactive?: boolean
/**
* 在文书里点击签名位采集到的签名图(key 是字段键,如 doctor)。
*
* 不走 task:task 是服务端快照,这里装的是"本次会话刚采到、还没提交"的取值。
* 单独一份也让「整篇重渲染」不会把刚采的签名冲掉。
*/
signatureInjections?: SigningAnswers
}>(),
{ interactive: false, signatureInjections: () => ({}) },
)
const emit = defineEmits<{
'selection-change': [items: string[]]
'field-change': [answers: SigningAnswers, progress: SigningFieldProgress]
/** 请求采集某个签名位,由签署页打开签字板并把结果回灌到 signatureInjections */
'signature-request': [request: SigningSignatureRequest]
}>()
const containerRef = ref<HTMLElement | null>(null)
const selectedItems = ref<string[]>([])
const fieldAnswers = ref<SigningAnswers>({})
const progress = ref<SigningFieldProgress>({
total: 0,
filled: 0,
missingRequired: [],
abnormal: [],
pendingSignatures: [],
})
// 只挑渲染真正需要的字段显式映射,不直接透传整个任务对象:
// 渲染数据契约与任务记录是两回事,哪天字段改名也不会静默丢取值。
const documentData = computed<SigningDocumentData>(() => ({
patientName: props.task.patientName,
sex: props.task.sex,
age: props.task.age,
visitNo: props.task.visitNo,
visitDate: props.task.visitDate,
signedAt: props.task.signedAt,
signatureDataUrl: props.task.signatureDataUrl,
selectedItems: props.task.selectedItems,
fieldAnswers: props.task.fieldAnswers,
}))
const processedHtml = computed(() =>
buildSigningDocumentHtml(
props.task.documentContentHtml ?? '',
documentData.value,
props.interactive,
// 本组件就是签名位的点击宿主(见 handleDocumentClick),
// 所以可采集与否只取决于文书当前是否可交互
{ signatureActionable: props.interactive },
),
)
/** 文书里的填写项数量(含只读展示),用于提示"这份文书有几个填写项" */
const fieldTotal = computed(() => progress.value.total)
/** 签名/状态文案:作废要单独说清楚,不能被当成"待患者签署" */
const signatureStateLabel = computed(() => {
if (props.task.status === 'void') {
return '已作废'
}
if (props.task.signatureDataUrl) {
return '已采集签名'
}
return props.task.status === 'signed' ? '已签署' : '待患者签署'
})
/**
* 从渲染后的 DOM 读回填写项取值与勾选清单。
* 生成 HTML 时已把取值写成属性/文本,这里再按取值同步一次 DOM 状态,
* 保证「签完再打开时填写项/勾选还在」这件事不依赖序列化细节。
*/
function syncFromDom() {
const container = containerRef.value
if (!container) {
return
}
const answers = readAnswersFromRoot(container)
fieldAnswers.value = answers
progress.value = describeFieldProgress(container, answers)
selectedItems.value = collectSelectedItemsFromRoot(container)
emit('field-change', { ...answers }, { ...progress.value })
emit('selection-change', [...selectedItems.value])
}
/** 按已固化的取值还原 DOM 状态(存量勾选表 + 新填写项都覆盖) */
function applyStoredValues() {
const container = containerRef.value
if (!container) {
return
}
applySelectedItemsToRoot(container, props.task.selectedItems ?? [])
const answers: SigningAnswers = { ...(props.task.fieldAnswers ?? {}) }
if (props.task.signatureDataUrl && !answers.signature) {
answers.signature = props.task.signatureDataUrl
}
container.querySelectorAll<HTMLElement>('[data-sign-field]').forEach((host) => {
const id = host.getAttribute('data-sign-field')
const value = id ? answers[id] : undefined
if (id === undefined || value === undefined) {
return
}
// 读写的判据都在 signing-document 里(readAnswersFromRoot 的镜像),
// 这里不自己写 data-sign-* 选择器,否则选项组这类"同 DOM 不同语义"的控件会两处漂移
applyAnswerToFieldHost(host, value)
})
}
watch(
() => props.task,
() => {
selectedItems.value = [...(props.task.selectedItems ?? [])]
fieldAnswers.value = { ...(props.task.fieldAnswers ?? {}) }
emit('selection-change', [...selectedItems.value])
},
{ immediate: true },
)
/**
* 从事件目标往上找「可点击采集的签名位」。
* 用 closest 而不是直接比对 target:签名位胶囊里还有类型标签、虚线框等子节点,
* 点在这些子节点上时 target 并不是宿主节点本身。
*/
function resolveSignatureHost(target: EventTarget | null): HTMLElement | null {
if (!(target instanceof Element)) {
return null
}
const host = target.closest<HTMLElement>(SIGNATURE_ACTION_SELECTOR)
// closest 会一路向上穿过容器,事件也可能来自容器外,必须确认落在本文书里
return host && containerRef.value?.contains(host) ? host : null
}
function requestSignature(host: HTMLElement) {
const fieldId = host.getAttribute('data-sign-field') ?? ''
if (!fieldId) {
return
}
emit('signature-request', {
fieldId,
label: host.getAttribute('data-sign-label') ?? fieldId,
})
}
/**
* 点击文书里的 ☑ / ☐ 选项。
*
* 与签名位分开处理:签名位是"请求采集"(要弹签字板),选项是就地切换。
* 切换逻辑本身在 utils/signing-document 的 toggleOptionAt 里,
* 组件只负责把点击翻译成一次调用 —— 单选互斥的判据不该在组件里再推一遍。
*/
function handleDocumentClick(event: MouseEvent) {
if (!props.interactive) {
return
}
const target = event.target
if (target instanceof Element) {
const option = target.closest<HTMLElement>(OPTION_TICK_SELECTOR)
const container = containerRef.value
if (option && container && toggleOptionAt(container, option)) {
syncFromDom()
return
}
}
const host = resolveSignatureHost(event.target)
if (host) {
requestSignature(host)
}
}
/** 签名位挂了 role=button 与 tabindex,键盘必须同样可用,否则只有鼠标点得动 */
function handleDocumentKeydown(event: KeyboardEvent) {
if (!props.interactive || (event.key !== 'Enter' && event.key !== ' ')) {
return
}
const host = resolveSignatureHost(event.target)
if (host) {
// 空格默认会滚动容器,必须挡掉
event.preventDefault()
requestSignature(host)
}
}
/**
* 把外部采集到的签名图就地写进签名位,写完立刻回读 DOM 上报取值。
*
* 不重算 processedHtml:整篇重建会换掉 v-html 的 DOM,
* 操作人员正在输入的填写项会连同光标一起丢。
*/
function patchSignatures() {
const container = containerRef.value
if (!container) {
return
}
const patched = Object.entries(props.signatureInjections).some(
([fieldId, value]) =>
typeof value === 'string' && applySignatureToRoot(container, fieldId, value),
)
if (patched) {
syncFromDom()
}
}
watch(
processedHtml,
() => {
applyStoredValues()
// 重渲染会丢掉就地写入的签名图,得重新贴一遍
patchSignatures()
},
{ flush: 'post' },
)
watch(() => props.signatureInjections, patchSignatures, { deep: true, flush: 'post' })
onMounted(() => {
applyStoredValues()
patchSignatures()
syncFromDom()
})
/** 供「导出 PDF」使用:拿到已渲染的文书容器(含固定页眉与签署态取值) */
function getDocumentElement() {
return containerRef.value
}
defineExpose({ getDocumentElement })
</script>
<template>
<article class="sign-doc">
<div class="sign-doc-banner">
<span>已按任务固化的文书版本渲染(填写项已按患者信息预填)</span>
<span class="sign-doc-state" :class="{ 'is-void': task.status === 'void' }">
{{ signatureStateLabel }}
</span>
</div>
<div v-if="task.status === 'void'" class="void-stamp">已 作 废</div>
<!-- eslint-disable vue/no-v-html -- 内容为本系统定稿的文书 HTML(编辑器产出、仅医务人员可改),非任意用户输入 -->
<div
ref="containerRef"
class="sign-doc-body"
@change="syncFromDom"
@input="syncFromDom"
@click="handleDocumentClick"
@keydown="handleDocumentKeydown"
v-html="processedHtml"
/>
<!-- eslint-enable vue/no-v-html -->
<footer v-if="interactive" class="sign-doc-footer">
<span>
填写项 <strong>{{ progress.filled }}</strong> / {{ fieldTotal }} 项已填
<template v-if="progress.abnormal.length">
・异常项:<strong class="is-abnormal">{{ progress.abnormal.join('、') }}</strong>
</template>
</span>
<span v-if="progress.missingRequired.length" class="is-missing">
必填未完成:{{ progress.missingRequired.join('、') }},请补充后再采集签名。
</span>
<span v-else-if="progress.pendingSignatures.length" class="is-pending">
还有签名位待采集:<strong>{{ progress.pendingSignatures.join('、') }}</strong
>,可直接点击文中签名位完成。
</span>
<span v-else
>完成填写后通过「手写板签署 / 线上签署」采集签名,填写项与勾选结果随签署证据一并提交。</span
>
</footer>
<footer v-else-if="fieldTotal" class="sign-doc-footer">
<span>
填写项 <strong>{{ progress.filled }}</strong> / {{ fieldTotal }} 项已填,取值已随签署固化。
</span>
</footer>
</article>
</template>
<style scoped>
.sign-doc {
position: relative;
display: flex;
flex-direction: column;
min-height: 480px;
background: #fff;
border-radius: var(--r);
box-shadow: var(--sh);
overflow: hidden;
}
/* 作废章压在文书上,位置与存量回退纸样(SigningDocumentPreview)保持一致 */
.void-stamp {
position: absolute;
top: 52px;
right: 32px;
padding: 4px 12px;
color: rgb(208 83 79 / 70%);
font-size: 20px;
font-weight: 700;
letter-spacing: 4px;
pointer-events: none;
border: 2.5px solid rgb(208 83 79 / 55%);
border-radius: 8px;
transform: rotate(-14deg);
}
.sign-doc-banner {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
justify-content: space-between;
padding: 9px 16px;
color: var(--brand);
font-size: 11.5px;
background: var(--brand-l);
}
.sign-doc-state {
padding: 2px 9px;
color: #fff;
font-weight: 700;
background: var(--brand);
border-radius: 10px;
}
.sign-doc-state.is-void {
background: #8a9aa3;
}
.sign-doc-body {
flex: 1;
padding: 26px 32px 30px;
overflow: auto;
}
.sign-doc-footer {
display: flex;
flex-direction: column;
gap: 4px;
padding: 10px 16px;
font-size: 12px;
color: var(--mut);
background: #f8fbfc;
border-top: 1px solid var(--line);
}
.sign-doc-footer strong {
color: var(--brand);
}
.sign-doc-footer .is-abnormal {
color: var(--err);
}
.sign-doc-footer .is-missing {
color: var(--err);
}
.sign-doc-footer .is-pending {
color: #a96900;
}
.sign-doc-footer .is-pending strong {
color: #a96900;
}
/* 注入的文书 HTML(v-html 内容不携带 scoped 属性,统一用 :deep 覆盖基础文书样式) */
.sign-doc-body :deep(p) {
margin: 0 0 6px;
font-size: 13.5px;
line-height: 1.85;
color: #1c2b33;
}
/* 文书表格按内容自适应列宽并允许换行,避免绝对列宽在窄容器里把整表裁掉 */
.sign-doc-body :deep(table) {
width: 100%;
margin: 8px 0 12px;
border-collapse: collapse;
table-layout: auto;
font-size: 12.5px;
line-height: 1.6;
}
.sign-doc-body :deep(th),
.sign-doc-body :deep(td) {
padding: 5px 7px;
border: 1px solid #52646e;
vertical-align: middle;
overflow-wrap: anywhere;
word-break: break-word;
}
.sign-doc-body :deep(th) {
font-weight: 700;
background: #f2f6f8;
}
.sign-doc-body :deep(img) {
max-width: 100%;
}
/* 填写项胶囊、选项 ☑ / ☐、系统页眉的样式在 styles/signing-document.css(全局):
它们同时出现在文书库预览、签署工作台与编辑器里,各写一份必然漂移。
这里只保留"这份文书外壳"自己的版式(正文字号、表格边框等)。 */
</style>
@@ -1,240 +0,0 @@
<script setup lang="ts">
/**
* 文书回退纸样。
*
* 只在任务缺少正文快照(documentContentHtml 为空)时使用:任务接口不返回正文,
* 正文由 hydrateSigningTaskContent 按 templateContentSha256 取回,取不到时才会走到这里。
*
* 这里的正文段落是通用告知语句,**不是任何一份真实模板的内容**,
* 因此页面必须明确标注"未取到模板正文",不能让医务人员误以为这就是要签的文书。
* 经治医师等应由现场填写的字段也不许写死:写死一个不存在的医生名,
* 等于在留痕材料上伪造了一个签署人。
*/
defineProps<{
task: SigningTaskRecord
}>()
import type { SigningTaskRecord } from '@/api/workbench/types'
</script>
<template>
<article class="paper-preview">
<div v-if="task.status === 'void'" class="void-stamp">已 作 废</div>
<div class="fallback-banner">
<strong>未能取到模板正文</strong>
<span>
本任务仅返回了模板版本哈希({{ task.contentSha256 || '未提供' }}),正文快照暂不可用。
请确认后端模板版本内容接口可访问,或联系信息科核对模板数据。
以下为占位版式,不是本任务实际签署的文书内容。
</span>
</div>
<h2>{{ task.documentName }}</h2>
<p class="paper-number">编号:{{ task.id }} · 就诊号:{{ task.visitNo }}</p>
<p class="paper-meta">
患者:<strong>{{ task.patientName }}</strong
>({{ task.sex }},{{ task.age }}岁) · 患者 ID:{{ task.patientId }}
</p>
<p class="paper-meta">
就诊信息:{{ task.visitType }} · {{ task.department }} · {{ task.visitDate }}
<span v-if="task.visitType === '住院'" class="current-badge">在院</span>
</p>
<p>
医务人员已就本项检查/治疗的适应证、禁忌证、医疗风险、替代方案及可能出现的不良后果向本人(或授权委托人)充分说明,本人已完全理解上述内容。
</p>
<p>
本人自愿接受上述检查/治疗,并同意在相关医疗文书中签署确认。所签署文件将作为医疗过程留痕依据,签署过程采用电子方式完成,签名图片与文档原件一并存档。
</p>
<div class="notice-block">
<strong>签署须知:</strong>
请仔细阅读全部内容后签名;签名图片将合成至本文档签名域;文档与签名原图双留存,可随时查验;如对内容有疑问,请先向医务人员咨询。
</div>
<div class="signature-grid">
<div class="signature-field">
<span>患者 / 授权委托人签名:</span>
<div class="signature-box">
<img v-if="task.signatureDataUrl" :src="task.signatureDataUrl" alt="患者签名" />
<span v-else>{{ task.status === 'void' ? '—' : '未签署' }}</span>
</div>
</div>
<div class="signature-field">
<span>日期:</span>
<div class="signature-box signature-box--date">
<span>{{ task.signedAt ?? '—' }}</span>
</div>
</div>
<!-- 经治医师不是任务快照里的字段,也无从由前端补齐:
这里不给占位人名,避免在留痕材料上凭空出现一个签署人 -->
<div class="signature-field">
<span>经治医师:</span>
<div class="signature-box signature-box--doctor">
<span>(以实际签署文书为准)</span>
</div>
</div>
</div>
</article>
</template>
<style scoped>
.paper-preview {
position: relative;
min-height: 560px;
padding: 34px 44px;
color: var(--ink);
font-size: 13.5px;
line-height: 1.9;
background: #fff;
border-radius: var(--r);
box-shadow: var(--sh);
}
/* 回退纸样必须在最显眼处声明"这不是真实文书内容",
否则医务人员会把占位段落当成要签的文书 */
.fallback-banner {
display: flex;
flex-direction: column;
gap: 4px;
padding: 10px 14px;
margin-bottom: 20px;
color: var(--warn);
font-size: 12px;
line-height: 1.7;
background: var(--warn-l);
border: 1px solid #f0dcae;
border-radius: 8px;
}
.fallback-banner strong {
font-size: 12.5px;
}
.paper-preview h2 {
margin: 0 0 6px;
font-size: 19px;
letter-spacing: 2px;
text-align: center;
}
.paper-number {
margin: 0 0 18px;
color: var(--mut);
font-size: 12px;
text-align: center;
}
.paper-preview p {
margin: 0 0 6px;
text-align: justify;
text-indent: 2em;
}
.paper-preview .paper-meta {
color: var(--mut);
text-indent: 0;
}
.paper-meta strong {
color: var(--ink);
}
.current-badge {
display: inline-block;
padding: 1px 7px;
margin-left: 4px;
color: var(--err);
font-size: 10px;
line-height: 1.5;
background: var(--err-l);
border-radius: 4px;
}
.notice-block {
padding: 10px 14px;
margin: 10px 0;
font-size: 12.5px;
background: #fafcfd;
border: 1px dashed var(--line);
border-radius: 8px;
}
.notice-block strong {
font-size: 12.5px;
}
.signature-grid {
display: flex;
gap: 40px;
align-items: flex-end;
flex-wrap: wrap;
margin-top: 22px;
}
.signature-field > span {
display: block;
margin-bottom: 6px;
color: var(--mut);
font-size: 12.5px;
}
.signature-box {
display: flex;
min-width: 200px;
min-height: 64px;
align-items: center;
justify-content: center;
border-bottom: 2px solid var(--ink);
}
.signature-box img {
max-width: 190px;
max-height: 60px;
}
.signature-box span {
color: var(--mut);
font-size: 12px;
}
.signature-box--date {
min-width: 150px;
}
.signature-box--doctor {
min-width: 120px;
}
/* 医师签名位是空的说明文字,不需要下划线,避免看起来像已填写 */
.signature-field:nth-child(3) .signature-box {
border-bottom-style: dashed;
}
.void-stamp {
position: absolute;
top: 34px;
right: 44px;
padding: 4px 12px;
color: rgb(208 83 79 / 70%);
font-size: 20px;
font-weight: 700;
letter-spacing: 4px;
border: 2.5px solid rgb(208 83 79 / 55%);
border-radius: 8px;
transform: rotate(-14deg);
}
@media (max-width: 700px) {
.paper-preview {
padding: 26px 24px;
}
.void-stamp {
top: 24px;
right: 24px;
}
}
</style>
@@ -9,10 +9,10 @@ import type {
} from '@/api/workbench/types'
import type { SigningFieldProgress, SigningSignatureRequest } from '@/utils/signing-document'
import SigningDocumentInteractive from './SigningDocumentInteractive.vue'
import SigningDocumentPreview from './SigningDocumentPreview.vue'
import SigningDocumentInteractive from '@/components/signing/SigningDocumentInteractive.vue'
import SigningDocumentPreview from '@/components/signing/SigningDocumentPreview.vue'
import TaskAuditTimeline from '@/components/signing/TaskAuditTimeline.vue'
import SigningArtifactList from './SigningArtifactList.vue'
import TaskAuditTimeline from './TaskAuditTimeline.vue'
import type { SigningTaskAction } from '../types'
defineProps<{
@@ -1,92 +0,0 @@
<script setup lang="ts">
import type { SigningTaskEvent } from '@/api/workbench/types'
defineProps<{
events: SigningTaskEvent[]
loading: boolean
error: boolean
}>()
</script>
<template>
<section class="audit-card">
<strong>操作留痕</strong>
<div v-if="loading" class="audit-state">正在加载操作留痕…</div>
<div v-else-if="error" class="audit-state audit-state--error">
操作留痕加载失败,请稍后刷新重试
</div>
<div v-else-if="!events.length" class="audit-state">暂无操作留痕</div>
<ol v-else class="audit-list">
<li v-for="event in events" :key="event.id">
<time>{{ event.time }}</time>
<span class="audit-event-content">
<span>{{ event.text }}</span>
<small v-if="event.attemptNo || event.reason">
<template v-if="event.attemptNo">第 {{ event.attemptNo }} 次</template>
<template v-if="event.attemptNo && event.reason"> · </template>
<template v-if="event.reason">{{ event.reason }}</template>
</small>
</span>
</li>
</ol>
</section>
</template>
<style scoped>
.audit-card {
padding: 12px 16px;
margin-top: 14px;
background: var(--card);
border-radius: var(--r);
box-shadow: var(--sh);
}
.audit-card > strong {
font-size: 12px;
}
.audit-state {
padding: 8px 0 1px;
color: var(--mut);
font-size: 12px;
}
.audit-state--error {
color: var(--err);
}
.audit-list {
display: flex;
flex-direction: column;
gap: 5px;
padding: 0;
margin: 6px 0 0;
color: var(--mut);
font-size: 12px;
line-height: 1.65;
list-style: none;
}
.audit-list li {
display: flex;
gap: 10px;
}
.audit-list time {
flex-shrink: 0;
color: var(--mut);
}
.audit-event-content {
display: inline-flex;
gap: 4px;
flex-wrap: wrap;
}
.audit-event-content small {
color: var(--mut);
font-size: 11px;
}
</style>