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:
parent
12f7f04d82
commit
bb9273706e
|
|
@ -1,30 +1,27 @@
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import React, { useMemo } from 'react';
|
import React, { useMemo } from 'react';
|
||||||
import { Card, Statistic, Typography, Space } from 'antd';
|
import { Card, Typography, Space } from 'antd';
|
||||||
import { ArrowUpOutlined, ArrowDownOutlined } from '@ant-design/icons';
|
import { ArrowUpOutlined, ArrowDownOutlined, MinusOutlined } from '@ant-design/icons';
|
||||||
import { type ChartInstance } from '@/domain/entities/ChartInstance';
|
import { type ChartInstance } from '@/domain/entities/ChartInstance';
|
||||||
|
|
||||||
const { Text } = Typography;
|
const { Text, Title } = Typography;
|
||||||
|
|
||||||
export interface KPICardProps {
|
export interface KPICardProps {
|
||||||
chart: ChartInstance;
|
chart: ChartInstance;
|
||||||
data: Record<string, any>[];
|
data: Record<string, any>[];
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Check if a column name looks like a YoY field */
|
|
||||||
function isYoYField(name: string): boolean {
|
function isYoYField(name: string): boolean {
|
||||||
const n = name.toLowerCase();
|
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 {
|
function isMoMField(name: string): boolean {
|
||||||
const n = name.toLowerCase();
|
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 {
|
function aggregate(values: number[], method: string = 'sum'): number {
|
||||||
if (!values.length) return 0;
|
if (!values.length) return 0;
|
||||||
switch (method) {
|
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 }) => {
|
export const KPICard: React.FC<KPICardProps> = ({ chart, data }) => {
|
||||||
const { style, bindings } = chart;
|
const { style, bindings } = chart;
|
||||||
|
|
||||||
const valueBinding = bindings.find((b) => b.axis === 'value');
|
const valueBinding = bindings.find((b) => b.axis === 'value');
|
||||||
const labelBinding = bindings.find((b) => b.axis === 'label');
|
const labelBinding = bindings.find((b) => b.axis === 'label');
|
||||||
|
|
||||||
const { mainValue, label, yoyValue, yoyLabel, momValue, momLabel } = useMemo(() => {
|
const kpiData = useMemo(() => {
|
||||||
if (!data.length || !valueBinding) {
|
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 colName = valueBinding.columnName;
|
||||||
const nums = data
|
const nums = data.map((r) => Number(r[colName])).filter((v) => !isNaN(v));
|
||||||
.map((r) => Number(r[valueBinding.columnName]))
|
|
||||||
.filter((v) => !isNaN(v));
|
|
||||||
const val = aggregate(nums, valueBinding.aggregation ?? 'sum');
|
const val = aggregate(nums, valueBinding.aggregation ?? 'sum');
|
||||||
|
const metricName = colName;
|
||||||
|
|
||||||
// Label: use value column name as title (e.g. "投诉量")
|
// Group label: if label binding exists, show unique values count or first value
|
||||||
const lbl = valueBinding.columnName;
|
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 keys = Object.keys(data[0] ?? {});
|
||||||
const yoyKey = keys.find(isYoYField);
|
const yoyKey = keys.find(isYoYField);
|
||||||
const momKey = keys.find(isMoMField);
|
const momKey = keys.find(isMoMField);
|
||||||
|
|
||||||
let yoyVal: number | undefined;
|
let yoy: number | undefined;
|
||||||
let momVal: number | undefined;
|
let mom: number | undefined;
|
||||||
|
|
||||||
if (yoyKey) {
|
if (yoyKey) {
|
||||||
const yoyNums = data.map((r) => Number(r[yoyKey])).filter((v) => !isNaN(v));
|
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) {
|
if (momKey) {
|
||||||
const momNums = data.map((r) => Number(r[momKey])).filter((v) => !isNaN(v));
|
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 {
|
// Build sparkline data: try to group by a date/text column and sum the metric
|
||||||
mainValue: val,
|
let sparkData: number[] = [];
|
||||||
label: lbl,
|
const dateCol = keys.find((k) => {
|
||||||
yoyValue: yoyVal,
|
const lower = k.toLowerCase();
|
||||||
yoyLabel: yoyKey ?? '同比',
|
return lower.includes('月') || lower.includes('date') || lower.includes('month') || lower.includes('时间');
|
||||||
momValue: momVal,
|
});
|
||||||
momLabel: momKey ?? '环比',
|
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]);
|
}, [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 colors = style.colors ?? ['#1890ff'];
|
||||||
|
const primaryColor = colors[0] || '#1890ff';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card
|
<Card
|
||||||
style={{
|
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%',
|
height: '100%',
|
||||||
|
borderRadius: 8,
|
||||||
|
border: '1px solid #f0f0f0',
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
flexDirection: 'column',
|
flexDirection: 'column',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
}}
|
}}
|
||||||
styles={{ body: { padding: '20px 24px' } }}
|
styles={{ body: { padding: '20px 24px' } }}
|
||||||
>
|
>
|
||||||
<Statistic
|
{/* Metric name + optional group label */}
|
||||||
title={<span style={{ fontSize: 14, color: '#8c8c8c' }}>{label}</span>}
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 4 }}>
|
||||||
value={mainValue}
|
<Text style={{ fontSize: 13, color: '#8c8c8c' }}>{kpiData.metricName}</Text>
|
||||||
precision={Number.isInteger(mainValue) ? 0 : 1}
|
{kpiData.groupLabel && (
|
||||||
valueStyle={{
|
<Text style={{ fontSize: 11, color: '#bfbfbf', background: '#fafafa', padding: '1px 6px', borderRadius: 3 }}>
|
||||||
fontSize: 32,
|
{kpiData.groupLabel}
|
||||||
fontWeight: 700,
|
</Text>
|
||||||
color: colors[0] || '#1890ff',
|
)}
|
||||||
}}
|
</div>
|
||||||
/>
|
|
||||||
|
|
||||||
{(yoyValue !== undefined || momValue !== undefined) && (
|
{/* Main value */}
|
||||||
<Space size={16} style={{ marginTop: 12 }}>
|
<Title
|
||||||
{yoyValue !== undefined && !isNaN(yoyValue) && (
|
level={2}
|
||||||
<Text style={{ color: yoyValue >= 0 ? '#52c41a' : '#ff4d4f', fontSize: 13 }}>
|
style={{
|
||||||
{yoyLabel}{' '}
|
margin: '4px 0 8px',
|
||||||
{yoyValue >= 0 ? <ArrowUpOutlined /> : <ArrowDownOutlined />}{' '}
|
color: primaryColor,
|
||||||
{Math.abs(yoyValue).toFixed(1)}%
|
fontSize: 36,
|
||||||
</Text>
|
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) && (
|
{kpiData.mom !== undefined && !isNaN(kpiData.mom) && (
|
||||||
<Text style={{ color: momValue >= 0 ? '#52c41a' : '#ff4d4f', fontSize: 13 }}>
|
<TrendBadge value={kpiData.mom} label="环比" />
|
||||||
{momLabel}{' '}
|
|
||||||
{momValue >= 0 ? <ArrowUpOutlined /> : <ArrowDownOutlined />}{' '}
|
|
||||||
{Math.abs(momValue).toFixed(1)}%
|
|
||||||
</Text>
|
|
||||||
)}
|
)}
|
||||||
</Space>
|
</Space>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Sparkline */}
|
||||||
|
{kpiData.sparkData.length >= 2 && (
|
||||||
|
<Sparkline data={kpiData.sparkData} color={primaryColor} />
|
||||||
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue