feat(web): Dashboard 图表增强 — bar/pie/funnel/line + 并行加载

- 新增基于 DashboardWidget 声明的图表渲染
- 支持 stat_card/bar_chart/pie_chart/funnel_chart/line_chart 五种类型
- 使用 @ant-design/charts 渲染 Column/Pie/Funnel/Line 图表
- Widget 数据通过 Promise.all 并行加载
- 保留原有基于实体的分布统计作为兜底
- 安装 @ant-design/charts 依赖
This commit is contained in:
iven
2026-04-17 11:04:36 +08:00
parent 9549f896b6
commit 4ea9bccba6
3 changed files with 1191 additions and 2 deletions

View File

@@ -10,6 +10,7 @@
"preview": "vite preview"
},
"dependencies": {
"@ant-design/charts": "^2.6.7",
"@ant-design/icons": "^6.1.1",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",

844
apps/web/pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
import { useEffect, useState, useRef, useMemo, useCallback } from 'react';
import { useParams } from 'react-router-dom';
import { Row, Col, Spin, Empty, Select, Tag, Progress, Skeleton, theme, Tooltip } from 'antd';
import { Row, Col, Spin, Empty, Select, Tag, Progress, Skeleton, theme, Tooltip, Card, Typography } from 'antd';
import {
TeamOutlined,
PhoneOutlined,
@@ -8,9 +8,14 @@ import {
RiseOutlined,
DashboardOutlined,
InfoCircleOutlined,
BarChartOutlined,
PieChartOutlined,
LineChartOutlined,
FunnelPlotOutlined,
} from '@ant-design/icons';
import { Column, Pie, Funnel, Line } from '@ant-design/charts';
import { countPluginData, aggregatePluginData, type AggregateItem } from '../api/pluginData';
import { getPluginSchema, type PluginEntitySchema, type PluginSchemaResponse } from '../api/plugins';
import { getPluginSchema, type PluginEntitySchema, type PluginSchemaResponse, type DashboardWidget } from '../api/plugins';
// ── 类型定义 ──
@@ -322,6 +327,253 @@ function SkeletonBreakdownCard({ index }: { index: number }) {
);
}
// ── Widget 图表子组件 ──
const WIDGET_ICON_MAP: Record<string, React.ReactNode> = {
stat_card: <DashboardOutlined />,
bar_chart: <BarChartOutlined />,
pie_chart: <PieChartOutlined />,
funnel_chart: <FunnelPlotOutlined />,
line_chart: <LineChartOutlined />,
};
const CHART_COLORS = [
'#4F46E5', '#059669', '#D97706', '#E11D48', '#7C3AED',
'#2563EB', '#0891B2', '#C026D3', '#EA580C', '#65A30D',
];
interface WidgetData {
widget: DashboardWidget;
data: AggregateItem[];
count?: number;
}
/** 统计卡片 widget */
function StatWidgetCard({ widgetData }: { widgetData: WidgetData }) {
const { widget, count } = widgetData;
const animatedValue = useCountUp(count ?? 0);
const color = widget.color || '#4F46E5';
return (
<Card
size="small"
className="erp-fade-in"
style={{ height: '100%' }}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<div
style={{
width: 40,
height: 40,
borderRadius: 10,
background: `${color}18`,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color,
fontSize: 20,
}}
>
{WIDGET_ICON_MAP[widget.type] || <DashboardOutlined />}
</div>
<div style={{ flex: 1 }}>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
{widget.title}
</Typography.Text>
<div style={{ fontSize: 24, fontWeight: 700, fontVariantNumeric: 'tabular-nums' }}>
{animatedValue.toLocaleString()}
</div>
</div>
</div>
</Card>
);
}
/** 柱状图 widget */
function BarWidgetCard({ widgetData, isDark }: { widgetData: WidgetData; isDark: boolean }) {
const { widget, data } = widgetData;
const dimensionOrder = widget.dimension_order;
const chartData = dimensionOrder
? dimensionOrder
.map((key) => data.find((d) => d.key === key))
.filter(Boolean)
.map((d) => ({ key: d!.key, count: d!.count }))
: data.map((d) => ({ key: d.key, count: d.count }));
const config = {
data: chartData,
xField: 'key',
yField: 'count',
colorField: 'key',
style: { maxWidth: 40, maxWidthRatio: 0.6 },
axis: {
x: { label: { style: { fill: isDark ? '#94A3B8' : '#475569' } } },
y: { label: { style: { fill: isDark ? '#94A3B8' : '#475569' } } },
},
};
return (
<Card
size="small"
title={
<span style={{ fontSize: 14 }}>
{WIDGET_ICON_MAP[widget.type]} {widget.title}
</span>
}
className="erp-fade-in"
>
{chartData.length > 0 ? (
<Column {...config} />
) : (
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="暂无数据" />
)}
</Card>
);
}
/** 饼图 widget */
function PieWidgetCard({ widgetData }: { widgetData: WidgetData }) {
const { widget, data } = widgetData;
const chartData = data.map((d) => ({ key: d.key, count: d.count }));
const config = {
data: chartData,
angleField: 'count',
colorField: 'key',
radius: 0.8,
innerRadius: 0.5,
label: {
text: 'key',
position: 'outside' as const,
},
legend: { color: { position: 'bottom' as const } },
};
return (
<Card
size="small"
title={
<span style={{ fontSize: 14 }}>
{WIDGET_ICON_MAP[widget.type]} {widget.title}
</span>
}
className="erp-fade-in"
>
{chartData.length > 0 ? (
<Pie {...config} />
) : (
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="暂无数据" />
)}
</Card>
);
}
/** 漏斗图 widget */
function FunnelWidgetCard({ widgetData }: { widgetData: WidgetData }) {
const { widget, data } = widgetData;
const dimensionOrder = widget.dimension_order;
const chartData = dimensionOrder
? dimensionOrder
.map((key) => data.find((d) => d.key === key))
.filter(Boolean)
.map((d) => ({ key: d!.key, count: d!.count }))
: data.map((d) => ({ key: d.key, count: d.count }));
const config = {
data: chartData,
xField: 'key',
yField: 'count',
legend: { position: 'bottom' as const },
};
return (
<Card
size="small"
title={
<span style={{ fontSize: 14 }}>
{WIDGET_ICON_MAP[widget.type]} {widget.title}
</span>
}
className="erp-fade-in"
>
{chartData.length > 0 ? (
<Funnel {...config} />
) : (
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="暂无数据" />
)}
</Card>
);
}
/** 折线图 widget */
function LineWidgetCard({ widgetData, isDark }: { widgetData: WidgetData; isDark: boolean }) {
const { widget, data } = widgetData;
const dimensionOrder = widget.dimension_order;
const chartData = dimensionOrder
? dimensionOrder
.map((key) => data.find((d) => d.key === key))
.filter(Boolean)
.map((d) => ({ key: d!.key, count: d!.count }))
: data.map((d) => ({ key: d.key, count: d.count }));
const config = {
data: chartData,
xField: 'key',
yField: 'count',
smooth: true,
axis: {
x: { label: { style: { fill: isDark ? '#94A3B8' : '#475569' } } },
y: { label: { style: { fill: isDark ? '#94A3B8' : '#475569' } } },
},
};
return (
<Card
size="small"
title={
<span style={{ fontSize: 14 }}>
{WIDGET_ICON_MAP[widget.type]} {widget.title}
</span>
}
className="erp-fade-in"
>
{chartData.length > 0 ? (
<Line {...config} />
) : (
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="暂无数据" />
)}
</Card>
);
}
/** 渲染单个 widget */
function WidgetRenderer({
widgetData,
isDark,
}: {
widgetData: WidgetData;
isDark: boolean;
}) {
switch (widgetData.widget.type) {
case 'stat_card':
return <StatWidgetCard widgetData={widgetData} />;
case 'bar_chart':
return <BarWidgetCard widgetData={widgetData} isDark={isDark} />;
case 'pie_chart':
return <PieWidgetCard widgetData={widgetData} />;
case 'funnel_chart':
return <FunnelWidgetCard widgetData={widgetData} />;
case 'line_chart':
return <LineWidgetCard widgetData={widgetData} isDark={isDark} />;
default:
return null;
}
}
// ── 延迟类名工具 ──
const DELAY_CLASSES = [
@@ -350,6 +602,11 @@ export function PluginDashboardPage() {
const [breakdowns, setBreakdowns] = useState<FieldBreakdown[]>([]);
const [error, setError] = useState<string | null>(null);
// Widget-based dashboard state
const [widgets, setWidgets] = useState<DashboardWidget[]>([]);
const [widgetData, setWidgetData] = useState<WidgetData[]>([]);
const [widgetsLoading, setWidgetsLoading] = useState(false);
const isDark =
themeToken.colorBgContainer === '#111827' ||
themeToken.colorBgContainer === 'rgb(17, 24, 39)';
@@ -370,6 +627,15 @@ export function PluginDashboardPage() {
if (entityList.length > 0) {
setSelectedEntity(entityList[0].name);
}
// 提取 dashboard widgets
const pages = schema.ui?.pages || [];
const dashboardPage = pages.find(
(p): p is PluginPageSchema & { type: 'dashboard'; widgets?: DashboardWidget[] } =>
p.type === 'dashboard',
);
if (dashboardPage?.widgets && dashboardPage.widgets.length > 0) {
setWidgets(dashboardPage.widgets);
}
} catch {
setError('Schema 加载失败,部分功能不可用');
} finally {
@@ -433,6 +699,49 @@ export function PluginDashboardPage() {
return () => abortController.abort();
}, [pluginId, entities]);
// Widget 数据并行加载
useEffect(() => {
if (!pluginId || widgets.length === 0) return;
const abortController = new AbortController();
async function loadWidgetData() {
setWidgetsLoading(true);
try {
const results = await Promise.all(
widgets.map(async (widget) => {
try {
if (widget.type === 'stat_card') {
const count = await countPluginData(pluginId!, widget.entity);
return { widget, data: [], count };
}
if (widget.dimension_field) {
const data = await aggregatePluginData(
pluginId!,
widget.entity,
widget.dimension_field,
);
return { widget, data };
}
// 没有 dimension_field 时仅返回计数
const count = await countPluginData(pluginId!, widget.entity);
return { widget, data: [], count };
} catch {
return { widget, data: [], count: 0 };
}
}),
);
if (!abortController.signal.aborted) {
setWidgetData(results);
}
} finally {
if (!abortController.signal.aborted) setWidgetsLoading(false);
}
}
loadWidgetData();
return () => abortController.abort();
}, [pluginId, widgets]);
// 当前实体的聚合数据
const loadData = useCallback(async () => {
if (!pluginId || !selectedEntity || filterableFields.length === 0) return;
@@ -572,6 +881,41 @@ export function PluginDashboardPage() {
))}
</Row>
{/* Widget 图表区域 */}
{widgets.length > 0 && (
<>
<div style={{ marginBottom: 16 }}>
<div className="erp-section-header">
<DashboardOutlined
className="erp-section-icon"
style={{ color: '#4F46E5' }}
/>
<span className="erp-section-title"></span>
</div>
</div>
{widgetsLoading && widgetData.length === 0 ? (
<Row gutter={[16, 16]}>
{widgets.map((_, i) => (
<SkeletonBreakdownCard key={i} index={i} />
))}
</Row>
) : (
<Row gutter={[16, 16]} style={{ marginBottom: 24 }}>
{widgetData.map((wd) => {
const colSpan = wd.widget.type === 'stat_card' ? 6
: wd.widget.type === 'pie_chart' || wd.widget.type === 'funnel_chart' ? 12
: 12;
return (
<Col key={`${wd.widget.type}-${wd.widget.entity}-${wd.widget.title}`} xs={24} sm={colSpan}>
<WidgetRenderer widgetData={wd} isDark={isDark} />
</Col>
);
})}
</Row>
)}
</>
)}
{/* 分组统计区域 */}
<div style={{ marginBottom: 16 }}>
<div className="erp-section-header">