From bb9273706ea06831e46286545d912b46e654e387 Mon Sep 17 00:00:00 2001 From: hailin Date: Sun, 5 Apr 2026 02:42:25 -0700 Subject: [PATCH] feat: redesign KPI card to enterprise standard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- .../frameworks/components/charts/KPICard.tsx | 203 ++++++++++++------ 1 file changed, 142 insertions(+), 61 deletions(-) diff --git a/frontend/src/frameworks/components/charts/KPICard.tsx b/frontend/src/frameworks/components/charts/KPICard.tsx index 109bf68..8ee23c5 100644 --- a/frontend/src/frameworks/components/charts/KPICard.tsx +++ b/frontend/src/frameworks/components/charts/KPICard.tsx @@ -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[]; } -/** 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 ? : isUp ? : ; + + return ( +
+ {icon} + {Math.abs(value).toFixed(1)}% + {label} +
+ ); +} + +/** 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 ( + + + + ); +} + export const KPICard: React.FC = ({ 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(); + 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 ( - {label}} - value={mainValue} - precision={Number.isInteger(mainValue) ? 0 : 1} - valueStyle={{ - fontSize: 32, - fontWeight: 700, - color: colors[0] || '#1890ff', - }} - /> + {/* Metric name + optional group label */} +
+ {kpiData.metricName} + {kpiData.groupLabel && ( + + {kpiData.groupLabel} + + )} +
- {(yoyValue !== undefined || momValue !== undefined) && ( - - {yoyValue !== undefined && !isNaN(yoyValue) && ( - = 0 ? '#52c41a' : '#ff4d4f', fontSize: 13 }}> - {yoyLabel}{' '} - {yoyValue >= 0 ? : }{' '} - {Math.abs(yoyValue).toFixed(1)}% - + {/* Main value */} + + {formatNumber(kpiData.mainValue)} + + + {/* Trend badges */} + {(kpiData.yoy !== undefined || kpiData.mom !== undefined) && ( + + {kpiData.yoy !== undefined && !isNaN(kpiData.yoy) && ( + )} - {momValue !== undefined && !isNaN(momValue) && ( - = 0 ? '#52c41a' : '#ff4d4f', fontSize: 13 }}> - {momLabel}{' '} - {momValue >= 0 ? : }{' '} - {Math.abs(momValue).toFixed(1)}% - + {kpiData.mom !== undefined && !isNaN(kpiData.mom) && ( + )} )} + + {/* Sparkline */} + {kpiData.sparkData.length >= 2 && ( + + )}
); };