feat(reports): implement report analysis
This commit is contained in:
@@ -0,0 +1,86 @@
|
||||
<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>
|
||||
@@ -0,0 +1,175 @@
|
||||
<script setup lang="ts">
|
||||
import type { ReportTask, ReportTaskStatus } from '../types'
|
||||
|
||||
defineProps<{
|
||||
rows: ReportTask[]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
view: [row: ReportTask]
|
||||
}>()
|
||||
|
||||
const statusLabels: Record<ReportTaskStatus, string> = {
|
||||
signed: '已签署',
|
||||
pending: '待签署',
|
||||
expired: '已超时',
|
||||
void: '已作废',
|
||||
}
|
||||
|
||||
function getStatusLabel(row: ReportTask) {
|
||||
if (row.status === 'pending') {
|
||||
return `${statusLabels[row.status]}·${row.method === 'pad' ? '手写板' : '短信'}`
|
||||
}
|
||||
|
||||
return statusLabels[row.status]
|
||||
}
|
||||
|
||||
function getStatusClass(status: ReportTaskStatus) {
|
||||
return `status-${status}`
|
||||
}
|
||||
|
||||
function formatTime(date: Date) {
|
||||
const pad = (value: number) => String(value).padStart(2, '0')
|
||||
return `${date.getMonth() + 1}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}`
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<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>
|
||||
</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>{{ row.method === 'pad' ? '✍️ 手写板' : '📱 短信' }}</td>
|
||||
<td>
|
||||
<span class="status-tag" :class="getStatusClass(row.status)">
|
||||
{{ getStatusLabel(row) }}
|
||||
</span>
|
||||
</td>
|
||||
<td>{{ formatTime(row.createdAt) }}</td>
|
||||
<td>
|
||||
<button type="button" class="view-button" @click="emit('view', row)">查看</button>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="!rows.length">
|
||||
<td colspan="8" class="empty-cell">暂无符合条件的签署明细</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.table-scroll {
|
||||
width: 100%;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.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-expired {
|
||||
color: var(--err);
|
||||
background: var(--err-l);
|
||||
}
|
||||
|
||||
.status-void {
|
||||
color: #8a9aa3;
|
||||
background: #eceef0;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,158 @@
|
||||
<script setup lang="ts">
|
||||
import type {
|
||||
ReportCampus,
|
||||
ReportDocumentCategory,
|
||||
ReportPeriodOption,
|
||||
ReportSelectOption,
|
||||
} from '../types'
|
||||
|
||||
defineProps<{
|
||||
period: ReportPeriodOption
|
||||
campus: ReportCampus
|
||||
department: string
|
||||
category: ReportDocumentCategory
|
||||
periodOptions: ReportSelectOption<ReportPeriodOption>[]
|
||||
campusOptions: ReportSelectOption<ReportCampus>[]
|
||||
departmentOptions: ReportSelectOption[]
|
||||
categoryOptions: ReportSelectOption<ReportDocumentCategory>[]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:period': [value: ReportPeriodOption]
|
||||
'update:campus': [value: ReportCampus]
|
||||
'update:department': [value: string]
|
||||
'update:category': [value: ReportDocumentCategory]
|
||||
export: []
|
||||
}>()
|
||||
|
||||
function readSelectValue(event: Event) {
|
||||
return (event.target as HTMLSelectElement).value
|
||||
}
|
||||
|
||||
function handlePeriodChange(event: Event) {
|
||||
emit('update:period', readSelectValue(event) as ReportPeriodOption)
|
||||
}
|
||||
|
||||
function handleCampusChange(event: Event) {
|
||||
emit('update:campus', readSelectValue(event) as ReportCampus)
|
||||
}
|
||||
|
||||
function handleDepartmentChange(event: Event) {
|
||||
emit('update:department', readSelectValue(event))
|
||||
}
|
||||
|
||||
function handleCategoryChange(event: Event) {
|
||||
emit('update:category', readSelectValue(event) as ReportDocumentCategory)
|
||||
}
|
||||
</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>
|
||||
|
||||
<span class="filter-spacer" />
|
||||
<button type="button" class="export-button" @click="emit('export')">导出</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 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);
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.filter-spacer {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,117 @@
|
||||
<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" 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>
|
||||
@@ -0,0 +1,114 @@
|
||||
<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>
|
||||
@@ -1,12 +1,333 @@
|
||||
<script setup lang="ts">
|
||||
import PagePlaceholder from '@/components/PagePlaceholder.vue'
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
|
||||
import DepartmentDistributionChart from './components/DepartmentDistributionChart.vue'
|
||||
import ReportDetailTable from './components/ReportDetailTable.vue'
|
||||
import ReportFilterBar from './components/ReportFilterBar.vue'
|
||||
import ReportMetricCard from './components/ReportMetricCard.vue'
|
||||
import SignatureTrendChart from './components/SignatureTrendChart.vue'
|
||||
import {
|
||||
reportCampusOptions,
|
||||
reportCategoryOptions,
|
||||
reportDepartmentColors,
|
||||
reportDepartmentOptions,
|
||||
reportPeriodOptions,
|
||||
reportTasks,
|
||||
} from './mock'
|
||||
import type {
|
||||
ReportCampus,
|
||||
ReportDepartmentPoint,
|
||||
ReportDocumentCategory,
|
||||
ReportFilterForm,
|
||||
ReportMetricCard as ReportMetricCardData,
|
||||
ReportPeriodOption,
|
||||
ReportTask,
|
||||
ReportTrendPoint,
|
||||
} from './types'
|
||||
|
||||
const loading = ref(true)
|
||||
const tasks = ref<ReportTask[]>([])
|
||||
|
||||
const filters = reactive<ReportFilterForm>({
|
||||
period: '14d',
|
||||
campus: 'all',
|
||||
department: 'all',
|
||||
category: 'all',
|
||||
})
|
||||
|
||||
const filteredTasks = computed(() => {
|
||||
const startDate = getStartDate(filters.period)
|
||||
|
||||
return tasks.value.filter((task) => {
|
||||
const matchesDate = !startDate || task.createdAt >= startDate
|
||||
const matchesCampus = filters.campus === 'all' || task.campus === filters.campus
|
||||
const matchesDepartment = filters.department === 'all' || task.department === filters.department
|
||||
const matchesCategory = filters.category === 'all' || task.category === filters.category
|
||||
|
||||
return matchesDate && matchesCampus && matchesDepartment && matchesCategory
|
||||
})
|
||||
})
|
||||
|
||||
const metrics = computed<ReportMetricCardData[]>(() => {
|
||||
const list = filteredTasks.value
|
||||
const signedTasks = list.filter((task) => task.status === 'signed')
|
||||
const completionRate = list.length ? Math.round((signedTasks.length / list.length) * 100) : 0
|
||||
const averageMinutes = signedTasks.length
|
||||
? Math.round(
|
||||
signedTasks.reduce((total, task) => total + (task.durationMinutes ?? 7), 0) /
|
||||
signedTasks.length,
|
||||
)
|
||||
: 0
|
||||
|
||||
return [
|
||||
{ key: 'total', label: '累计发起任务', value: list.length, tone: 'brand' },
|
||||
{ key: 'signed', label: '签署完成', value: signedTasks.length, tone: 'ok' },
|
||||
{ key: 'rate', label: '按时签署率', value: `${completionRate}%`, tone: 'info' },
|
||||
{ key: 'average', label: '平均签署用时(分钟)', value: averageMinutes, tone: 'warn' },
|
||||
]
|
||||
})
|
||||
|
||||
const trendPoints = computed<ReportTrendPoint[]>(() => {
|
||||
const days = filters.period === '7d' ? 7 : 14
|
||||
const today = startOfDay(new Date())
|
||||
const signedTasks = filteredTasks.value.filter((task) => task.status === 'signed')
|
||||
|
||||
return Array.from({ length: days }, (_, index) => {
|
||||
const date = new Date(today)
|
||||
date.setDate(date.getDate() - (days - index - 1))
|
||||
|
||||
return {
|
||||
date: date.toISOString(),
|
||||
label: `${date.getMonth() + 1}/${date.getDate()}`,
|
||||
value: signedTasks.filter((task) => isSameDay(task.createdAt, date)).length,
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
const departmentPoints = computed<ReportDepartmentPoint[]>(() => {
|
||||
const counts = new Map<string, number>()
|
||||
|
||||
filteredTasks.value.forEach((task) => {
|
||||
counts.set(task.department, (counts.get(task.department) ?? 0) + 1)
|
||||
})
|
||||
|
||||
return [...counts.entries()]
|
||||
.map(([department, value]) => ({
|
||||
department,
|
||||
value,
|
||||
color: reportDepartmentColors[department] ?? '#0e6e8c',
|
||||
}))
|
||||
.sort((left, right) => right.value - left.value)
|
||||
})
|
||||
|
||||
function startOfDay(date: Date) {
|
||||
const result = new Date(date)
|
||||
result.setHours(0, 0, 0, 0)
|
||||
return result
|
||||
}
|
||||
|
||||
function getStartDate(period: ReportPeriodOption) {
|
||||
if (period === 'custom') {
|
||||
return null
|
||||
}
|
||||
|
||||
if (period === 'month') {
|
||||
const result = startOfDay(new Date())
|
||||
result.setDate(1)
|
||||
return result
|
||||
}
|
||||
|
||||
const days = period === '7d' ? 7 : 14
|
||||
const result = startOfDay(new Date())
|
||||
result.setDate(result.getDate() - days + 1)
|
||||
return result
|
||||
}
|
||||
|
||||
function isSameDay(left: Date, right: Date) {
|
||||
return startOfDay(left).getTime() === startOfDay(right).getTime()
|
||||
}
|
||||
|
||||
function updatePeriod(value: ReportPeriodOption) {
|
||||
filters.period = value
|
||||
}
|
||||
|
||||
function updateCampus(value: ReportCampus) {
|
||||
filters.campus = value
|
||||
}
|
||||
|
||||
function updateDepartment(value: string) {
|
||||
filters.department = value
|
||||
}
|
||||
|
||||
function updateCategory(value: ReportDocumentCategory) {
|
||||
filters.category = value
|
||||
}
|
||||
|
||||
function handleExport() {
|
||||
ElMessage.info('原型演示:导出 Excel')
|
||||
}
|
||||
|
||||
function handleView(row: ReportTask) {
|
||||
ElMessage.info(`原型演示:查看任务${row.id},签署工作台暂未实现`)
|
||||
}
|
||||
|
||||
async function loadReports() {
|
||||
loading.value = true
|
||||
await Promise.resolve()
|
||||
tasks.value = reportTasks.map((task) => ({
|
||||
...task,
|
||||
createdAt: new Date(task.createdAt),
|
||||
}))
|
||||
loading.value = false
|
||||
}
|
||||
|
||||
onMounted(loadReports)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<PagePlaceholder
|
||||
group="管理"
|
||||
title="报表分析"
|
||||
icon="↗"
|
||||
description="报表分析页面占位,待根据 Web 端需求实现。"
|
||||
/>
|
||||
<section class="reports-page">
|
||||
<ReportFilterBar
|
||||
:period="filters.period"
|
||||
:campus="filters.campus"
|
||||
:department="filters.department"
|
||||
:category="filters.category"
|
||||
:period-options="reportPeriodOptions"
|
||||
:campus-options="reportCampusOptions"
|
||||
:department-options="reportDepartmentOptions"
|
||||
:category-options="reportCategoryOptions"
|
||||
@update:period="updatePeriod"
|
||||
@update:campus="updateCampus"
|
||||
@update:department="updateDepartment"
|
||||
@update:category="updateCategory"
|
||||
@export="handleExport"
|
||||
/>
|
||||
|
||||
<div v-if="loading" class="loading-card" aria-label="正在加载报表">
|
||||
<span v-for="index in 4" :key="index" />
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<div class="metric-grid">
|
||||
<ReportMetricCard v-for="metric in metrics" :key="metric.key" :metric="metric" />
|
||||
</div>
|
||||
|
||||
<div class="chart-grid">
|
||||
<section class="page-card trend-card">
|
||||
<div class="card-heading">
|
||||
<h2>每日签署量趋势</h2>
|
||||
</div>
|
||||
<SignatureTrendChart :points="trendPoints" />
|
||||
</section>
|
||||
|
||||
<section class="page-card department-chart-card">
|
||||
<div class="card-heading">
|
||||
<h2>科室签署分布</h2>
|
||||
</div>
|
||||
<DepartmentDistributionChart :points="departmentPoints" />
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<section class="page-card detail-card">
|
||||
<div class="card-heading">
|
||||
<h2>签署明细</h2>
|
||||
<span>点击行可在工作台查看原件</span>
|
||||
</div>
|
||||
<ReportDetailTable :rows="filteredTasks" @view="handleView" />
|
||||
</section>
|
||||
</template>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.reports-page {
|
||||
width: 100%;
|
||||
max-width: 1440px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.metric-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.chart-grid {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.page-card {
|
||||
padding: 16px 18px;
|
||||
background: var(--card);
|
||||
border-radius: var(--r);
|
||||
box-shadow: var(--sh);
|
||||
}
|
||||
|
||||
.trend-card {
|
||||
flex: 1.5;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.department-chart-card {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.detail-card {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.card-heading {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.card-heading h2 {
|
||||
margin: 0;
|
||||
color: var(--ink);
|
||||
font-size: 14.5px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.card-heading span {
|
||||
margin-left: auto;
|
||||
color: var(--mut);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.loading-card {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.loading-card span {
|
||||
display: block;
|
||||
height: 80px;
|
||||
background: linear-gradient(90deg, #edf3f5 25%, #f7fafb 37%, #edf3f5 63%);
|
||||
background-size: 400% 100%;
|
||||
border-radius: var(--r);
|
||||
box-shadow: var(--sh);
|
||||
animation: skeleton-shimmer 1.4s ease infinite;
|
||||
}
|
||||
|
||||
@keyframes skeleton-shimmer {
|
||||
0% {
|
||||
background-position: 100% 0;
|
||||
}
|
||||
|
||||
100% {
|
||||
background-position: -100% 0;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1200px) {
|
||||
.metric-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.chart-grid {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.metric-grid,
|
||||
.loading-card {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.card-heading {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.card-heading span {
|
||||
margin-left: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
175
clinical-web/src/views/management/reports/mock.ts
Normal file
175
clinical-web/src/views/management/reports/mock.ts
Normal file
@@ -0,0 +1,175 @@
|
||||
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),
|
||||
durationMinutes: 7,
|
||||
},
|
||||
{
|
||||
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),
|
||||
durationMinutes: 7,
|
||||
},
|
||||
{
|
||||
id: 'T0817001',
|
||||
patientName: '王建国',
|
||||
patientId: 'P202610001',
|
||||
documentName: '冠状动脉造影知情同意书',
|
||||
department: '心内科',
|
||||
campus: '本部院区',
|
||||
category: 'consent',
|
||||
method: 'sms',
|
||||
status: 'signed',
|
||||
createdAt: daysAgo(1, 9, 30),
|
||||
durationMinutes: 7,
|
||||
},
|
||||
{
|
||||
id: 'T0817002',
|
||||
patientName: '陈志强',
|
||||
patientId: 'P202610003',
|
||||
documentName: '骨科手术知情同意书',
|
||||
department: '骨科',
|
||||
campus: '本部院区',
|
||||
category: 'consent',
|
||||
method: 'pad',
|
||||
status: 'signed',
|
||||
createdAt: daysAgo(2, 10, 15),
|
||||
durationMinutes: 7,
|
||||
},
|
||||
{
|
||||
id: 'T0816001',
|
||||
patientName: '陈志强',
|
||||
patientId: 'P202610003',
|
||||
documentName: '麻醉知情同意书',
|
||||
department: '骨科',
|
||||
campus: '本部院区',
|
||||
category: 'consent',
|
||||
method: 'pad',
|
||||
status: 'signed',
|
||||
createdAt: daysAgo(3, 14, 25),
|
||||
durationMinutes: 7,
|
||||
},
|
||||
{
|
||||
id: 'T0815001',
|
||||
patientName: '王建国',
|
||||
patientId: 'P202610001',
|
||||
documentName: '心包穿刺知情同意书',
|
||||
department: '心内科',
|
||||
campus: '本部院区',
|
||||
category: 'consent',
|
||||
method: 'sms',
|
||||
status: 'expired',
|
||||
createdAt: daysAgo(4, 16, 0),
|
||||
},
|
||||
{
|
||||
id: 'T0814001',
|
||||
patientName: '周雅琴',
|
||||
patientId: 'P202610006',
|
||||
documentName: '患者授权委托书',
|
||||
department: '全院通用',
|
||||
campus: '本部院区',
|
||||
category: 'consent',
|
||||
method: 'pad',
|
||||
status: 'void',
|
||||
createdAt: daysAgo(5, 11, 45),
|
||||
},
|
||||
]
|
||||
|
||||
export const reportDepartmentColors: Record<string, string> = {
|
||||
放射科: '#0e6e8c',
|
||||
心内科: '#18a0c0',
|
||||
骨科: '#0f9d6c',
|
||||
健康管理中心: '#d9821f',
|
||||
全院通用: '#7a4dbb',
|
||||
}
|
||||
|
||||
export const fallbackDepartmentPoint: ReportDepartmentPoint = {
|
||||
department: '暂无数据',
|
||||
value: 0,
|
||||
color: '#cbd8de',
|
||||
}
|
||||
@@ -1,21 +1,57 @@
|
||||
export type ReportPeriodOption = 'today' | 'week' | 'month' | 'year'
|
||||
export type ReportPeriodOption = '7d' | '14d' | 'month' | 'custom'
|
||||
|
||||
export type ReportCampus = 'all' | '本部院区' | '东院区' | '西院区'
|
||||
|
||||
export type ReportDocumentCategory = 'all' | 'consent' | 'risk' | 'admission'
|
||||
|
||||
export interface ReportFilterForm {
|
||||
period: ReportPeriodOption
|
||||
campus: ReportCampus
|
||||
department: string
|
||||
category: ReportDocumentCategory
|
||||
}
|
||||
|
||||
export type ReportMetricTone = 'brand' | 'ok' | 'info' | 'warn'
|
||||
|
||||
export interface ReportMetricCard {
|
||||
key: string
|
||||
label: string
|
||||
value: number
|
||||
unit: string
|
||||
trend: number
|
||||
value: string | number
|
||||
unit?: string
|
||||
tone: ReportMetricTone
|
||||
}
|
||||
|
||||
export interface ReportChartPoint {
|
||||
date: string
|
||||
signed: number
|
||||
rejected: number
|
||||
pending: number
|
||||
export type ReportTaskStatus = 'signed' | 'pending' | 'expired' | 'void'
|
||||
|
||||
export type ReportSigningMethod = 'pad' | 'sms'
|
||||
|
||||
export interface ReportTask {
|
||||
id: string
|
||||
patientName: string
|
||||
patientId: string
|
||||
documentName: string
|
||||
department: string
|
||||
campus: Exclude<ReportCampus, 'all'>
|
||||
category: Exclude<ReportDocumentCategory, 'all'>
|
||||
method: ReportSigningMethod
|
||||
status: ReportTaskStatus
|
||||
createdAt: Date
|
||||
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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user