feat(签署记录): 新增签署记录查询页并复用签署任务接口
This commit is contained in:
@@ -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>
|
||||
Reference in New Issue
Block a user