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

528 lines
11 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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 }"
>
<div class="kpi-top">
<div class="kpi-icon">
<i :class="metric.icon"></i>
</div>
<div>
<p>{{ metric.label }}</p>
<strong>{{ metric.displayValue }}</strong>
</div>
</div>
<div class="kpi-bottom" :class="metric.trend">
<span>
<i :class="metric.trend === 'down' ? 'pi pi-arrow-down' : 'pi pi-arrow-up'"></i>
{{ metric.changeText }}
</span>
<small>{{ metric.delta }}</small>
</div>
</article>
</div>
<div class="content-grid top-grid">
<article class="panel dashboard-card trend-panel">
<div class="card-head">
<h3>报销申请与审批趋势 <i class="pi pi-info-circle"></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="pi pi-info-circle"></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="pi pi-info-circle"></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="pi pi-info-circle"></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="pi pi-info-circle"></i></h3>
</div>
<div class="bottleneck-list">
<div v-for="item in bottlenecks" :key="item.name" class="bottleneck-row">
<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="pi pi-angle-right"></i></button>
</article>
<article class="panel dashboard-card budget-panel">
<div class="card-head">
<h3>预算执行率本月 <i class="pi pi-info-circle"></i></h3>
</div>
<GaugeChart
:ratio="budgetSummary.ratio"
:total="budgetSummary.total"
:used="budgetSummary.used"
:left="budgetSummary.left"
/>
<button type="button" class="text-link">查看详情 <i class="pi pi-angle-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 kpiMetrics = computed(() => metricBlueprints.map((metric) => {
const rawValue = demoTotals[metric.key]
const displayValue = metric.key === 'pendingAmount'
? formatCurrency(rawValue)
: metric.unit && !String(rawValue).endsWith(metric.unit)
? `${rawValue} ${metric.unit}`
: `${rawValue}`
return {
...metric,
displayValue,
changeText: metric.change
}
}))
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 {
padding: 20px;
display: flex;
flex-direction: column;
gap: 0;
}
.kpi-top {
flex: 1;
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 10px;
}
.kpi-top > div {
min-width: 0;
overflow: hidden;
}
.kpi-top p {
margin: 0 0 6px;
color: #64748b;
font-size: 13px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.kpi-top strong {
display: block;
color: #1e293b;
font-size: clamp(18px, 1.6vw, 22px);
line-height: 1.2;
font-weight: 700;
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.kpi-icon {
width: 44px;
height: 44px;
display: grid;
place-items: center;
border-radius: 10px;
background: color-mix(in srgb, var(--accent) 10%, white);
color: var(--accent);
font-size: 20px;
flex: 0 0 auto;
}
.kpi-bottom {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
margin-top: 12px;
padding-top: 12px;
border-top: 1px solid #f1f5f9;
}
.kpi-bottom span {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 13px;
font-weight: 600;
}
.kpi-bottom.up span {
color: #dc2626;
}
.kpi-bottom.down span {
color: #16a34a;
}
.kpi-bottom small {
color: #94a3b8;
font-size: 12px;
text-align: right;
}
.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;
}
.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 .pi {
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;
}
.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;
}
@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>