fix: show detailed import errors + improve KPICard

- DropZone: log actual error message instead of generic text
- KPICard: aggregate all rows, auto-detect 同比/环比 columns,
  show proper formatting

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
hailin 2026-04-05 02:16:53 -07:00
parent e8f2554aa8
commit b07e8bb3d5
2 changed files with 94 additions and 71 deletions

View File

@ -1,7 +1,7 @@
'use client'; 'use client';
import React, { useMemo } from 'react'; 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 { ArrowUpOutlined, ArrowDownOutlined } from '@ant-design/icons';
import { type ChartInstance } from '@/domain/entities/ChartInstance'; import { type ChartInstance } from '@/domain/entities/ChartInstance';
@ -12,108 +12,129 @@ export interface KPICardProps {
data: Record<string, any>[]; 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');
}
/** 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<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, yoy, mom } = useMemo(() => { const { mainValue, label, yoyValue, yoyLabel, momValue, momLabel } = useMemo(() => {
if (!data.length || !valueBinding) { 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]; // Aggregate the value column across all rows
const val = Number(row[valueBinding.columnName]) || 0; const nums = data
const lbl = labelBinding ? String(row[labelBinding.columnName] ?? '') : ''; .map((r) => Number(r[valueBinding.columnName]))
.filter((v) => !isNaN(v));
const val = aggregate(nums, valueBinding.aggregation ?? 'sum');
// Look for YoY / MoM fields by convention // Label: use first row's label value or column name
const yoyVal = const lbl = labelBinding
row['yoy'] !== undefined ? String(data[0][labelBinding.columnName] ?? valueBinding.columnName)
? Number(row['yoy']) : valueBinding.columnName;
: row['YoY'] !== undefined
? Number(row['YoY'])
: undefined;
const momVal =
row['mom'] !== undefined
? Number(row['mom'])
: row['MoM'] !== undefined
? Number(row['MoM'])
: undefined;
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]); }, [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 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 ( return (
<Card <Card
style={{ style={{
background: background.color, background: background.color,
opacity: background.opacity, opacity: background.opacity,
borderRadius: background.borderRadius, borderRadius: background.borderRadius ?? 8,
border: border.visible border: border.visible
? `${border.width}px ${border.style} ${border.color}` ? `${border.width}px ${border.style} ${border.color}`
: 'none', : '1px solid #f0f0f0',
height: '100%', height: '100%',
display: 'flex', display: 'flex',
flexDirection: 'column', flexDirection: 'column',
justifyContent: 'center', justifyContent: 'center',
}} }}
styles={{ body: { textAlign: titleStyle.align } }} styles={{ body: { padding: '20px 24px' } }}
> >
{titleStyle.visible && (
<Text
style={{
fontSize: titleStyle.fontSize,
fontWeight: titleStyle.fontWeight,
color: titleStyle.color,
display: 'block',
marginBottom: 8,
}}
>
{titleStyle.text}
</Text>
)}
<Statistic <Statistic
title={label || undefined} title={<span style={{ fontSize: 14, color: '#8c8c8c' }}>{label}</span>}
value={mainValue} value={mainValue}
precision={Number.isInteger(mainValue) ? 0 : 1}
valueStyle={{ valueStyle={{
fontSize: 36, fontSize: 32,
fontWeight: 'bold', fontWeight: 700,
color: style.colors[0] || '#1890ff', color: colors[0] || '#1890ff',
}} }}
/> />
<div style={{ marginTop: 12, display: 'flex', gap: 16, justifyContent: 'center' }}> {(yoyValue !== undefined || momValue !== undefined) && (
{yoy !== undefined && !isNaN(yoy) && ( <Space size={16} style={{ marginTop: 12 }}>
<Text {yoyValue !== undefined && !isNaN(yoyValue) && (
style={{ <Text style={{ color: yoyValue >= 0 ? '#52c41a' : '#ff4d4f', fontSize: 13 }}>
color: yoy >= 0 ? '#3f8600' : '#cf1322', {yoyLabel}{' '}
fontSize: 14, {yoyValue >= 0 ? <ArrowUpOutlined /> : <ArrowDownOutlined />}{' '}
}} {Math.abs(yoyValue).toFixed(1)}%
> </Text>
YoY{' '} )}
{yoy >= 0 ? <ArrowUpOutlined /> : <ArrowDownOutlined />}{' '} {momValue !== undefined && !isNaN(momValue) && (
{Math.abs(yoy).toFixed(2)}% <Text style={{ color: momValue >= 0 ? '#52c41a' : '#ff4d4f', fontSize: 13 }}>
</Text> {momLabel}{' '}
)} {momValue >= 0 ? <ArrowUpOutlined /> : <ArrowDownOutlined />}{' '}
{mom !== undefined && !isNaN(mom) && ( {Math.abs(momValue).toFixed(1)}%
<Text </Text>
style={{ )}
color: mom >= 0 ? '#3f8600' : '#cf1322', </Space>
fontSize: 14, )}
}}
>
MoM{' '}
{mom >= 0 ? <ArrowUpOutlined /> : <ArrowDownOutlined />}{' '}
{Math.abs(mom).toFixed(2)}%
</Text>
)}
</div>
</Card> </Card>
); );
}; };

View File

@ -23,8 +23,10 @@ export default function DropZone({ compact = false, onImportSuccess }: DropZoneP
const result = await handleImport(file); const result = await handleImport(file);
message.success(`${file.name} 导入成功`); message.success(`${file.name} 导入成功`);
onImportSuccess?.(result); onImportSuccess?.(result);
} catch { } catch (err: any) {
message.error('文件导入失败,请检查文件格式'); const msg = err?.message ?? String(err);
console.error('Import error:', msg, err);
message.error(`导入失败: ${msg}`);
} }
}, },
[handleImport, onImportSuccess], [handleImport, onImportSuccess],