diff --git a/apps/web/src/pages/PluginDashboardPage.tsx b/apps/web/src/pages/PluginDashboardPage.tsx index f5657f4..a71f9e2 100644 --- a/apps/web/src/pages/PluginDashboardPage.tsx +++ b/apps/web/src/pages/PluginDashboardPage.tsx @@ -1,585 +1,24 @@ -import { useEffect, useState, useRef, useMemo, useCallback } from 'react'; +import { useEffect, useState, useMemo, useCallback } from 'react'; import { useParams } from 'react-router-dom'; -import { Row, Col, Spin, Empty, Select, Tag, Progress, Skeleton, theme, Tooltip, Card, Typography } from 'antd'; +import { Row, Col, Empty, Select, Spin, theme } from 'antd'; +import { DashboardOutlined } from '@ant-design/icons'; +import { countPluginData, aggregatePluginData } from '../api/pluginData'; import { - TeamOutlined, - PhoneOutlined, - TagsOutlined, - 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, type PluginPageSchema, type DashboardWidget } from '../api/plugins'; - -// ── 类型定义 ── - -interface EntityStat { - name: string; - displayName: string; - count: number; - icon: React.ReactNode; - gradient: string; - iconBg: string; -} - -interface FieldBreakdown { - fieldName: string; - displayName: string; - items: AggregateItem[]; -} - -// ── 色板配置 ── - -const ENTITY_PALETTE: Record = { - customer: { - gradient: 'linear-gradient(135deg, #4F46E5, #6366F1)', - iconBg: 'rgba(79, 70, 229, 0.12)', - tagColor: 'purple', - }, - contact: { - gradient: 'linear-gradient(135deg, #059669, #10B981)', - iconBg: 'rgba(5, 150, 105, 0.12)', - tagColor: 'green', - }, - communication: { - gradient: 'linear-gradient(135deg, #D97706, #F59E0B)', - iconBg: 'rgba(217, 119, 6, 0.12)', - tagColor: 'orange', - }, - customer_tag: { - gradient: 'linear-gradient(135deg, #7C3AED, #A78BFA)', - iconBg: 'rgba(124, 58, 237, 0.12)', - tagColor: 'volcano', - }, - customer_relationship: { - gradient: 'linear-gradient(135deg, #E11D48, #F43F5E)', - iconBg: 'rgba(225, 29, 72, 0.12)', - tagColor: 'red', - }, -}; - -const DEFAULT_PALETTE = { - gradient: 'linear-gradient(135deg, #2563EB, #3B82F6)', - iconBg: 'rgba(37, 99, 235, 0.12)', - tagColor: 'blue', -}; - -const TAG_COLORS = [ - 'blue', 'green', 'orange', 'red', 'purple', 'cyan', - 'magenta', 'gold', 'lime', 'geekblue', 'volcano', -]; - -// ── 图标映射 ── - -const ENTITY_ICONS: Record = { - customer: , - contact: , - communication: , - customer_tag: , - customer_relationship: , -}; - -// ── 计数动画 Hook ── - -function useCountUp(end: number, duration = 800) { - const [count, setCount] = useState(0); - const prevEnd = useRef(end); - - useEffect(() => { - if (end === prevEnd.current && count > 0) return; - prevEnd.current = end; - - if (end === 0) { - setCount(0); - return; - } - - const startTime = performance.now(); - - function tick(now: number) { - const elapsed = now - startTime; - const progress = Math.min(elapsed / duration, 1); - const eased = 1 - Math.pow(1 - progress, 3); - setCount(Math.round(end * eased)); - if (progress < 1) requestAnimationFrame(tick); - } - - requestAnimationFrame(tick); - }, [end, duration]); - - return count; -} - -// ── 子组件 ── - -function StatValue({ value, loading }: { value: number; loading: boolean }) { - const animatedValue = useCountUp(value); - if (loading) return ; - return {animatedValue.toLocaleString()}; -} - -/** 顶部统计卡片 */ -function StatCard({ - stat, - loading, - delay, -}: { - stat: EntityStat; - loading: boolean; - delay: string; -}) { - return ( - -
-
-
-
-
{stat.displayName}
-
- -
-
-
{stat.icon}
-
-
- - ); -} - -/** 骨架屏卡片 */ -function SkeletonStatCard({ delay }: { delay: string }) { - return ( - -
-
-
-
-
- -
-
- -
-
-
- -
-
-
- - ); -} - -/** 字段分布卡片 */ -function BreakdownCard({ - breakdown, - totalCount, - index, -}: { - breakdown: FieldBreakdown; - totalCount: number; - index: number; -}) { - const maxCount = Math.max(...breakdown.items.map((i) => i.count), 1); - - return ( - -
-
- - - {breakdown.displayName} - - - {breakdown.items.length} 项 - -
- -
- {breakdown.items.map((item, idx) => { - const percent = totalCount > 0 ? Math.round((item.count / totalCount) * 100) : 0; - const barPercent = maxCount > 0 ? Math.round((item.count / maxCount) * 100) : 0; - const color = TAG_COLORS[idx % TAG_COLORS.length]; - - return ( -
-
- - - {item.key || '(空)'} - - - - {item.count} - - {percent}% - - -
- -
- ); - })} -
- - {breakdown.items.length === 0 && ( - - )} -
- - ); -} - -/** 骨架屏分布卡片 */ -function SkeletonBreakdownCard({ index }: { index: number }) { - return ( - -
- -
- - ); -} - -// ── Widget 图表子组件 ── - -const WIDGET_ICON_MAP: Record = { - stat_card: , - bar_chart: , - pie_chart: , - funnel_chart: , - line_chart: , -}; - -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 ( - -
-
- {WIDGET_ICON_MAP[widget.type] || } -
-
- - {widget.title} - -
- {animatedValue.toLocaleString()} -
-
-
-
- ); -} - -/** 柱状图 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 ( - - {WIDGET_ICON_MAP[widget.type]} {widget.title} - - } - className="erp-fade-in" - > - {chartData.length > 0 ? ( - - ) : ( - - )} - - ); -} - -/** 饼图 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 ( - - {WIDGET_ICON_MAP[widget.type]} {widget.title} - - } - className="erp-fade-in" - > - {chartData.length > 0 ? ( - - ) : ( - - )} - - ); -} - -/** 漏斗图 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 ( - - {WIDGET_ICON_MAP[widget.type]} {widget.title} - - } - className="erp-fade-in" - > - {chartData.length > 0 ? ( - - ) : ( - - )} - - ); -} - -/** 折线图 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 ( - - {WIDGET_ICON_MAP[widget.type]} {widget.title} - - } - className="erp-fade-in" - > - {chartData.length > 0 ? ( - - ) : ( - - )} - - ); -} - -/** 渲染单个 widget */ -function WidgetRenderer({ - widgetData, - isDark, -}: { - widgetData: WidgetData; - isDark: boolean; -}) { - switch (widgetData.widget.type) { - case 'stat_card': - return ; - case 'bar_chart': - return ; - case 'pie_chart': - return ; - case 'funnel_chart': - return ; - case 'line_chart': - return ; - default: - return null; - } -} - -// ── 延迟类名工具 ── - -const DELAY_CLASSES = [ - 'erp-fade-in erp-fade-in-delay-1', - 'erp-fade-in erp-fade-in-delay-2', - 'erp-fade-in erp-fade-in-delay-3', - 'erp-fade-in erp-fade-in-delay-4', - 'erp-fade-in erp-fade-in-delay-4', -]; - -function getDelayClass(index: number): string { - return DELAY_CLASSES[index % DELAY_CLASSES.length]; -} + getPluginSchema, + type PluginEntitySchema, + type PluginSchemaResponse, + type PluginPageSchema, + type DashboardWidget, +} from '../api/plugins'; +import type { EntityStat, FieldBreakdown, WidgetData } from './dashboard/dashboardTypes'; +import { ENTITY_PALETTE, DEFAULT_PALETTE, ENTITY_ICONS, getDelayClass } from './dashboard/dashboardConstants'; +import { + StatCard, + SkeletonStatCard, + BreakdownCard, + SkeletonBreakdownCard, + WidgetRenderer, +} from './dashboard/DashboardWidgets'; // ── 主组件 ── @@ -594,12 +33,10 @@ export function PluginDashboardPage() { const [entityStats, setEntityStats] = useState([]); const [breakdowns, setBreakdowns] = useState([]); const [error, setError] = useState(null); - // Widget-based dashboard state const [widgets, setWidgets] = useState([]); const [widgetData, setWidgetData] = useState([]); const [widgetsLoading, setWidgetsLoading] = useState(false); - const isDark = themeToken.colorBgContainer === '#111827' || themeToken.colorBgContainer === 'rgb(17, 24, 39)'; @@ -608,7 +45,6 @@ export function PluginDashboardPage() { useEffect(() => { if (!pluginId) return; const abortController = new AbortController(); - async function loadSchema() { setSchemaLoading(true); setError(null); @@ -639,22 +75,18 @@ export function PluginDashboardPage() { loadSchema(); return () => abortController.abort(); }, [pluginId]); - const currentEntity = useMemo( () => entities.find((e) => e.name === selectedEntity), [entities, selectedEntity], ); - const filterableFields = useMemo( () => currentEntity?.fields.filter((f) => f.filterable) || [], [currentEntity], ); - // 加载所有实体的计数 useEffect(() => { if (!pluginId || entities.length === 0) return; const abortController = new AbortController(); - async function loadAllCounts() { const results: EntityStat[] = []; for (const entity of entities) { @@ -691,12 +123,10 @@ export function PluginDashboardPage() { loadAllCounts(); return () => abortController.abort(); }, [pluginId, entities]); - // Widget 数据并行加载 useEffect(() => { if (!pluginId || widgets.length === 0) return; const abortController = new AbortController(); - async function loadWidgetData() { setWidgetsLoading(true); try { @@ -734,18 +164,14 @@ export function PluginDashboardPage() { loadWidgetData(); return () => abortController.abort(); }, [pluginId, widgets]); - // 当前实体的聚合数据 const loadData = useCallback(async () => { if (!pluginId || !selectedEntity || filterableFields.length === 0) return; - const abortController = new AbortController(); setLoading(true); setError(null); - try { const fieldResults: FieldBreakdown[] = []; - for (const field of filterableFields) { if (abortController.signal.aborted) return; try { @@ -761,39 +187,29 @@ export function PluginDashboardPage() { } } - if (!abortController.signal.aborted) { - setBreakdowns(fieldResults); - } + if (!abortController.signal.aborted) setBreakdowns(fieldResults); } catch { setError('统计数据加载失败'); } finally { if (!abortController.signal.aborted) setLoading(false); } - return () => abortController.abort(); }, [pluginId, selectedEntity, filterableFields, entityStats]); - useEffect(() => { const cleanup = loadData(); - return () => { - cleanup?.then((fn) => fn?.()).catch(() => {}); - }; + return () => { cleanup?.then((fn) => fn?.()).catch(() => {}); }; }, [loadData]); - // 当前选中实体的总数 const currentTotal = useMemo( () => entityStats.find((s) => s.name === selectedEntity)?.count ?? 0, [entityStats, selectedEntity], ); - // 当前实体的色板 const currentPalette = useMemo( () => ENTITY_PALETTE[selectedEntity] || DEFAULT_PALETTE, [selectedEntity], ); - // ── 渲染 ── - if (schemaLoading) { return (
diff --git a/apps/web/src/pages/dashboard/DashboardWidgets.tsx b/apps/web/src/pages/dashboard/DashboardWidgets.tsx new file mode 100644 index 0000000..94bce50 --- /dev/null +++ b/apps/web/src/pages/dashboard/DashboardWidgets.tsx @@ -0,0 +1,298 @@ +import { useEffect, useState, useRef } from 'react'; +import { Col, Spin, Empty, Tag, Progress, Skeleton, Tooltip, Card, Typography } from 'antd'; +import { + InfoCircleOutlined, + DashboardOutlined, +} from '@ant-design/icons'; +import { Column, Pie, Funnel, Line } from '@ant-design/charts'; +import type { EntityStat, FieldBreakdown, WidgetData } from './dashboardTypes'; +import { TAG_COLORS, WIDGET_ICON_MAP } from './dashboardConstants'; + +// ── 计数动画 Hook ── + +function useCountUp(end: number, duration = 800) { + const [count, setCount] = useState(0); + const prevEnd = useRef(end); + useEffect(() => { + if (end === prevEnd.current && count > 0) return; + prevEnd.current = end; + if (end === 0) { setCount(0); return; } + const startTime = performance.now(); + function tick(now: number) { + const elapsed = now - startTime; + const progress = Math.min(elapsed / duration, 1); + const eased = 1 - Math.pow(1 - progress, 3); + setCount(Math.round(end * eased)); + if (progress < 1) requestAnimationFrame(tick); + } + requestAnimationFrame(tick); + }, [end, duration]); + return count; +} + +// ── 共享工具 ── + +function prepareChartData(data: WidgetData['data'], dimensionOrder?: string[]) { + return 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 TAG_COLOR_MAP: Record = { + blue: '#3B82F6', green: '#10B981', orange: '#F59E0B', red: '#EF4444', + purple: '#8B5CF6', cyan: '#06B6D4', magenta: '#EC4899', gold: '#EAB308', + lime: '#84CC16', geekblue: '#6366F1', volcano: '#F97316', +}; + +function tagStrokeColor(color: string): string { + return TAG_COLOR_MAP[color] || '#3B82F6'; +} + +function WidgetCardShell({ + title, + widgetType, + children, +}: { + title: string; + widgetType: string; + children: React.ReactNode; +}) { + return ( + {WIDGET_ICON_MAP[widgetType]} {title}} + className="erp-fade-in" + > + {children} + + ); +} + +function ChartEmpty() { + return ; +} + +// ── 基础子组件 ── + +function StatValue({ value, loading }: { value: number; loading: boolean }) { + const animatedValue = useCountUp(value); + if (loading) return ; + return {animatedValue.toLocaleString()}; +} + +/** 顶部统计卡片 */ +export function StatCard({ stat, loading, delay }: { stat: EntityStat; loading: boolean; delay: string }) { + return ( + +
+
+
+
+
{stat.displayName}
+
+ +
+
+
{stat.icon}
+
+
+ + ); +} + +/** 骨架屏卡片 */ +export function SkeletonStatCard({ delay }: { delay: string }) { + return ( + +
+
+
+
+
+ +
+
+ +
+
+
+ +
+
+
+ + ); +} + +/** 字段分布卡片 */ +export function BreakdownCard({ + breakdown, totalCount, index, +}: { breakdown: FieldBreakdown; totalCount: number; index: number }) { + const maxCount = Math.max(...breakdown.items.map((i) => i.count), 1); + return ( + +
+
+ + + {breakdown.displayName} + + + {breakdown.items.length} 项 + +
+
+ {breakdown.items.map((item, idx) => { + const percent = totalCount > 0 ? Math.round((item.count / totalCount) * 100) : 0; + const barPercent = maxCount > 0 ? Math.round((item.count / maxCount) * 100) : 0; + const color = TAG_COLORS[idx % TAG_COLORS.length]; + return ( +
+
+ + + {item.key || '(空)'} + + + + {item.count} + {percent}% + +
+ +
+ ); + })} +
+ {breakdown.items.length === 0 && ( + + )} +
+ + ); +} + +/** 骨架屏分布卡片 */ +export function SkeletonBreakdownCard({ index }: { index: number }) { + return ( + +
+ +
+ + ); +} + +// ── Widget 图表子组件 ── + +/** 统计卡片 widget */ +function StatWidgetCard({ widgetData }: { widgetData: WidgetData }) { + const { widget, count } = widgetData; + const animatedValue = useCountUp(count ?? 0); + const color = widget.color || '#4F46E5'; + return ( + +
+
+ {WIDGET_ICON_MAP[widget.type] || } +
+
+ {widget.title} +
+ {animatedValue.toLocaleString()} +
+
+
+
+ ); +} + +/** 柱状图 widget */ +function BarWidgetCard({ widgetData, isDark }: { widgetData: WidgetData; isDark: boolean }) { + const { widget, data } = widgetData; + const chartData = prepareChartData(data, widget.dimension_order); + const axisLabelStyle = { fill: isDark ? '#94A3B8' : '#475569' }; + return ( + + {chartData.length > 0 ? ( + + ) : } + + ); +} + +/** 饼图 widget */ +function PieWidgetCard({ widgetData }: { widgetData: WidgetData }) { + const { widget, data } = widgetData; + const chartData = data.map((d) => ({ key: d.key, count: d.count })); + return ( + + {chartData.length > 0 ? ( + + ) : } + + ); +} + +/** 漏斗图 widget */ +function FunnelWidgetCard({ widgetData }: { widgetData: WidgetData }) { + const { widget, data } = widgetData; + const chartData = prepareChartData(data, widget.dimension_order); + return ( + + {chartData.length > 0 ? ( + + ) : } + + ); +} + +/** 折线图 widget */ +function LineWidgetCard({ widgetData, isDark }: { widgetData: WidgetData; isDark: boolean }) { + const { widget, data } = widgetData; + const chartData = prepareChartData(data, widget.dimension_order); + const axisLabelStyle = { fill: isDark ? '#94A3B8' : '#475569' }; + return ( + + {chartData.length > 0 ? ( + + ) : } + + ); +} + +/** 渲染单个 widget */ +export function WidgetRenderer({ widgetData, isDark }: { widgetData: WidgetData; isDark: boolean }) { + switch (widgetData.widget.type) { + case 'stat_card': return ; + case 'bar_chart': return ; + case 'pie_chart': return ; + case 'funnel_chart': return ; + case 'line_chart': return ; + default: return null; + } +} diff --git a/apps/web/src/pages/dashboard/dashboardConstants.ts b/apps/web/src/pages/dashboard/dashboardConstants.ts new file mode 100644 index 0000000..9be9901 --- /dev/null +++ b/apps/web/src/pages/dashboard/dashboardConstants.ts @@ -0,0 +1,85 @@ +import type React from 'react'; +import { + TeamOutlined, + PhoneOutlined, + TagsOutlined, + RiseOutlined, + DashboardOutlined, + BarChartOutlined, + PieChartOutlined, + LineChartOutlined, + FunnelPlotOutlined, +} from '@ant-design/icons'; + +// ── 色板配置 ── + +export const ENTITY_PALETTE: Record = { + customer: { + gradient: 'linear-gradient(135deg, #4F46E5, #6366F1)', + iconBg: 'rgba(79, 70, 229, 0.12)', + tagColor: 'purple', + }, + contact: { + gradient: 'linear-gradient(135deg, #059669, #10B981)', + iconBg: 'rgba(5, 150, 105, 0.12)', + tagColor: 'green', + }, + communication: { + gradient: 'linear-gradient(135deg, #D97706, #F59E0B)', + iconBg: 'rgba(217, 119, 6, 0.12)', + tagColor: 'orange', + }, + customer_tag: { + gradient: 'linear-gradient(135deg, #7C3AED, #A78BFA)', + iconBg: 'rgba(124, 58, 237, 0.12)', + tagColor: 'volcano', + }, + customer_relationship: { + gradient: 'linear-gradient(135deg, #E11D48, #F43F5E)', + iconBg: 'rgba(225, 29, 72, 0.12)', + tagColor: 'red', + }, +}; + +export const DEFAULT_PALETTE = { + gradient: 'linear-gradient(135deg, #2563EB, #3B82F6)', + iconBg: 'rgba(37, 99, 235, 0.12)', + tagColor: 'blue', +}; + +export const TAG_COLORS = [ + 'blue', 'green', 'orange', 'red', 'purple', 'cyan', + 'magenta', 'gold', 'lime', 'geekblue', 'volcano', +]; + +// ── 图标映射 ── + +export const ENTITY_ICONS: Record = { + customer: , + contact: , + communication: , + customer_tag: , + customer_relationship: , +}; + +export const WIDGET_ICON_MAP: Record = { + stat_card: , + bar_chart: , + pie_chart: , + funnel_chart: , + line_chart: , +}; + +// ── 延迟类名工具 ── + +const DELAY_CLASSES = [ + 'erp-fade-in erp-fade-in-delay-1', + 'erp-fade-in erp-fade-in-delay-2', + 'erp-fade-in erp-fade-in-delay-3', + 'erp-fade-in erp-fade-in-delay-4', + 'erp-fade-in erp-fade-in-delay-4', +]; + +export function getDelayClass(index: number): string { + return DELAY_CLASSES[index % DELAY_CLASSES.length]; +} diff --git a/apps/web/src/pages/dashboard/dashboardTypes.ts b/apps/web/src/pages/dashboard/dashboardTypes.ts new file mode 100644 index 0000000..e219ed2 --- /dev/null +++ b/apps/web/src/pages/dashboard/dashboardTypes.ts @@ -0,0 +1,25 @@ +import type React from 'react'; +import type { AggregateItem, DashboardWidget } from '../../api/plugins'; + +// ── 类型定义 ── + +export interface EntityStat { + name: string; + displayName: string; + count: number; + icon: React.ReactNode; + gradient: string; + iconBg: string; +} + +export interface FieldBreakdown { + fieldName: string; + displayName: string; + items: AggregateItem[]; +} + +export interface WidgetData { + widget: DashboardWidget; + data: AggregateItem[]; + count?: number; +}