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';
|
'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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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],
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue