feat: redesign KPI card to enterprise standard

- Metric name (column name) as title
- Large formatted number with 万/亿 abbreviation
- Auto-detect 同比/环比 columns, show as colored trend badges
- Group label showing dimension info
- SVG sparkline for trend visualization
- Clean layout matching Tableau/Power BI KPI card patterns

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
hailin 2026-04-05 02:42:25 -07:00
parent 12f7f04d82
commit bb9273706e
1 changed files with 142 additions and 61 deletions

View File

@ -1,30 +1,27 @@
'use client';
import React, { useMemo } from 'react';
import { Card, Statistic, Typography, Space } from 'antd';
import { ArrowUpOutlined, ArrowDownOutlined } from '@ant-design/icons';
import { Card, Typography, Space } from 'antd';
import { ArrowUpOutlined, ArrowDownOutlined, MinusOutlined } from '@ant-design/icons';
import { type ChartInstance } from '@/domain/entities/ChartInstance';
const { Text } = Typography;
const { Text, Title } = Typography;
export interface KPICardProps {
chart: ChartInstance;
data: Record<string, any>[];
}
/** Check if a column name looks like a YoY field */
function isYoYField(name: string): boolean {
const n = name.toLowerCase();
return n.includes('yoy') || n.includes('同比') || n.includes('year-over-year');
return n.includes('yoy') || n.includes('同比') || n.includes('year-over-year') || n.includes('year_over_year');
}
/** Check if a column name looks like a MoM field */
function isMoMField(name: string): boolean {
const n = name.toLowerCase();
return n.includes('mom') || n.includes('环比') || n.includes('month-over-month');
return n.includes('mom') || n.includes('环比') || n.includes('month-over-month') || n.includes('month_over_month');
}
/** Aggregate numeric values */
function aggregate(values: number[], method: string = 'sum'): number {
if (!values.length) return 0;
switch (method) {
@ -37,102 +34,186 @@ function aggregate(values: number[], method: string = 'sum'): number {
}
}
function formatNumber(val: number): string {
if (Math.abs(val) >= 1e8) return (val / 1e8).toFixed(2) + '亿';
if (Math.abs(val) >= 1e4) return (val / 1e4).toFixed(1) + '万';
if (Number.isInteger(val)) return val.toLocaleString('zh-CN');
return val.toLocaleString('zh-CN', { maximumFractionDigits: 2 });
}
function TrendBadge({ value, label }: { value: number; label: string }) {
const isUp = value > 0;
const isZero = value === 0;
const color = isZero ? '#8c8c8c' : isUp ? '#52c41a' : '#ff4d4f';
const bgColor = isZero ? '#f5f5f5' : isUp ? '#f6ffed' : '#fff2f0';
const icon = isZero ? <MinusOutlined /> : isUp ? <ArrowUpOutlined /> : <ArrowDownOutlined />;
return (
<div style={{
display: 'inline-flex',
alignItems: 'center',
gap: 4,
padding: '2px 8px',
borderRadius: 4,
background: bgColor,
fontSize: 12,
color,
fontWeight: 500,
}}>
{icon}
<span>{Math.abs(value).toFixed(1)}%</span>
<span style={{ color: '#8c8c8c', fontWeight: 400 }}>{label}</span>
</div>
);
}
/** Minimal sparkline using SVG */
function Sparkline({ data, color = '#1890ff' }: { data: number[]; color?: string }) {
if (data.length < 2) return null;
const min = Math.min(...data);
const max = Math.max(...data);
const range = max - min || 1;
const w = 120;
const h = 24;
const points = data.map((v, i) => {
const x = (i / (data.length - 1)) * w;
const y = h - ((v - min) / range) * (h - 4) - 2;
return `${x},${y}`;
}).join(' ');
return (
<svg width={w} height={h} style={{ display: 'block', marginTop: 8 }}>
<polyline
points={points}
fill="none"
stroke={color}
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}
export const KPICard: React.FC<KPICardProps> = ({ chart, data }) => {
const { style, bindings } = chart;
const valueBinding = bindings.find((b) => b.axis === 'value');
const labelBinding = bindings.find((b) => b.axis === 'label');
const { mainValue, label, yoyValue, yoyLabel, momValue, momLabel } = useMemo(() => {
const kpiData = useMemo(() => {
if (!data.length || !valueBinding) {
return { mainValue: 0, label: '暂无数据', yoyValue: undefined, yoyLabel: '', momValue: undefined, momLabel: '' };
return { mainValue: 0, metricName: '暂无数据', yoy: undefined, mom: undefined, sparkData: [], groupLabel: undefined };
}
// Aggregate the value column across all rows
const nums = data
.map((r) => Number(r[valueBinding.columnName]))
.filter((v) => !isNaN(v));
const colName = valueBinding.columnName;
const nums = data.map((r) => Number(r[colName])).filter((v) => !isNaN(v));
const val = aggregate(nums, valueBinding.aggregation ?? 'sum');
const metricName = colName;
// Label: use value column name as title (e.g. "投诉量")
const lbl = valueBinding.columnName;
// Group label: if label binding exists, show unique values count or first value
let groupLabel: string | undefined;
if (labelBinding) {
const uniqueValues = [...new Set(data.map((r) => String(r[labelBinding.columnName] ?? '')))];
if (uniqueValues.length === 1) {
groupLabel = uniqueValues[0];
} else {
groupLabel = `${uniqueValues.length}${labelBinding.columnName}`;
}
}
// Auto-detect YoY and MoM columns from data keys
// Auto-detect YoY/MoM columns
const keys = Object.keys(data[0] ?? {});
const yoyKey = keys.find(isYoYField);
const momKey = keys.find(isMoMField);
let yoyVal: number | undefined;
let momVal: number | undefined;
let yoy: number | undefined;
let mom: number | undefined;
if (yoyKey) {
const yoyNums = data.map((r) => Number(r[yoyKey])).filter((v) => !isNaN(v));
yoyVal = yoyNums.length ? yoyNums.reduce((a, b) => a + b, 0) / yoyNums.length : undefined;
yoy = yoyNums.length ? yoyNums.reduce((a, b) => a + b, 0) / yoyNums.length : undefined;
}
if (momKey) {
const momNums = data.map((r) => Number(r[momKey])).filter((v) => !isNaN(v));
momVal = momNums.length ? momNums.reduce((a, b) => a + b, 0) / momNums.length : undefined;
mom = momNums.length ? momNums.reduce((a, b) => a + b, 0) / momNums.length : undefined;
}
return {
mainValue: val,
label: lbl,
yoyValue: yoyVal,
yoyLabel: yoyKey ?? '同比',
momValue: momVal,
momLabel: momKey ?? '环比',
};
// Build sparkline data: try to group by a date/text column and sum the metric
let sparkData: number[] = [];
const dateCol = keys.find((k) => {
const lower = k.toLowerCase();
return lower.includes('月') || lower.includes('date') || lower.includes('month') || lower.includes('时间');
});
if (dateCol) {
const groups = new Map<string, number>();
for (const row of data) {
const key = String(row[dateCol] ?? '');
groups.set(key, (groups.get(key) ?? 0) + (Number(row[colName]) || 0));
}
sparkData = [...groups.values()];
} else if (nums.length <= 30) {
sparkData = nums;
}
return { mainValue: val, metricName, yoy, mom, sparkData, groupLabel };
}, [data, valueBinding, labelBinding]);
const background = style.background ?? { color: '#fff', opacity: 1, borderRadius: 8 };
const border = style.border ?? { visible: false, color: '#ddd', width: 1, style: 'solid' as const };
const colors = style.colors ?? ['#1890ff'];
const primaryColor = colors[0] || '#1890ff';
return (
<Card
style={{
background: background.color,
opacity: background.opacity,
borderRadius: background.borderRadius ?? 8,
border: border.visible
? `${border.width}px ${border.style} ${border.color}`
: '1px solid #f0f0f0',
height: '100%',
borderRadius: 8,
border: '1px solid #f0f0f0',
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
}}
styles={{ body: { padding: '20px 24px' } }}
>
<Statistic
title={<span style={{ fontSize: 14, color: '#8c8c8c' }}>{label}</span>}
value={mainValue}
precision={Number.isInteger(mainValue) ? 0 : 1}
valueStyle={{
fontSize: 32,
fontWeight: 700,
color: colors[0] || '#1890ff',
}}
/>
{/* Metric name + optional group label */}
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 4 }}>
<Text style={{ fontSize: 13, color: '#8c8c8c' }}>{kpiData.metricName}</Text>
{kpiData.groupLabel && (
<Text style={{ fontSize: 11, color: '#bfbfbf', background: '#fafafa', padding: '1px 6px', borderRadius: 3 }}>
{kpiData.groupLabel}
</Text>
)}
</div>
{(yoyValue !== undefined || momValue !== undefined) && (
<Space size={16} style={{ marginTop: 12 }}>
{yoyValue !== undefined && !isNaN(yoyValue) && (
<Text style={{ color: yoyValue >= 0 ? '#52c41a' : '#ff4d4f', fontSize: 13 }}>
{yoyLabel}{' '}
{yoyValue >= 0 ? <ArrowUpOutlined /> : <ArrowDownOutlined />}{' '}
{Math.abs(yoyValue).toFixed(1)}%
</Text>
{/* Main value */}
<Title
level={2}
style={{
margin: '4px 0 8px',
color: primaryColor,
fontSize: 36,
fontWeight: 700,
lineHeight: 1.1,
}}
>
{formatNumber(kpiData.mainValue)}
</Title>
{/* Trend badges */}
{(kpiData.yoy !== undefined || kpiData.mom !== undefined) && (
<Space size={8} style={{ marginBottom: 4 }}>
{kpiData.yoy !== undefined && !isNaN(kpiData.yoy) && (
<TrendBadge value={kpiData.yoy} label="同比" />
)}
{momValue !== undefined && !isNaN(momValue) && (
<Text style={{ color: momValue >= 0 ? '#52c41a' : '#ff4d4f', fontSize: 13 }}>
{momLabel}{' '}
{momValue >= 0 ? <ArrowUpOutlined /> : <ArrowDownOutlined />}{' '}
{Math.abs(momValue).toFixed(1)}%
</Text>
{kpiData.mom !== undefined && !isNaN(kpiData.mom) && (
<TrendBadge value={kpiData.mom} label="环比" />
)}
</Space>
)}
{/* Sparkline */}
{kpiData.sparkData.length >= 2 && (
<Sparkline data={kpiData.sparkData} color={primaryColor} />
)}
</Card>
);
};