Files
X-Financial/src/views/OverviewView.vue

607 lines
14 KiB
Vue
Raw Normal View History

<template>
<section class="dashboard">
<div class="kpi-grid">
<article
v-for="metric in kpiMetrics"
:key="metric.label"
class="kpi-card panel"
:style="{ '--accent': metric.accent, '--delay': `${metric.delay}ms` }"
>
<div class="kpi-head">
<span class="kpi-icon"><i :class="metric.icon"></i></span>
<span class="kpi-label">{{ metric.label }}</span>
</div>
<strong class="kpi-value">{{ metric.displayValue }}</strong>
<div class="kpi-trend">
<span class="kpi-badge" :class="metric.trend">
<i :class="metric.trend === 'down' ? 'mdi mdi-arrow-down' : 'mdi mdi-arrow-up'"></i>
{{ metric.changeText }}
</span>
<span class="kpi-delta">{{ metric.delta }}</span>
</div>
</article>
</div>
<div class="content-grid top-grid">
<article class="panel dashboard-card trend-panel">
<div class="card-head">
<h3>报销申请与审批趋势 <i class="mdi mdi-information-outline"></i></h3>
<select v-model="activeTrendRange" class="card-select" aria-label="趋势时间范围">
<option v-for="range in trendRanges" :key="range">{{ range }}</option>
</select>
</div>
<TrendChart
:labels="activeTrend.labels"
:applications="activeTrend.applications"
:approved="activeTrend.approved"
:avg-hours="activeTrend.avgHours"
/>
</article>
<article class="panel dashboard-card donut-panel">
<div class="card-head">
<h3>费用结构 <i class="mdi mdi-information-outline"></i></h3>
</div>
<DonutChart :items="spendLegend" center-value="¥361.6K" center-label="待处理金额" />
<p class="panel-note">* 百分比为占待处理金额比例</p>
</article>
<article class="panel dashboard-card donut-panel">
<div class="card-head">
<h3>风险异常分布 <i class="mdi mdi-information-outline"></i></h3>
</div>
<DonutChart :items="riskLegend" :center-value="`${riskTotal}`" center-label="异常预警单" />
<p class="panel-note">* 近30天数据</p>
</article>
</div>
<div class="content-grid bottom-grid">
<article class="panel dashboard-card rank-panel">
<div class="card-head">
<h3>部门报销排行待处理金额 <i class="mdi mdi-information-outline"></i></h3>
<select v-model="activeDepartmentRange" class="card-select" aria-label="部门排行时间范围">
<option v-for="range in departmentRangeOptions" :key="range">{{ range }}</option>
</select>
</div>
<BarChart :items="rankedDepartments" />
</article>
<article class="panel dashboard-card bottleneck-panel">
<div class="card-head">
<h3>审批瓶颈平均处理时长 <i class="mdi mdi-information-outline"></i></h3>
</div>
<div class="bottleneck-list">
<div
v-for="(item, index) in bottlenecks"
:key="item.name"
class="bottleneck-row"
:style="{ '--delay': `${index * 70}ms` }"
>
<div class="reviewer">
<div class="reviewer-avatar">{{ item.avatar }}</div>
<div>
<strong>{{ item.name }}</strong>
<span>{{ item.role }}</span>
</div>
</div>
<div class="reviewer-stats">
<strong>{{ item.duration }}</strong>
<span class="status-tag" :class="item.tone">{{ item.status }}</span>
</div>
</div>
</div>
<button type="button" class="text-link">查看全部 <i class="mdi mdi-chevron-right"></i></button>
</article>
<article class="panel dashboard-card budget-panel">
<div class="card-head">
<h3>预算执行率本月 <i class="mdi mdi-information-outline"></i></h3>
</div>
<GaugeChart
:ratio="budgetSummary.ratio"
:total="budgetSummary.total"
:used="budgetSummary.used"
:left="budgetSummary.left"
/>
<button type="button" class="text-link">查看详情 <i class="mdi mdi-chevron-right"></i></button>
</article>
</div>
</section>
</template>
<script setup>
import { computed, ref } from 'vue'
import {
metricBlueprints,
trendRanges,
trendSeries,
spendByCategory,
exceptionMix,
departmentRangeOptions,
bottlenecks,
budgetSummary
} from '../data/metrics.js'
import TrendChart from '../components/charts/TrendChart.vue'
import DonutChart from '../components/charts/DonutChart.vue'
import BarChart from '../components/charts/BarChart.vue'
import GaugeChart from '../components/charts/GaugeChart.vue'
defineProps({
filteredRequests: { type: Array, required: true }
})
const activeTrendRange = ref(trendRanges[0])
const activeDepartmentRange = ref(departmentRangeOptions[0])
const demoTotals = {
pendingCount: 128,
pendingAmount: 361600,
avgSla: 6.8,
autoPassRate: 78,
riskCount: 14,
slaRate: 96
}
const demoDepartments = [
{ name: '销售部', amount: 182000, color: '#10b981' },
{ name: '研发中心', amount: 146000, color: '#3b82f6' },
{ name: '市场部', amount: 96000, color: '#f59e0b' },
{ name: '运营部', amount: 68600, color: '#8b5cf6' },
{ name: '行政部', amount: 48300, color: '#3b82f6' }
]
const formatCompact = (value) => {
if (value >= 1_000_000) return `¥${(value / 1_000_000).toFixed(1)}M`
if (value >= 1_000) return `¥${(value / 1_000).toFixed(1)}K`
return `¥${value}`
}
const formatCurrency = (value) => formatCompact(value)
const formatMetricValue = (metric, value) => {
if (metric.key === 'pendingAmount') return formatCurrency(Math.round(value))
if (metric.key === 'avgSla') return `${value.toFixed(1)} ${metric.unit}`
if (metric.unit === '%') return `${Math.round(value)} ${metric.unit}`
if (metric.unit) return `${Math.round(value)} ${metric.unit}`
return `${Math.round(value)}`
}
const kpiMetrics = computed(() => metricBlueprints.map((metric, index) => {
const rawValue = demoTotals[metric.key]
const displayValue = formatMetricValue(metric, rawValue)
return {
...metric,
displayValue,
changeText: metric.change,
delay: index * 55
}
}))
const activeTrend = computed(() => trendSeries[activeTrendRange.value])
const spendTotal = computed(() => spendByCategory.reduce((sum, item) => sum + item.value, 0))
const riskTotal = computed(() => exceptionMix.reduce((sum, item) => sum + item.value, 0))
const spendLegend = computed(() => spendByCategory.map((item) => ({
...item,
display: `${Math.round((item.value / spendTotal.value) * 100)}%`
})))
const riskLegend = computed(() => exceptionMix.map((item) => ({
...item,
display: `${item.value}`
})))
const rankedDepartments = computed(() => {
const rows = demoDepartments
const max = Math.max(...rows.map((item) => item.amount), 1)
return rows.slice(0, 5).map((item, index) => ({
...item,
rank: index + 1,
shortName: item.name,
amountLabel: formatCurrency(item.amount),
width: `${Math.max((item.amount / max) * 100, 18)}%`,
color: item.color
}))
})
</script>
<style scoped>
.dashboard {
display: grid;
gap: 24px;
animation: fadeUp 260ms var(--ease) both;
}
.kpi-grid {
display: grid;
grid-template-columns: repeat(6, minmax(0, 1fr));
gap: 24px;
}
.kpi-card {
position: relative;
padding: 20px 20px 16px;
display: flex;
flex-direction: column;
border-left: 3px solid var(--accent);
animation: dashboardItemIn 520ms var(--ease) both;
animation-delay: var(--delay, 0ms);
transition: box-shadow 200ms ease, transform 200ms ease;
}
.kpi-card:hover {
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.06);
transform: translateY(-1px);
}
.kpi-head {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 16px;
}
.kpi-icon {
width: 36px;
height: 36px;
display: grid;
place-items: center;
border-radius: 8px;
background: color-mix(in srgb, var(--accent) 10%, white);
color: var(--accent);
font-size: 18px;
flex: 0 0 auto;
animation: iconPop 560ms var(--ease) both;
animation-delay: calc(var(--delay, 0ms) + 100ms);
}
.kpi-label {
color: #64748b;
font-size: 13px;
font-weight: 500;
line-height: 1.3;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.kpi-value {
display: block;
height: 32px;
line-height: 32px;
color: #0f172a;
font-size: clamp(20px, 1.6vw, 26px);
line-height: 1;
font-weight: 800;
font-variant-numeric: tabular-nums;
white-space: nowrap;
margin-bottom: 16px;
letter-spacing: 0;
}
.kpi-trend {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding-top: 12px;
border-top: 1px solid #f1f5f9;
}
.kpi-badge {
display: inline-flex;
align-items: center;
gap: 3px;
padding: 2px 8px;
border-radius: 4px;
font-size: 12px;
font-weight: 700;
line-height: 1.6;
}
.kpi-badge.up {
background: rgba(239, 68, 68, 0.08);
color: #dc2626;
}
.kpi-badge.down {
background: rgba(22, 163, 74, 0.08);
color: #16a34a;
}
.kpi-badge .mdi {
font-size: 13px;
}
.kpi-delta {
color: #94a3b8;
font-size: 12px;
white-space: nowrap;
}
.content-grid {
display: grid;
grid-template-columns: repeat(12, minmax(0, 1fr));
gap: 24px;
}
.dashboard-card {
padding: 20px;
transition: box-shadow 200ms ease, transform 200ms ease;
animation: dashboardItemIn 560ms var(--ease) both;
}
.top-grid .dashboard-card:nth-child(1) { animation-delay: 80ms; }
.top-grid .dashboard-card:nth-child(2) { animation-delay: 150ms; }
.top-grid .dashboard-card:nth-child(3) { animation-delay: 220ms; }
.bottom-grid .dashboard-card:nth-child(1) { animation-delay: 290ms; }
.bottom-grid .dashboard-card:nth-child(2) { animation-delay: 360ms; }
.bottom-grid .dashboard-card:nth-child(3) { animation-delay: 430ms; }
.dashboard-card:hover {
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.06);
transform: translateY(-1px);
}
.trend-panel,
.rank-panel {
grid-column: span 6;
}
.donut-panel,
.bottleneck-panel,
.budget-panel {
grid-column: span 3;
}
.card-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
margin-bottom: 16px;
}
.card-head h3 {
color: #1e293b;
font-size: 16px;
font-weight: 600;
line-height: 1.35;
}
.card-head .mdi {
color: #94a3b8;
font-size: 12px;
vertical-align: 1px;
}
.card-select {
height: 30px;
min-width: 82px;
padding: 0 8px;
border: 1px solid #cbd5e1;
border-radius: 4px;
background: #fff;
color: #334155;
font-size: 14px;
}
.panel-note {
margin-top: 8px;
color: #64748b;
font-size: 12px;
text-align: center;
}
.bottleneck-panel,
.budget-panel {
display: flex;
flex-direction: column;
}
.bottleneck-panel .text-link,
.budget-panel .text-link {
margin-top: auto;
}
.bottleneck-list {
flex: 1;
display: grid;
gap: 16px;
align-content: center;
}
.bottleneck-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
animation: listRowIn 460ms var(--ease) both;
animation-delay: var(--delay, 0ms);
}
.reviewer {
display: flex;
align-items: center;
gap: 12px;
min-width: 0;
}
.reviewer-avatar {
width: 32px;
height: 32px;
display: grid;
place-items: center;
border-radius: 999px;
background: #e2f6ef;
color: #047857;
font-size: 13px;
font-weight: 700;
flex: 0 0 auto;
}
.reviewer strong,
.reviewer-stats strong {
display: block;
color: #1e293b;
font-size: 14px;
font-weight: 500;
}
.reviewer span,
.reviewer-stats span {
display: block;
margin-top: 4px;
color: #64748b;
font-size: 12px;
}
.reviewer-stats {
text-align: right;
}
.status-tag {
display: inline-flex;
align-items: center;
padding: 2px 8px;
border-radius: 4px;
font-size: 12px;
}
.status-tag.danger {
background: rgba(239,68,68,.10);
color: #ef4444;
}
.status-tag.warning {
background: rgba(245,158,11,.10);
color: #f59e0b;
}
.status-tag.success {
background: rgba(16,185,129,.10);
color: #16a34a;
}
.text-link {
width: 100%;
margin-top: 16px;
display: inline-flex;
align-items: center;
justify-content: space-between;
padding: 16px 0 0;
border: 0;
border-top: 1px solid #f1f5f9;
background: transparent;
color: #10b981;
font-size: 14px;
}
@keyframes dashboardItemIn {
from {
opacity: 0;
transform: translateY(12px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes listRowIn {
from {
opacity: 0;
transform: translateX(-10px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
@keyframes iconPop {
0% {
opacity: 0;
transform: scale(.82);
}
70% {
opacity: 1;
transform: scale(1.04);
}
100% {
opacity: 1;
transform: scale(1);
}
}
@media (prefers-reduced-motion: reduce) {
.kpi-card,
.dashboard-card,
.bottleneck-row {
animation: none;
}
}
@media (max-width: 1320px) {
.kpi-grid {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.trend-panel,
.rank-panel {
grid-column: span 12;
}
.donut-panel,
.bottleneck-panel,
.budget-panel {
grid-column: span 6;
}
}
@media (max-width: 860px) {
.kpi-grid,
.content-grid {
grid-template-columns: 1fr;
}
.trend-panel,
.rank-panel,
.donut-panel,
.bottleneck-panel,
.budget-panel {
grid-column: span 1;
}
.card-head {
align-items: flex-start;
flex-direction: column;
}
.donut-wrap {
grid-template-columns: 1fr;
}
.rank-row {
grid-template-columns: 24px 64px minmax(0, 1fr);
}
.rank-value {
grid-column: 2 / -1;
}
.budget-summary {
grid-template-columns: 1fr;
}
}
</style>