feat(reports): implement report analysis

This commit is contained in:
yelan
2026-08-27 16:48:17 +08:00
parent 94a0d88f8b
commit 332fb53cfa
8 changed files with 1198 additions and 16 deletions
@@ -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>