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';
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<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 }) => {
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 (
<Card
style={{
background: background.color,
opacity: background.opacity,
borderRadius: background.borderRadius,
borderRadius: background.borderRadius ?? 8,
border: border.visible
? `${border.width}px ${border.style} ${border.color}`
: 'none',
: '1px solid #f0f0f0',
height: '100%',
display: 'flex',
flexDirection: 'column',
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
title={label || undefined}
title={<span style={{ fontSize: 14, color: '#8c8c8c' }}>{label}</span>}
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',
}}
/>
<div style={{ marginTop: 12, display: 'flex', gap: 16, justifyContent: 'center' }}>
{yoy !== undefined && !isNaN(yoy) && (
<Text
style={{
color: yoy >= 0 ? '#3f8600' : '#cf1322',
fontSize: 14,
}}
>
YoY{' '}
{yoy >= 0 ? <ArrowUpOutlined /> : <ArrowDownOutlined />}{' '}
{Math.abs(yoy).toFixed(2)}%
{(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>
)}
{mom !== undefined && !isNaN(mom) && (
<Text
style={{
color: mom >= 0 ? '#3f8600' : '#cf1322',
fontSize: 14,
}}
>
MoM{' '}
{mom >= 0 ? <ArrowUpOutlined /> : <ArrowDownOutlined />}{' '}
{Math.abs(mom).toFixed(2)}%
{momValue !== undefined && !isNaN(momValue) && (
<Text style={{ color: momValue >= 0 ? '#52c41a' : '#ff4d4f', fontSize: 13 }}>
{momLabel}{' '}
{momValue >= 0 ? <ArrowUpOutlined /> : <ArrowDownOutlined />}{' '}
{Math.abs(momValue).toFixed(1)}%
</Text>
)}
</div>
</Space>
)}
</Card>
);
};

View File

@ -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],