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"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@ant-design/charts": "^2.6.7",
|
||||||
"@ant-design/icons": "^6.1.1",
|
"@ant-design/icons": "^6.1.1",
|
||||||
"@dnd-kit/core": "^6.3.1",
|
"@dnd-kit/core": "^6.3.1",
|
||||||
"@dnd-kit/sortable": "^10.0.0",
|
"@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 { useEffect, useState, useRef, useMemo, useCallback } from 'react';
|
||||||
import { useParams } from 'react-router-dom';
|
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 {
|
import {
|
||||||
TeamOutlined,
|
TeamOutlined,
|
||||||
PhoneOutlined,
|
PhoneOutlined,
|
||||||
@@ -8,9 +8,14 @@ import {
|
|||||||
RiseOutlined,
|
RiseOutlined,
|
||||||
DashboardOutlined,
|
DashboardOutlined,
|
||||||
InfoCircleOutlined,
|
InfoCircleOutlined,
|
||||||
|
BarChartOutlined,
|
||||||
|
PieChartOutlined,
|
||||||
|
LineChartOutlined,
|
||||||
|
FunnelPlotOutlined,
|
||||||
} from '@ant-design/icons';
|
} from '@ant-design/icons';
|
||||||
|
import { Column, Pie, Funnel, Line } from '@ant-design/charts';
|
||||||
import { countPluginData, aggregatePluginData, type AggregateItem } from '../api/pluginData';
|
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 = [
|
const DELAY_CLASSES = [
|
||||||
@@ -350,6 +602,11 @@ export function PluginDashboardPage() {
|
|||||||
const [breakdowns, setBreakdowns] = useState<FieldBreakdown[]>([]);
|
const [breakdowns, setBreakdowns] = useState<FieldBreakdown[]>([]);
|
||||||
const [error, setError] = useState<string | null>(null);
|
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 =
|
const isDark =
|
||||||
themeToken.colorBgContainer === '#111827' ||
|
themeToken.colorBgContainer === '#111827' ||
|
||||||
themeToken.colorBgContainer === 'rgb(17, 24, 39)';
|
themeToken.colorBgContainer === 'rgb(17, 24, 39)';
|
||||||
@@ -370,6 +627,15 @@ export function PluginDashboardPage() {
|
|||||||
if (entityList.length > 0) {
|
if (entityList.length > 0) {
|
||||||
setSelectedEntity(entityList[0].name);
|
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 {
|
} catch {
|
||||||
setError('Schema 加载失败,部分功能不可用');
|
setError('Schema 加载失败,部分功能不可用');
|
||||||
} finally {
|
} finally {
|
||||||
@@ -433,6 +699,49 @@ export function PluginDashboardPage() {
|
|||||||
return () => abortController.abort();
|
return () => abortController.abort();
|
||||||
}, [pluginId, entities]);
|
}, [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 () => {
|
const loadData = useCallback(async () => {
|
||||||
if (!pluginId || !selectedEntity || filterableFields.length === 0) return;
|
if (!pluginId || !selectedEntity || filterableFields.length === 0) return;
|
||||||
@@ -572,6 +881,41 @@ export function PluginDashboardPage() {
|
|||||||
))}
|
))}
|
||||||
</Row>
|
</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 style={{ marginBottom: 16 }}>
|
||||||
<div className="erp-section-header">
|
<div className="erp-section-header">
|
||||||
|
|||||||
Reference in New Issue
Block a user