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:
parent
e8f2554aa8
commit
b07e8bb3d5
|
|
@ -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)}%
|
||||
</Text>
|
||||
)}
|
||||
{mom !== undefined && !isNaN(mom) && (
|
||||
<Text
|
||||
style={{
|
||||
color: mom >= 0 ? '#3f8600' : '#cf1322',
|
||||
fontSize: 14,
|
||||
}}
|
||||
>
|
||||
MoM{' '}
|
||||
{mom >= 0 ? <ArrowUpOutlined /> : <ArrowDownOutlined />}{' '}
|
||||
{Math.abs(mom).toFixed(2)}%
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
{(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>
|
||||
)}
|
||||
{momValue !== undefined && !isNaN(momValue) && (
|
||||
<Text style={{ color: momValue >= 0 ? '#52c41a' : '#ff4d4f', fontSize: 13 }}>
|
||||
{momLabel}{' '}
|
||||
{momValue >= 0 ? <ArrowUpOutlined /> : <ArrowDownOutlined />}{' '}
|
||||
{Math.abs(momValue).toFixed(1)}%
|
||||
</Text>
|
||||
)}
|
||||
</Space>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
|
|
|
|||
Loading…
Reference in New Issue