diff --git a/frontend/src/frameworks/components/charts/KPICard.tsx b/frontend/src/frameworks/components/charts/KPICard.tsx index 7f66972..08bdaab 100644 --- a/frontend/src/frameworks/components/charts/KPICard.tsx +++ b/frontend/src/frameworks/components/charts/KPICard.tsx @@ -1,7 +1,7 @@ 'use client'; import React, { useMemo } from 'react'; -import { Card, Statistic, Typography } from 'antd'; +import { Card, Statistic, Typography, Space } from 'antd'; import { ArrowUpOutlined, ArrowDownOutlined } from '@ant-design/icons'; import { type ChartInstance } from '@/domain/entities/ChartInstance'; @@ -12,108 +12,129 @@ export interface KPICardProps { 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'); +} + +/** 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'); +} + +/** Aggregate numeric values */ +function aggregate(values: number[], method: string = 'sum'): number { + if (!values.length) return 0; + switch (method) { + case 'avg': return values.reduce((a, b) => a + b, 0) / values.length; + case 'count': return values.length; + case 'max': return Math.max(...values); + case 'min': return Math.min(...values); + case 'sum': + default: return values.reduce((a, b) => a + b, 0); + } +} + 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, yoy, mom } = useMemo(() => { + const { mainValue, label, yoyValue, yoyLabel, momValue, momLabel } = useMemo(() => { if (!data.length || !valueBinding) { - return { mainValue: 0, label: '', yoy: undefined, mom: undefined }; + return { mainValue: 0, label: '暂无数据', yoyValue: undefined, yoyLabel: '', momValue: undefined, momLabel: '' }; } - const row = data[0]; - const val = Number(row[valueBinding.columnName]) || 0; - const lbl = labelBinding ? String(row[labelBinding.columnName] ?? '') : ''; + // Aggregate the value column across all rows + const nums = data + .map((r) => Number(r[valueBinding.columnName])) + .filter((v) => !isNaN(v)); + const val = aggregate(nums, valueBinding.aggregation ?? 'sum'); - // Look for YoY / MoM fields by convention - const yoyVal = - row['yoy'] !== undefined - ? Number(row['yoy']) - : row['YoY'] !== undefined - ? Number(row['YoY']) - : undefined; - const momVal = - row['mom'] !== undefined - ? Number(row['mom']) - : row['MoM'] !== undefined - ? Number(row['MoM']) - : undefined; + // Label: use first row's label value or column name + const lbl = labelBinding + ? String(data[0][labelBinding.columnName] ?? valueBinding.columnName) + : valueBinding.columnName; - return { mainValue: val, label: lbl, yoy: yoyVal, mom: momVal }; + // Auto-detect YoY and MoM columns from data keys + const keys = Object.keys(data[0] ?? {}); + const yoyKey = keys.find(isYoYField); + const momKey = keys.find(isMoMField); + + let yoyVal: number | undefined; + let momVal: 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; + } + 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; + } + + return { + mainValue: val, + label: lbl, + yoyValue: yoyVal, + yoyLabel: yoyKey ?? '同比', + momValue: momVal, + momLabel: momKey ?? '环比', + }; }, [data, valueBinding, labelBinding]); - const background = style.background ?? { color: '#fff', opacity: 1, borderRadius: 4 }; + 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 titleStyle = style.title ?? { text: '', visible: true, fontSize: 16, fontWeight: 'bold' as const, color: '#333', align: 'left' as const }; + const colors = style.colors ?? ['#1890ff']; return ( - {titleStyle.visible && ( - - {titleStyle.text} - - )} - {label}} value={mainValue} + precision={Number.isInteger(mainValue) ? 0 : 1} valueStyle={{ - fontSize: 36, - fontWeight: 'bold', - color: style.colors[0] || '#1890ff', + fontSize: 32, + fontWeight: 700, + color: colors[0] || '#1890ff', }} /> -
- {yoy !== undefined && !isNaN(yoy) && ( - = 0 ? '#3f8600' : '#cf1322', - fontSize: 14, - }} - > - YoY{' '} - {yoy >= 0 ? : }{' '} - {Math.abs(yoy).toFixed(2)}% - - )} - {mom !== undefined && !isNaN(mom) && ( - = 0 ? '#3f8600' : '#cf1322', - fontSize: 14, - }} - > - MoM{' '} - {mom >= 0 ? : }{' '} - {Math.abs(mom).toFixed(2)}% - - )} -
+ {(yoyValue !== undefined || momValue !== undefined) && ( + + {yoyValue !== undefined && !isNaN(yoyValue) && ( + = 0 ? '#52c41a' : '#ff4d4f', fontSize: 13 }}> + {yoyLabel}{' '} + {yoyValue >= 0 ? : }{' '} + {Math.abs(yoyValue).toFixed(1)}% + + )} + {momValue !== undefined && !isNaN(momValue) && ( + = 0 ? '#52c41a' : '#ff4d4f', fontSize: 13 }}> + {momLabel}{' '} + {momValue >= 0 ? : }{' '} + {Math.abs(momValue).toFixed(1)}% + + )} + + )}
); }; diff --git a/frontend/src/frameworks/components/dataImport/DropZone.tsx b/frontend/src/frameworks/components/dataImport/DropZone.tsx index 2e94cf2..2d5b989 100644 --- a/frontend/src/frameworks/components/dataImport/DropZone.tsx +++ b/frontend/src/frameworks/components/dataImport/DropZone.tsx @@ -23,8 +23,10 @@ export default function DropZone({ compact = false, onImportSuccess }: DropZoneP const result = await handleImport(file); message.success(`${file.name} 导入成功`); onImportSuccess?.(result); - } catch { - message.error('文件导入失败,请检查文件格式'); + } catch (err: any) { + const msg = err?.message ?? String(err); + console.error('Import error:', msg, err); + message.error(`导入失败: ${msg}`); } }, [handleImport, onImportSuccess],