chore(report): 下线「系统管理 → 报表分析」菜单及其接口与开关

This commit is contained in:
yelan
2026-09-30 09:42:34 +08:00
parent fe8cb01d4d
commit e7a7c1f01b
21 changed files with 48 additions and 2988 deletions
@@ -16,7 +16,6 @@ const componentRegistry: Record<string, Component> = {
'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')),
'report/index': defineAsyncComponent(() => import('@/views/management/reports/index.vue')),
'monitor/audit/index': defineAsyncComponent(() => import('@/views/management/audit/index.vue')),
'report/sign/overview/index': defineAsyncComponent(
() => import('@/views/reports/overview/index.vue'),
@@ -1,86 +0,0 @@
<script setup lang="ts">
import { computed } from 'vue'
import type { ReportDepartmentPoint } from '../types'
const props = defineProps<{
points: ReportDepartmentPoint[]
}>()
const maxValue = computed(() => Math.max(...props.points.map((point) => point.value), 1))
</script>
<template>
<div v-if="points.length" class="distribution-list" aria-label="科室签署分布">
<div v-for="point in points" :key="point.department" class="distribution-row">
<span class="department-name" :title="point.department">{{ point.department }}</span>
<div class="distribution-track" aria-hidden="true">
<span
class="distribution-value"
:style="{
width: `${(point.value / maxValue) * 100}%`,
backgroundColor: point.color,
}"
/>
</div>
<strong>{{ point.value }}</strong>
</div>
</div>
<div v-else class="empty-chart">暂无科室数据</div>
</template>
<style scoped>
.distribution-list {
padding-top: 2px;
}
.distribution-row {
display: flex;
gap: 10px;
align-items: center;
margin-bottom: 12px;
}
.distribution-row:last-child {
margin-bottom: 0;
}
.department-name {
flex: 0 0 86px;
overflow: hidden;
color: var(--ink);
font-size: 12.5px;
text-overflow: ellipsis;
white-space: nowrap;
}
.distribution-track {
flex: 1;
height: 14px;
overflow: hidden;
background: #eef4f6;
border-radius: 7px;
}
.distribution-value {
display: block;
height: 100%;
min-width: 2px;
border-radius: 7px;
}
.distribution-row strong {
flex: 0 0 30px;
color: var(--ink);
font-size: 12px;
text-align: right;
}
.empty-chart {
display: grid;
min-height: 150px;
color: var(--mut);
font-size: 13px;
place-items: center;
}
</style>
@@ -1,353 +0,0 @@
<script setup lang="ts">
import { formatSigningMethod, readSigningMethod } from '@/utils/signing-method'
import type { ReportTask, ReportTaskStatus } from '../types'
defineProps<{
rows: ReportTask[]
page: number
pageSize: number
total: number
pages: number
loading: boolean
error: string
pageSizeOptions: number[]
}>()
const emit = defineEmits<{
view: [row: ReportTask]
'page-change': [page: number]
'page-size-change': [size: number]
retry: []
}>()
const statusLabels: Record<ReportTaskStatus, string> = {
signed: '已签署',
confirming: '待科室确认',
pending: '待签署',
signing: '签署中',
expired: '已超时',
void: '已作废',
failed: '处理失败',
}
function getStatusLabel(row: ReportTask) {
if (row.status === 'pending') {
return `${statusLabels[row.status]}·${readSigningMethod(row.method).short}`
}
return statusLabels[row.status]
}
function getStatusClass(status: ReportTaskStatus) {
return `status-${status}`
}
function formatTime(date: Date | undefined) {
if (!date) {
return '—'
}
const pad = (value: number) => String(value).padStart(2, '0')
return `${date.getMonth() + 1}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}`
}
function formatDuration(row: ReportTask) {
const seconds =
row.durationSeconds !== undefined
? row.durationSeconds
: row.durationMinutes !== undefined
? row.durationMinutes * 60
: undefined
if (seconds === undefined || !Number.isFinite(seconds)) {
return '—'
}
const roundedSeconds = Math.round(seconds)
const minutes = Math.floor(roundedSeconds / 60)
const restSeconds = roundedSeconds % 60
if (!minutes) {
return String(restSeconds) + '秒'
}
return restSeconds
? String(minutes) + '分' + String(restSeconds) + '秒'
: String(minutes) + '分钟'
}
function getPageCount(pages: number) {
return Math.max(pages, 1)
}
function handlePreviousPage(page: number) {
if (page > 1) {
emit('page-change', page - 1)
}
}
function handleNextPage(page: number, pages: number) {
if (page < getPageCount(pages)) {
emit('page-change', page + 1)
}
}
function handlePageSizeChange(event: Event) {
const value = Number((event.target as HTMLSelectElement).value)
if (Number.isFinite(value)) {
emit('page-size-change', value)
}
}
</script>
<template>
<div v-if="error" class="table-error" role="alert">
<span>{{ error }}</span>
<button type="button" @click="emit('retry')">重试</button>
</div>
<div v-else class="table-container" :class="{ 'is-loading': loading }" :aria-busy="loading">
<div class="table-scroll">
<table class="detail-table">
<thead>
<tr>
<th>任务号</th>
<th>患者</th>
<th>文档</th>
<th>科室</th>
<th>方式</th>
<th>状态</th>
<th>发起时间</th>
<th>签署完成</th>
<th>耗时</th>
<th>操作</th>
</tr>
</thead>
<tbody>
<tr v-for="row in rows" :key="row.id">
<td>{{ row.id }}</td>
<td>
<strong>{{ row.patientName }}</strong>
<span class="patient-id">{{ row.patientId }}</span>
</td>
<td class="document-name" :title="row.documentName">{{ row.documentName }}</td>
<td>{{ row.department }}</td>
<td>{{ formatSigningMethod(row.method) }}</td>
<td>
<span class="status-tag" :class="getStatusClass(row.status)">
{{ getStatusLabel(row) }}
</span>
</td>
<td>{{ formatTime(row.createdAt) }}</td>
<td>{{ formatTime(row.signedAt) }}</td>
<td>{{ formatDuration(row) }}</td>
<td>
<button type="button" class="view-button" @click="emit('view', row)">查看</button>
</td>
</tr>
<tr v-if="!rows.length">
<td colspan="10" class="empty-cell">暂无符合条件的签署明细</td>
</tr>
</tbody>
</table>
</div>
<div v-if="total" class="table-footer">
<span>共 {{ total }} 条</span>
<label>
每页
<select :value="pageSize" aria-label="选择每页条数" @change="handlePageSizeChange">
<option v-for="option in pageSizeOptions" :key="option" :value="option">
{{ option }}
</option>
</select>
条
</label>
<button
type="button"
class="page-button"
:disabled="page <= 1 || loading"
@click="handlePreviousPage(page)"
>
上一页
</button>
<span>第 {{ page }} / {{ getPageCount(pages) }} 页</span>
<button
type="button"
class="page-button"
:disabled="page >= getPageCount(pages) || loading"
@click="handleNextPage(page, pages)"
>
下一页
</button>
</div>
</div>
</template>
<style scoped>
.table-scroll {
width: 100%;
overflow-x: auto;
}
.table-container {
position: relative;
}
.table-container.is-loading {
min-height: 120px;
pointer-events: none;
opacity: 0.55;
}
.detail-table {
width: 100%;
min-width: 1000px;
font-size: 13px;
border-collapse: collapse;
}
.detail-table th {
padding: 9px 12px;
color: var(--mut);
font-size: 12.5px;
font-weight: 700;
text-align: left;
white-space: nowrap;
background: #f5f9fa;
border-bottom: 1px solid var(--line);
}
.detail-table td {
padding: 9px 12px;
vertical-align: middle;
border-bottom: 1px solid #eef4f6;
}
.detail-table tbody tr:hover td {
background: #f7fbfc;
}
.patient-id {
display: block;
margin-top: 2px;
color: var(--mut);
font-size: 11px;
}
.document-name {
max-width: 230px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.status-tag {
display: inline-block;
padding: 2px 10px;
font-size: 11.5px;
font-weight: 700;
white-space: nowrap;
border-radius: 20px;
}
.status-signed {
color: var(--ok);
background: var(--ok-l);
}
.status-pending {
color: var(--brand);
background: var(--brand-l);
}
.status-signing {
color: var(--info);
background: var(--info-l);
}
.status-expired {
color: var(--err);
background: var(--err-l);
}
.status-void {
color: #8a9aa3;
background: #eceef0;
}
.status-failed {
color: var(--err);
background: #fff0f0;
}
.view-button {
padding: 4px 10px;
color: var(--brand);
font-size: 12px;
background: #fff;
border: 1px solid var(--brand);
border-radius: 5px;
}
.view-button:hover {
background: var(--brand-l);
}
.empty-cell {
padding: 40px 12px !important;
color: var(--mut);
text-align: center;
}
.table-error {
display: flex;
gap: 12px;
align-items: center;
justify-content: center;
min-height: 90px;
color: var(--err);
font-size: 12px;
}
.table-error button,
.page-button {
padding: 5px 10px;
color: var(--brand);
font-size: 12px;
background: #fff;
border: 1px solid var(--brand);
border-radius: 5px;
}
.table-footer {
display: flex;
gap: 12px;
align-items: center;
justify-content: flex-end;
padding-top: 12px;
color: var(--mut);
font-size: 12px;
}
.table-footer label {
display: inline-flex;
gap: 4px;
align-items: center;
}
.table-footer select {
padding: 3px 5px;
color: var(--ink);
background: #fff;
border: 1px solid var(--line);
border-radius: 4px;
}
.page-button:disabled {
cursor: not-allowed;
opacity: 0.45;
}
</style>
@@ -1,263 +0,0 @@
<script setup lang="ts">
import type {
ReportCampus,
ReportDocumentCategory,
ReportMethodFilter,
ReportPeriodOption,
ReportSelectOption,
} from '../types'
defineProps<{
period: ReportPeriodOption
campus: ReportCampus
department: string
category: ReportDocumentCategory
method: ReportMethodFilter
keyword: string
customFrom: string
customTo: string
periodOptions: ReportSelectOption<ReportPeriodOption>[]
campusOptions: ReportSelectOption<ReportCampus>[]
departmentOptions: ReportSelectOption[]
categoryOptions: ReportSelectOption<ReportDocumentCategory>[]
methodOptions: ReportSelectOption<ReportMethodFilter>[]
exportLoading: boolean
exportStatus: string
canExport: boolean
}>()
const emit = defineEmits<{
'update:period': [value: ReportPeriodOption]
'update:campus': [value: ReportCampus]
'update:department': [value: string]
'update:category': [value: ReportDocumentCategory]
'update:method': [value: ReportMethodFilter]
'update:keyword': [value: string]
'update:customFrom': [value: string]
'update:customTo': [value: string]
export: []
}>()
function readValue(event: Event) {
return (event.target as HTMLSelectElement).value
}
function handlePeriodChange(event: Event) {
emit('update:period', readValue(event) as ReportPeriodOption)
}
function handleCampusChange(event: Event) {
emit('update:campus', readValue(event) as ReportCampus)
}
function handleDepartmentChange(event: Event) {
emit('update:department', readValue(event))
}
function handleCategoryChange(event: Event) {
emit('update:category', readValue(event) as ReportDocumentCategory)
}
function handleMethodChange(event: Event) {
emit('update:method', readValue(event) as ReportMethodFilter)
}
function handleKeywordChange(event: Event) {
emit('update:keyword', readValue(event))
}
</script>
<template>
<section class="filter-card">
<div class="filter-bar">
<label class="filter-field">
<span>时间</span>
<select :value="period" aria-label="选择报表时间" @change="handlePeriodChange">
<option v-for="option in periodOptions" :key="option.value" :value="option.value">
{{ option.label }}
</option>
</select>
</label>
<label class="filter-field">
<span>院区</span>
<select :value="campus" aria-label="选择报表院区" @change="handleCampusChange">
<option v-for="option in campusOptions" :key="option.value" :value="option.value">
{{ option.label }}
</option>
</select>
</label>
<label class="filter-field">
<span>科室</span>
<select :value="department" aria-label="选择报表科室" @change="handleDepartmentChange">
<option v-for="option in departmentOptions" :key="option.value" :value="option.value">
{{ option.label }}
</option>
</select>
</label>
<label class="filter-field">
<span>文档类别</span>
<select :value="category" aria-label="选择文档类别" @change="handleCategoryChange">
<option v-for="option in categoryOptions" :key="option.value" :value="option.value">
{{ option.label }}
</option>
</select>
</label>
<label class="filter-field">
<span>方式</span>
<select :value="method" aria-label="选择签署方式" @change="handleMethodChange">
<option v-for="option in methodOptions" :key="option.value" :value="option.value">
{{ option.label }}
</option>
</select>
</label>
<label class="filter-field keyword-field">
<span>关键词</span>
<input
type="search"
:value="keyword"
maxlength="128"
placeholder="任务号/患者/文档"
aria-label="搜索报表关键词"
@change="handleKeywordChange"
/>
</label>
<template v-if="period === 'custom'">
<label class="filter-field date-field">
<span>起始</span>
<input
type="date"
:value="customFrom"
aria-label="选择报表起始日期"
@change="emit('update:customFrom', readValue($event))"
/>
</label>
<label class="filter-field date-field">
<span>结束</span>
<input
type="date"
:value="customTo"
aria-label="选择报表结束日期"
@change="emit('update:customTo', readValue($event))"
/>
</label>
</template>
<span class="filter-spacer" />
<span v-if="exportStatus" class="export-status" role="status">{{ exportStatus }}</span>
<button
v-if="canExport"
type="button"
class="export-button"
:disabled="exportLoading"
@click="emit('export')"
>
{{ exportLoading ? '导出中…' : '导出' }}
</button>
</div>
</section>
</template>
<style scoped>
.filter-card {
padding: 16px 18px;
margin-bottom: 16px;
background: var(--card);
border-radius: var(--r);
box-shadow: var(--sh);
}
.filter-bar {
display: flex;
flex-wrap: wrap;
gap: 10px 16px;
align-items: center;
}
.filter-field {
display: flex;
gap: 6px;
align-items: center;
color: var(--mut);
font-size: 12.5px;
}
.filter-field select {
width: auto;
min-width: 98px;
padding: 7px 10px;
color: var(--ink);
font-size: 13px;
background: #fff;
border: 1px solid var(--line);
border-radius: 6px;
}
.filter-field input {
width: 132px;
padding: 7px 8px;
color: var(--ink);
font-size: 13px;
background: #fff;
border: 1px solid var(--line);
border-radius: 6px;
}
.keyword-field input {
width: 180px;
}
.filter-field input:focus {
outline: none;
border-color: var(--brand);
}
.filter-field select:focus {
outline: none;
border-color: var(--brand);
}
.filter-spacer {
flex: 1;
min-width: 20px;
}
.export-button {
padding: 8px 16px;
color: var(--brand);
font-size: 13px;
font-weight: 700;
background: #fff;
border: 1px solid var(--brand);
border-radius: 7px;
}
.export-button:hover {
background: var(--brand-l);
}
.export-button:disabled {
cursor: wait;
opacity: 0.6;
}
.export-status {
max-width: 240px;
overflow: hidden;
color: var(--mut);
font-size: 11px;
text-overflow: ellipsis;
white-space: nowrap;
}
@media (max-width: 900px) {
.filter-spacer {
display: none;
}
}
</style>
@@ -1,120 +0,0 @@
<script setup lang="ts">
import type { ReportMetricCard as ReportMetricCardData } from '../types'
defineProps<{
metric: ReportMetricCardData
}>()
</script>
<template>
<article class="metric-card">
<div class="metric-icon" :class="'tone-' + metric.tone" aria-hidden="true">
<svg v-if="metric.key === 'total'" width="24" height="24" viewBox="0 0 24 24" fill="none">
<path
d="M9 5H7a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V7a2 2 0 0 0-2-2h-2M9 5a2 2 0 0 0 2 2h2a2 2 0 0 0 2-2M9 5a2 2 0 0 1 2-2h2a2 2 0 0 1 2 2m-6 9 2 2 4-4"
stroke="currentColor"
stroke-width="1.8"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
<svg
v-else-if="metric.key === 'signed'"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
>
<path
d="M20 6 9 17l-5-5"
stroke="currentColor"
stroke-width="2.2"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
<svg v-else-if="metric.key === 'rate'" width="24" height="24" viewBox="0 0 24 24" fill="none">
<circle cx="12" cy="12" r="9" stroke="currentColor" stroke-width="2" />
<path d="M12 7v5l3 3" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
</svg>
<svg v-else width="24" height="24" viewBox="0 0 24 24" fill="none">
<circle cx="12" cy="12" r="9" stroke="currentColor" stroke-width="2" />
<path
d="M12 8v5M12 16h.01"
stroke="currentColor"
stroke-width="2.2"
stroke-linecap="round"
/>
</svg>
</div>
<div>
<div class="metric-value">
{{ metric.value
}}<span v-if="metric.unit && metric.value !== '—'" class="metric-unit">{{
metric.unit
}}</span>
</div>
<div class="metric-label">{{ metric.label }}</div>
</div>
</article>
</template>
<style scoped>
.metric-card {
display: flex;
gap: 14px;
align-items: center;
padding: 16px 18px;
background: var(--card);
border-radius: var(--r);
box-shadow: var(--sh);
}
.metric-icon {
display: grid;
flex-shrink: 0;
width: 48px;
height: 48px;
place-items: center;
border-radius: 12px;
}
.metric-icon.tone-brand {
color: var(--brand);
background: var(--brand-l);
}
.metric-icon.tone-ok {
color: var(--ok);
background: var(--ok-l);
}
.metric-icon.tone-info {
color: var(--info);
background: var(--info-l);
}
.metric-icon.tone-warn {
color: var(--warn);
background: var(--warn-l);
}
.metric-value {
color: var(--ink);
font-size: 26px;
font-weight: 700;
line-height: 1.1;
}
.metric-unit {
margin-left: 3px;
font-size: 13px;
font-weight: 400;
}
.metric-label {
margin-top: 3px;
color: var(--mut);
font-size: 12px;
}
</style>
@@ -1,114 +0,0 @@
<script setup lang="ts">
import { computed } from 'vue'
import type { ReportTrendPoint } from '../types'
const props = defineProps<{
points: ReportTrendPoint[]
}>()
const chartBars = computed(() => {
const width = 640
const height = 200
const paddingX = 30
const paddingY = 30
const slotWidth = (width - paddingX * 2) / Math.max(props.points.length, 1)
const barWidth = slotWidth * 0.55
const maxValue = Math.max(...props.points.map((point) => point.value), 3) * 1.2
return props.points.map((point, index) => {
const barHeight = (point.value / maxValue) * (height - paddingY * 2)
const x = paddingX + slotWidth * index + (slotWidth - barWidth) / 2
const y = height - paddingY - barHeight
return {
...point,
x,
y,
width: barWidth,
height: Math.max(barHeight, 2),
}
})
})
const gridLines = [0, 1, 2, 3].map((index) => 30 + ((200 - 60) / 3) * index)
</script>
<template>
<div v-if="points.length" class="chart-wrap">
<svg viewBox="0 0 640 200" role="img" aria-label="每日签署量趋势柱状图">
<defs>
<linearGradient id="report-bar-gradient" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#18a0c0" />
<stop offset="1" stop-color="#0e6e8c" />
</linearGradient>
</defs>
<line
v-for="line in gridLines"
:key="line"
x1="30"
:y1="line"
x2="610"
:y2="line"
stroke="#e6eef2"
stroke-dasharray="3 4"
/>
<g v-for="bar in chartBars" :key="bar.date">
<rect
:x="bar.x"
:y="bar.y"
:width="bar.width"
:height="bar.height"
rx="4"
:fill="bar.value ? 'url(#report-bar-gradient)' : '#e2ebef'"
>
<title>{{ bar.label }}:{{ bar.value }} 单</title>
</rect>
<text
v-if="bar.value"
:x="bar.x + bar.width / 2"
:y="bar.y - 6"
text-anchor="middle"
font-size="10.5"
fill="#0a4f66"
font-weight="bold"
>
{{ bar.value }}
</text>
<text
:x="bar.x + bar.width / 2"
y="190"
text-anchor="middle"
font-size="9.5"
fill="#687f8b"
>
{{ bar.label }}
</text>
</g>
</svg>
</div>
<div v-else class="empty-chart">暂无趋势数据</div>
</template>
<style scoped>
.chart-wrap {
width: 100%;
min-height: 200px;
}
.chart-wrap svg {
display: block;
width: 100%;
height: auto;
}
.empty-chart {
display: grid;
min-height: 200px;
color: var(--mut);
font-size: 13px;
place-items: center;
}
</style>
File diff suppressed because it is too large Load Diff
@@ -1,235 +0,0 @@
import type {
ReportCampus,
ReportDepartmentPoint,
ReportDocumentCategory,
ReportPeriodOption,
ReportSelectOption,
ReportTask,
} from './types'
function daysAgo(days: number, hour: number, minute: number) {
const date = new Date()
date.setDate(date.getDate() - days)
date.setHours(hour, minute, 0, 0)
return date
}
export const reportCampusOptions: ReportSelectOption<ReportCampus>[] = [
{ value: 'all', label: '全部院区' },
{ value: '本部院区', label: '本部院区' },
{ value: '东院区', label: '东院区' },
{ value: '西院区', label: '西院区' },
]
export const reportDepartmentOptions: ReportSelectOption[] = [
{ value: 'all', label: '全部科室' },
{ value: '放射科', label: '放射科' },
{ value: '心内科', label: '心内科' },
{ value: '骨科', label: '骨科' },
{ value: '健康管理中心', label: '健康管理中心' },
{ value: '全院通用', label: '全院通用' },
]
export const reportCategoryOptions: ReportSelectOption<ReportDocumentCategory>[] = [
{ value: 'all', label: '全部类别' },
{ value: 'consent', label: '知情同意书' },
{ value: 'risk', label: '风险告知书' },
{ value: 'admission', label: '入院告知' },
]
export const reportPeriodOptions: ReportSelectOption<ReportPeriodOption>[] = [
{ value: '7d', label: '近7天' },
{ value: '14d', label: '近14天' },
{ value: 'month', label: '本月' },
{ value: 'custom', label: '自定义…' },
]
export const reportTasks: ReportTask[] = [
{
id: 'T0818001',
patientName: '赵美英',
patientId: 'P202610004',
documentName: '体检报告发放及知情同意书',
department: '健康管理中心',
campus: '东院区',
category: 'consent',
method: 'pad',
status: 'signed',
createdAt: daysAgo(0, 9, 12),
signedAt: daysAgo(0, 9, 19),
expiredAt: daysAgo(-1, 9, 12),
durationMinutes: 7,
},
{
id: 'T0818009',
patientName: '王秀兰',
patientId: 'P202610007',
documentName: '内镜检查知情同意书',
department: '消化内科',
campus: '本部院区',
category: 'consent',
method: 'sms',
// 「待科室确认」样本:患者已签完,等科室复核签章 ——
// 没有这条样本,报表里的这一栏永远是 0,看不出它到底有没有接上
status: 'confirming',
createdAt: daysAgo(0, 10, 40),
signedAt: daysAgo(0, 10, 45),
expiredAt: daysAgo(-1, 10, 40),
durationMinutes: 5,
},
{
id: 'T0818002',
patientName: '李淑芬',
patientId: 'P202610002',
documentName: 'MRI磁共振检查知情同意书',
department: '放射科',
campus: '东院区',
category: 'consent',
method: 'sms',
status: 'pending',
createdAt: daysAgo(0, 10, 5),
},
{
id: 'T0818003',
patientName: '周雅琴',
patientId: 'P202610006',
documentName: '住院须知及告知书',
department: '全院通用',
campus: '本部院区',
category: 'admission',
method: 'pad',
status: 'pending',
createdAt: daysAgo(0, 11, 20),
},
{
id: 'T0818004',
patientName: '孙浩然',
patientId: 'P202610005',
documentName: '心脏介入手术知情同意书',
department: '心内科',
campus: '西院区',
category: 'consent',
method: 'pad',
status: 'signed',
createdAt: daysAgo(1, 15, 40),
signedAt: daysAgo(1, 15, 47),
expiredAt: daysAgo(0, 15, 40),
durationMinutes: 7,
},
{
id: 'T0817001',
patientName: '王建国',
patientId: 'P202610001',
documentName: '冠状动脉造影知情同意书',
department: '心内科',
campus: '本部院区',
category: 'consent',
method: 'sms',
status: 'signed',
createdAt: daysAgo(1, 9, 30),
signedAt: daysAgo(1, 9, 37),
expiredAt: daysAgo(0, 9, 30),
durationMinutes: 7,
},
{
id: 'T0817002',
patientName: '陈志强',
patientId: 'P202610003',
documentName: '骨科手术知情同意书',
department: '骨科',
campus: '本部院区',
category: 'consent',
method: 'pad',
status: 'signed',
createdAt: daysAgo(2, 10, 15),
signedAt: daysAgo(2, 10, 22),
expiredAt: daysAgo(1, 10, 15),
durationMinutes: 7,
},
{
id: 'T0816001',
patientName: '陈志强',
patientId: 'P202610003',
documentName: '麻醉知情同意书',
department: '骨科',
campus: '本部院区',
category: 'consent',
method: 'pad',
status: 'signed',
createdAt: daysAgo(3, 14, 25),
signedAt: daysAgo(3, 14, 32),
expiredAt: daysAgo(2, 14, 25),
durationMinutes: 7,
},
{
id: 'T0815001',
patientName: '王建国',
patientId: 'P202610001',
documentName: '心包穿刺知情同意书',
department: '心内科',
campus: '本部院区',
category: 'consent',
method: 'sms',
status: 'expired',
createdAt: daysAgo(4, 16, 0),
expiredAt: daysAgo(4, 16, 0),
},
{
id: 'T0814001',
patientName: '周雅琴',
patientId: 'P202610006',
documentName: '患者授权委托书',
department: '全院通用',
campus: '本部院区',
category: 'consent',
method: 'pad',
status: 'void',
createdAt: daysAgo(5, 11, 45),
},
// Pad 与公众号两条:四种方式都要有样本,否则「方式」列与筛选选到 Pad / 公众号时
// 永远只能得到空结果,看着像筛选坏了(其实是演示数据没覆盖)。
{
id: 'T0813002',
patientName: '罗文静',
patientId: 'P202610007',
documentName: '床旁超声检查知情同意书',
department: '放射科',
campus: '东院区',
category: 'consent',
method: 'ipad',
status: 'signed',
createdAt: daysAgo(2, 8, 50),
signedAt: daysAgo(2, 8, 58),
expiredAt: daysAgo(1, 8, 50),
durationMinutes: 8,
},
{
id: 'T0813001',
patientName: '梁俊豪',
patientId: 'P202610008',
documentName: '体检报告发放及知情同意书',
department: '健康管理中心',
campus: '东院区',
category: 'consent',
method: 'wx',
status: 'signed',
createdAt: daysAgo(3, 16, 10),
signedAt: daysAgo(3, 16, 26),
expiredAt: daysAgo(2, 16, 10),
durationMinutes: 16,
},
]
export const reportDepartmentColors: Record<string, string> = {
放射科: '#0e6e8c',
心内科: '#18a0c0',
骨科: '#0f9d6c',
健康管理中心: '#d9821f',
全院通用: '#7a4dbb',
}
export const fallbackDepartmentPoint: ReportDepartmentPoint = {
department: '暂无数据',
value: 0,
color: '#cbd8de',
}
@@ -1,81 +0,0 @@
import type { SigningMethod } from '@/api/workbench/types'
export type ReportPeriodOption = '7d' | '14d' | 'month' | 'custom'
/** 院区由后端字典提供,保留 all 作为全院筛选值。 */
export type ReportCampus = 'all' | (string & {})
export type ReportDocumentCategory = 'all' | (string & {})
export type ReportMethodFilter = 'all' | SigningMethod
export interface ReportFilterForm {
period: ReportPeriodOption
campus: ReportCampus
department: string
category: ReportDocumentCategory
method: ReportMethodFilter
keyword: string
customFrom: string
customTo: string
}
export type ReportMetricTone = 'brand' | 'ok' | 'info' | 'warn'
export interface ReportMetricCard {
key: string
label: string
value: string | number
unit?: string
tone: ReportMetricTone
}
export type ReportTaskStatus =
| 'signed'
| 'confirming'
| 'pending'
| 'signing'
| 'expired'
| 'void'
| 'failed'
export type ReportSigningMethod = SigningMethod
export interface ReportTask {
id: string
patientName: string
patientId: string
documentName: string
department: string
campus: Exclude<ReportCampus, 'all'>
campusId?: string
category: Exclude<ReportDocumentCategory, 'all'>
method: ReportSigningMethod
status: ReportTaskStatus
createdAt: Date
deadlineAt?: Date
signStartedAt?: Date
signedAt?: Date
expiredAt?: Date
voidedAt?: Date
onTime?: boolean
durationSeconds?: number
durationMinutes?: number
}
export interface ReportTrendPoint {
date: string
label: string
value: number
}
export interface ReportDepartmentPoint {
department: string
value: number
color: string
}
export interface ReportSelectOption<T extends string = string> {
value: T
label: string
}