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:
@@ -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
844
apps/web/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user