feat: add interactive Chart.js trend chart and improve KPI card layout
- Replace static SVG trend chart with Chart.js bar+line mixed chart - Click-triggered custom tooltip popup with per-day detail overlay - KPI cards: flex column layout, compact K/M currency formatting - Custom legend above chart for consistent spacing Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
@@ -1,94 +1,733 @@
|
||||
<template>
|
||||
<section class="view">
|
||||
<div class="metric-strip">
|
||||
<section class="dashboard">
|
||||
<div class="kpi-grid">
|
||||
<article
|
||||
v-for="(metric, index) in metrics"
|
||||
v-for="metric in kpiMetrics"
|
||||
:key="metric.label"
|
||||
v-motion
|
||||
class="metric"
|
||||
:initial="{ opacity: 0, y: 16 }"
|
||||
:enter="{ opacity: 1, y: 0, transition: { delay: index * 0.07, duration: 0.36 } }"
|
||||
:style="{ '--accent': metric.color }"
|
||||
class="kpi-card panel"
|
||||
:style="{ '--accent': metric.accent }"
|
||||
>
|
||||
<div class="metric-top">
|
||||
<span>{{ metric.label }}</span>
|
||||
<b :class="metric.tone">{{ metric.delta }}</b>
|
||||
<div class="kpi-top">
|
||||
<div>
|
||||
<p>{{ metric.label }}</p>
|
||||
<strong>{{ metric.displayValue }}</strong>
|
||||
</div>
|
||||
<div class="kpi-icon">
|
||||
<i :class="metric.icon"></i>
|
||||
</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>
|
||||
<strong>{{ metric.value }}</strong>
|
||||
<small>{{ metric.note }}</small>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<div class="overview-grid">
|
||||
<article class="panel spend-panel">
|
||||
<PanelHead eyebrow="Category spend" title="费用类型月度支出" note="用企业报销常见科目展示本月费用压力。" />
|
||||
<div class="bar-chart" role="img" aria-label="费用类型月度支出柱状图">
|
||||
<div v-for="item in spendByCategory" :key="item.name" class="bar-row">
|
||||
<span>{{ item.name }}</span>
|
||||
<div class="bar-track"><div class="bar-fill" :style="{ width: item.width, background: item.color }"></div></div>
|
||||
<strong>{{ item.value }}</strong>
|
||||
</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">
|
||||
<PanelHead eyebrow="Compliance mix" title="审核结论占比" note="把自动通过、需补件和高风险分层展示。" />
|
||||
<div class="donut-layout">
|
||||
<div class="donut" aria-label="审核结论环形图"><strong>68%</strong><span>可自动处理</span></div>
|
||||
<div class="legend">
|
||||
<div v-for="item in auditMix" :key="item.name" class="legend-row">
|
||||
<i :style="{ background: item.color }"></i><span>{{ item.name }}</span><strong>{{ item.value }}</strong>
|
||||
<article class="panel dashboard-card donut-panel">
|
||||
<div class="card-head">
|
||||
<h3>费用结构 <i class="pi pi-info-circle"></i></h3>
|
||||
</div>
|
||||
<div class="donut-wrap">
|
||||
<div class="donut" :style="{ background: spendDonutBackground }">
|
||||
<div class="donut-center">
|
||||
<strong>{{ spendTotalLabel }}</strong>
|
||||
<span>待处理金额</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="donut-legend">
|
||||
<div v-for="item in spendLegend" :key="item.name" class="legend-row">
|
||||
<span><i :style="{ background: item.color }"></i>{{ item.name }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<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>
|
||||
<div class="donut-wrap">
|
||||
<div class="donut" :style="{ background: riskDonutBackground }">
|
||||
<div class="donut-center">
|
||||
<strong>{{ riskTotal }}</strong>
|
||||
<span>异常预警单</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="donut-legend">
|
||||
<div v-for="item in riskLegend" :key="item.name" class="legend-row">
|
||||
<span><i :style="{ background: item.color }"></i>{{ item.name }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p class="panel-note">* 近30天数据</p>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<RequestTable :requests="filteredRequests" @ask="emit('ask', $event)" @approve="emit('approve', $event)" @reject="emit('reject', $event)" />
|
||||
<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>
|
||||
|
||||
<div class="rank-list">
|
||||
<div v-for="item in rankedDepartments" :key="item.name" class="rank-row">
|
||||
<span class="rank-index">{{ item.rank }}</span>
|
||||
<span class="rank-name">{{ item.shortName }}</span>
|
||||
<div class="rank-track">
|
||||
<div class="rank-fill" :style="{ width: item.width, background: item.color }"></div>
|
||||
</div>
|
||||
<strong class="rank-value">{{ item.amountLabel }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
</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>
|
||||
|
||||
<div class="budget-gauge" :style="{ '--budget-ratio': `${budgetSummary.ratio}` }">
|
||||
<div class="budget-gauge-ring"></div>
|
||||
<div class="budget-gauge-center">
|
||||
<strong>{{ budgetSummary.ratio }}%</strong>
|
||||
<span>已执行</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="budget-summary">
|
||||
<div>
|
||||
<span>预算总额</span>
|
||||
<strong>{{ budgetSummary.total }}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>已执行</span>
|
||||
<strong>{{ budgetSummary.used }}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>剩余可用</span>
|
||||
<strong>{{ budgetSummary.left }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="button" class="text-link">查看详情 <i class="pi pi-angle-right"></i></button>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import PanelHead from '../components/shared/PanelHead.vue'
|
||||
import RequestTable from '../components/business/RequestTable.vue'
|
||||
import { metrics, spendByCategory, auditMix } from '../data/metrics.js'
|
||||
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'
|
||||
|
||||
defineProps({
|
||||
filteredRequests: { type: Array, required: true }
|
||||
})
|
||||
|
||||
const emit = defineEmits(['ask', 'approve', 'reject'])
|
||||
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 spendTotalLabel = computed(() => formatCurrency(demoTotals.pendingAmount))
|
||||
|
||||
const buildDonutBackground = (items, total) => {
|
||||
let current = 0
|
||||
const stops = items.map((item) => {
|
||||
const start = current
|
||||
current += (item.value / total) * 100
|
||||
return `${item.color} ${start}% ${current}%`
|
||||
}).join(',')
|
||||
|
||||
return `radial-gradient(circle, #fff 0 60%, transparent 61%), conic-gradient(${stops})`
|
||||
}
|
||||
|
||||
const spendDonutBackground = computed(() => buildDonutBackground(spendByCategory, spendTotal.value))
|
||||
const riskTotal = computed(() => exceptionMix.reduce((sum, item) => sum + item.value, 0))
|
||||
const riskDonutBackground = computed(() => buildDonutBackground(exceptionMix, riskTotal.value))
|
||||
|
||||
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>
|
||||
.view { display: grid; gap: 22px; animation: fadeUp 220ms var(--ease) both; }
|
||||
.metric-strip { display: grid; grid-template-columns: repeat(4, minmax(190px, 1fr)); gap: 16px; }
|
||||
.metric {
|
||||
min-height: 128px; padding: 18px; border-top: 3px solid var(--accent);
|
||||
border: 1px solid var(--line); border-radius: var(--radius); background: var(--surface);
|
||||
box-shadow: 0 1px 2px rgba(16,24,40,.04);
|
||||
transition: transform 220ms var(--ease), box-shadow 220ms var(--ease), border-color 220ms var(--ease);
|
||||
.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(20px, 2vw, 26px);
|
||||
line-height: 1.2;
|
||||
font-weight: 700;
|
||||
font-variant-numeric: tabular-nums;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.kpi-icon {
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border-radius: 10px;
|
||||
background: color-mix(in srgb, var(--accent) 10%, white);
|
||||
color: var(--accent);
|
||||
font-size: 16px;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.kpi-bottom {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1px;
|
||||
margin-top: 14px;
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid #f1f5f9;
|
||||
}
|
||||
|
||||
.kpi-bottom span {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.kpi-bottom.up span {
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
.kpi-bottom.down span {
|
||||
color: #16a34a;
|
||||
}
|
||||
|
||||
.kpi-bottom small {
|
||||
color: #94a3b8;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.content-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(12, minmax(0, 1fr));
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.dashboard-card {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.donut-wrap {
|
||||
min-height: 192px;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(116px, 1fr) minmax(96px, .8fr);
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.donut {
|
||||
width: min(148px, 100%);
|
||||
aspect-ratio: 1;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border-radius: 50%;
|
||||
justify-self: center;
|
||||
}
|
||||
|
||||
.donut-center {
|
||||
width: 84px;
|
||||
aspect-ratio: 1;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
align-content: center;
|
||||
border-radius: 50%;
|
||||
background: #fff;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.donut-center strong {
|
||||
color: #1e293b;
|
||||
font-size: 16px;
|
||||
line-height: 1;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.donut-center span {
|
||||
margin-top: 6px;
|
||||
color: #64748b;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.donut-legend {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
align-content: center;
|
||||
}
|
||||
|
||||
.legend-row span {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: #334155;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.legend-row i {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 999px;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.panel-note {
|
||||
margin-top: 8px;
|
||||
color: #64748b;
|
||||
font-size: 12px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.rank-list,
|
||||
.bottleneck-list {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.rank-row {
|
||||
display: grid;
|
||||
grid-template-columns: 24px 72px minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.rank-index {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border-radius: 999px;
|
||||
background: #94a3b8;
|
||||
color: #fff;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.rank-row:first-child .rank-index {
|
||||
background: #f59e0b;
|
||||
}
|
||||
|
||||
.rank-name {
|
||||
color: #475569;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.rank-value {
|
||||
color: #334155;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.rank-track {
|
||||
height: 24px;
|
||||
overflow: hidden;
|
||||
border-radius: 999px;
|
||||
background: #f1f5f9;
|
||||
}
|
||||
|
||||
.rank-fill {
|
||||
height: 100%;
|
||||
border-radius: inherit;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.budget-gauge {
|
||||
position: relative;
|
||||
height: 128px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
|
||||
.budget-gauge-ring {
|
||||
width: 172px;
|
||||
height: 92px;
|
||||
border-radius: 172px 172px 0 0;
|
||||
background: conic-gradient(from 180deg, #10b981 0 calc(var(--budget-ratio) * 1.8deg), #e2e8f0 calc(var(--budget-ratio) * 1.8deg) 180deg);
|
||||
-webkit-mask: radial-gradient(circle at 50% 100%, transparent 0 52px, #000 53px);
|
||||
mask: radial-gradient(circle at 50% 100%, transparent 0 52px, #000 53px);
|
||||
}
|
||||
|
||||
.budget-gauge-center {
|
||||
position: absolute;
|
||||
inset: auto 0 10px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
|
||||
.budget-gauge-center strong {
|
||||
color: #10b981;
|
||||
font-size: 24px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.budget-gauge-center span {
|
||||
margin-top: 4px;
|
||||
color: #64748b;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.budget-summary {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 8px;
|
||||
margin-top: 16px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.budget-summary span {
|
||||
display: block;
|
||||
color: #64748b;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.budget-summary strong {
|
||||
display: block;
|
||||
margin-top: 6px;
|
||||
color: #1e293b;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
@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;
|
||||
}
|
||||
}
|
||||
.metric:hover { transform: translateY(-2px); border-color: rgba(51,92,255,.22); box-shadow: 0 16px 42px rgba(16,24,40,.08); }
|
||||
.metric-top { display: flex; justify-content: space-between; gap: 10px; color: var(--muted); font-size: 12px; font-weight: 800; text-transform: uppercase; }
|
||||
.metric-top b { padding: 4px 8px; border-radius: 999px; background: var(--success-soft); color: var(--success); font-size: 12px; }
|
||||
.metric-top b.warn { background: var(--warning-soft); color: var(--warning); }
|
||||
.metric-top b.bad { background: var(--danger-soft); color: var(--danger); }
|
||||
.metric strong { display: block; margin-top: 16px; color: var(--ink); font-size: 30px; line-height: 1; font-variant-numeric: tabular-nums; }
|
||||
.metric small { display: block; margin-top: 10px; color: var(--muted); }
|
||||
.overview-grid { display: grid; grid-template-columns: minmax(0, 1.35fr) minmax(360px, .65fr); gap: 22px; }
|
||||
.spend-panel { padding: 20px; }
|
||||
.bar-chart { display: grid; gap: 14px; }
|
||||
.bar-row { display: grid; grid-template-columns: 94px minmax(0, 1fr) 70px; align-items: center; gap: 14px; font-weight: 700; }
|
||||
.bar-track { height: 36px; overflow: hidden; border-radius: 7px; background: #f2f4f7; }
|
||||
.bar-fill { height: 100%; border-radius: inherit; animation: grow 520ms var(--ease) both; }
|
||||
.bar-row strong { color: var(--ink); text-align: right; font-variant-numeric: tabular-nums; }
|
||||
.donut-layout { display: grid; grid-template-columns: 190px minmax(0, 1fr); align-items: center; gap: 20px; }
|
||||
.donut { width: 174px; aspect-ratio: 1; display: grid; place-items: center; border-radius: 50%; background: radial-gradient(circle,#fff 0 52%,transparent 53%), conic-gradient(#335cff 0 42%,#0e9384 42% 68%,#f79009 68% 86%,#d92d20 86% 100%); }
|
||||
.donut strong { display: block; color: var(--ink); font-size: 28px; text-align: center; }
|
||||
.donut span { color: var(--muted); font-size: 12px; }
|
||||
.legend { display: grid; gap: 10px; }
|
||||
.legend-row { display: grid; grid-template-columns: 12px 1fr auto; align-items: center; gap: 10px; padding-bottom: 10px; border-bottom: 1px solid var(--line); }
|
||||
.legend-row i { width: 12px; height: 12px; border-radius: 999px; }
|
||||
.legend-row strong { color: var(--ink); }
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user