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:
1868
package-lock.json
generated
Normal file
1868
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -10,9 +10,14 @@
|
||||
"preview": "vite preview --host 127.0.0.1"
|
||||
},
|
||||
"dependencies": {
|
||||
"@primevue/themes": "^4.5.4",
|
||||
"@vitejs/plugin-vue": "^5.2.4",
|
||||
"@vueuse/motion": "^3.0.3",
|
||||
"chart.js": "^4.5.1",
|
||||
"primeicons": "^7.0.0",
|
||||
"primevue": "^4.5.5",
|
||||
"vite": "^5.4.19",
|
||||
"vue": "^3.5.13"
|
||||
"vue": "^3.5.13",
|
||||
"vue-chartjs": "^5.3.3"
|
||||
}
|
||||
}
|
||||
|
||||
201
src/components/charts/TrendChart.vue
Normal file
201
src/components/charts/TrendChart.vue
Normal file
@@ -0,0 +1,201 @@
|
||||
<template>
|
||||
<div class="trend-chart">
|
||||
<div class="chart-legend">
|
||||
<span><i style="background:#10b981"></i>申请量(单)</span>
|
||||
<span><i style="background:#3b82f6"></i>审批完成量(单)</span>
|
||||
<span><i style="background:#8b5cf6"></i>平均审批时长(小时)</span>
|
||||
</div>
|
||||
<div class="chart-body">
|
||||
<Bar :data="chartData" :options="chartOptions" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { Bar } from 'vue-chartjs'
|
||||
import {
|
||||
Chart as ChartJS,
|
||||
CategoryScale,
|
||||
LinearScale,
|
||||
BarElement,
|
||||
PointElement,
|
||||
LineElement,
|
||||
Filler,
|
||||
Tooltip,
|
||||
Legend
|
||||
} from 'chart.js'
|
||||
|
||||
ChartJS.register(CategoryScale, LinearScale, BarElement, PointElement, LineElement, Filler, Tooltip, Legend)
|
||||
|
||||
const props = defineProps({
|
||||
labels: { type: Array, required: true },
|
||||
applications: { type: Array, required: true },
|
||||
approved: { type: Array, required: true },
|
||||
avgHours: { type: Array, required: true }
|
||||
})
|
||||
|
||||
const chartData = computed(() => ({
|
||||
labels: props.labels,
|
||||
datasets: [
|
||||
{
|
||||
label: '申请量(单)',
|
||||
data: props.applications,
|
||||
backgroundColor: '#10b981',
|
||||
borderRadius: 4,
|
||||
barPercentage: 0.6,
|
||||
categoryPercentage: 0.5,
|
||||
order: 2
|
||||
},
|
||||
{
|
||||
label: '审批完成量(单)',
|
||||
data: props.approved,
|
||||
backgroundColor: '#3b82f6',
|
||||
borderRadius: 4,
|
||||
barPercentage: 0.6,
|
||||
categoryPercentage: 0.5,
|
||||
order: 2
|
||||
},
|
||||
{
|
||||
label: '平均审批时长(小时)',
|
||||
data: props.avgHours,
|
||||
borderColor: '#8b5cf6',
|
||||
backgroundColor: 'transparent',
|
||||
borderWidth: 2,
|
||||
pointBackgroundColor: '#ffffff',
|
||||
pointBorderColor: '#8b5cf6',
|
||||
pointBorderWidth: 2,
|
||||
pointRadius: 3,
|
||||
pointHoverRadius: 5,
|
||||
type: 'line',
|
||||
yAxisID: 'y1',
|
||||
order: 1
|
||||
}
|
||||
]
|
||||
}))
|
||||
|
||||
const chartOptions = {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
interaction: {
|
||||
mode: 'index',
|
||||
intersect: false
|
||||
},
|
||||
events: ['click', 'mousemove', 'mouseout'],
|
||||
plugins: {
|
||||
legend: {
|
||||
display: false
|
||||
},
|
||||
tooltip: {
|
||||
enabled: false,
|
||||
external: externalTooltip
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
grid: { display: false },
|
||||
ticks: {
|
||||
color: '#64748b',
|
||||
font: { size: 11 }
|
||||
}
|
||||
},
|
||||
y: {
|
||||
beginAtZero: true,
|
||||
max: 250,
|
||||
grid: { color: '#f1f5f9' },
|
||||
ticks: {
|
||||
color: '#64748b',
|
||||
font: { size: 11 },
|
||||
stepSize: 50
|
||||
}
|
||||
},
|
||||
y1: {
|
||||
position: 'right',
|
||||
beginAtZero: true,
|
||||
max: 15,
|
||||
grid: { display: false },
|
||||
ticks: {
|
||||
color: '#64748b',
|
||||
font: { size: 11 },
|
||||
stepSize: 3
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function externalTooltip(context) {
|
||||
const { chart, tooltip } = context
|
||||
|
||||
let el = chart.canvas.parentNode.querySelector('.chart-tooltip')
|
||||
if (!el) {
|
||||
el = document.createElement('div')
|
||||
el.classList.add('chart-tooltip')
|
||||
el.style.cssText =
|
||||
'position:absolute;background:#fff;border:1px solid #e2e8f0;border-radius:8px;padding:12px 16px;pointer-events:none;transition:opacity .15s,transform .15s;font-family:Inter,system-ui,sans-serif;font-size:13px;box-shadow:0 4px 12px rgba(0,0,0,.08);z-index:10;opacity:0;transform:translateY(4px)'
|
||||
chart.canvas.parentNode.appendChild(el)
|
||||
}
|
||||
|
||||
if (tooltip.opacity === 0) {
|
||||
el.style.opacity = '0'
|
||||
return
|
||||
}
|
||||
|
||||
if (tooltip.body) {
|
||||
const titleLines = tooltip.title || []
|
||||
const bodyLines = tooltip.body.map((b) => b.lines)
|
||||
|
||||
const colors = tooltip.labelColors
|
||||
const dot = (color, text) =>
|
||||
`<div style="display:flex;align-items:center;gap:6px;margin-top:4px"><span style="width:8px;height:8px;border-radius:50%;background:${color};flex-shrink:0"></span><span style="color:#64748b">${text}</span></div>`
|
||||
|
||||
el.innerHTML =
|
||||
`<div style="font-weight:600;color:#1e293b;margin-bottom:4px">${titleLines.join('')}</div>` +
|
||||
bodyLines
|
||||
.map((lines, i) =>
|
||||
lines.map((line) => dot(colors[i]?.backgroundColor || colors[i]?.borderColor || '#999', line))
|
||||
)
|
||||
.join('')
|
||||
}
|
||||
|
||||
const { offsetLeft, offsetTop } = chart.canvas
|
||||
const left = offsetLeft + tooltip.caretX
|
||||
const top = offsetTop + tooltip.caretY
|
||||
|
||||
el.style.opacity = '1'
|
||||
el.style.transform = 'translateY(0)'
|
||||
el.style.left = `${left}px`
|
||||
el.style.top = `${top - el.offsetHeight - 12}px`
|
||||
el.style.transform = `translate(-50%, 0)`
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.trend-chart {
|
||||
height: 280px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.chart-legend {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
color: #475569;
|
||||
font-size: 12px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.chart-legend i {
|
||||
display: inline-block;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 2px;
|
||||
margin-right: 4px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.chart-body {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -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