feat(reports): implement report analysis
This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user