Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
528 lines
11 KiB
Vue
528 lines
11 KiB
Vue
<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>
|