diff --git a/apps/web/src/pages/PluginDashboardPage.tsx b/apps/web/src/pages/PluginDashboardPage.tsx index 108a095..0551f12 100644 --- a/apps/web/src/pages/PluginDashboardPage.tsx +++ b/apps/web/src/pages/PluginDashboardPage.tsx @@ -1,33 +1,367 @@ -import { useEffect, useState } from 'react'; +import { useEffect, useState, useRef, useMemo, useCallback } from 'react'; import { useParams } from 'react-router-dom'; -import { Card, Row, Col, Statistic, Spin, Empty, Select, Tag, message } from 'antd'; +import { Row, Col, Spin, Empty, Select, Tag, Progress, Skeleton, theme, Tooltip } from 'antd'; import { TeamOutlined, - RiseOutlined, PhoneOutlined, TagsOutlined, + RiseOutlined, + DashboardOutlined, + InfoCircleOutlined, } from '@ant-design/icons'; import { countPluginData, aggregatePluginData, type AggregateItem } from '../api/pluginData'; import { getPluginSchema, type PluginEntitySchema, type PluginSchemaResponse } from '../api/plugins'; -/** - * 插件统计概览页面 — 通过路由参数自加载 schema,使用后端 aggregate API - * 路由: /plugins/:pluginId/dashboard - */ +// ── 类型定义 ── + +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, + palette, + index, +}: { + breakdown: FieldBreakdown; + totalCount: number; + palette: { tagColor: string }; + 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 ( + +
+ +
+ + ); +} + +// ── 延迟类名工具 ── + +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]; +} + +// ── 主组件 ── + export function PluginDashboardPage() { const { pluginId } = useParams<{ pluginId: string }>(); + const { token: themeToken } = theme.useToken(); + const [loading, setLoading] = useState(false); + const [schemaLoading, setSchemaLoading] = useState(false); const [entities, setEntities] = useState([]); const [selectedEntity, setSelectedEntity] = useState(''); - const [totalCount, setTotalCount] = useState(0); - const [aggregations, setAggregations] = useState([]); + const [entityStats, setEntityStats] = useState([]); + const [breakdowns, setBreakdowns] = useState([]); + const [error, setError] = useState(null); - // 加载 schema 获取 entities + const isDark = + themeToken.colorBgContainer === '#111827' || + themeToken.colorBgContainer === 'rgb(17, 24, 39)'; + + // 加载 schema useEffect(() => { if (!pluginId) return; const abortController = new AbortController(); async function loadSchema() { + setSchemaLoading(true); + setError(null); try { const schema: PluginSchemaResponse = await getPluginSchema(pluginId!); if (abortController.signal.aborted) return; @@ -37,7 +371,9 @@ export function PluginDashboardPage() { setSelectedEntity(entityList[0].name); } } catch { - message.warning('Schema 加载失败,部分功能不可用'); + setError('Schema 加载失败,部分功能不可用'); + } finally { + if (!abortController.signal.aborted) setSchemaLoading(false); } } @@ -45,112 +381,266 @@ export function PluginDashboardPage() { return () => abortController.abort(); }, [pluginId]); - const currentEntity = entities.find((e) => e.name === selectedEntity); - const filterableFields = currentEntity?.fields.filter((f) => f.filterable) || []; + const currentEntity = useMemo( + () => entities.find((e) => e.name === selectedEntity), + [entities, selectedEntity], + ); - // 使用后端 count/aggregate API + const filterableFields = useMemo( + () => currentEntity?.fields.filter((f) => f.filterable) || [], + [currentEntity], + ); + + // 加载所有实体的计数 useEffect(() => { - if (!pluginId || !selectedEntity) return; + if (!pluginId || entities.length === 0) return; const abortController = new AbortController(); - async function loadData() { - setLoading(true); - try { - const total = await countPluginData(pluginId!, selectedEntity!); + async function loadAllCounts() { + const results: EntityStat[] = []; + for (const entity of entities) { if (abortController.signal.aborted) return; - setTotalCount(total); - - const aggs: AggregateItem[] = []; - for (const field of filterableFields) { + try { + const count = await countPluginData(pluginId!, entity.name); if (abortController.signal.aborted) return; - try { - const items = await aggregatePluginData(pluginId!, selectedEntity!, field.name); - for (const item of items) { - aggs.push({ - key: `${field.display_name || field.name}: ${item.key || '(空)'}`, - count: item.count, - }); - } - } catch { - // 单个字段聚合失败不影响其他字段 - } + const palette = ENTITY_PALETTE[entity.name] || DEFAULT_PALETTE; + results.push({ + name: entity.name, + displayName: entity.display_name || entity.name, + count, + icon: ENTITY_ICONS[entity.name] || , + gradient: palette.gradient, + iconBg: palette.iconBg, + }); + } catch { + const palette = ENTITY_PALETTE[entity.name] || DEFAULT_PALETTE; + results.push({ + name: entity.name, + displayName: entity.display_name || entity.name, + count: 0, + icon: ENTITY_ICONS[entity.name] || , + gradient: palette.gradient, + iconBg: palette.iconBg, + }); } - if (abortController.signal.aborted) return; - setAggregations(aggs); - } catch { - message.warning('统计数据加载失败'); } + if (!abortController.signal.aborted) { + setEntityStats(results); + } + } + + loadAllCounts(); + return () => abortController.abort(); + }, [pluginId, entities]); + + // 当前实体的聚合数据 + const loadData = useCallback(async () => { + if (!pluginId || !selectedEntity || filterableFields.length === 0) return; + + const abortController = new AbortController(); + setLoading(true); + setError(null); + + try { + const totalCount = entityStats.find((s) => s.name === selectedEntity)?.count ?? 0; + const fieldResults: FieldBreakdown[] = []; + + for (const field of filterableFields) { + if (abortController.signal.aborted) return; + try { + const items = await aggregatePluginData(pluginId!, selectedEntity!, field.name); + if (abortController.signal.aborted) return; + fieldResults.push({ + fieldName: field.name, + displayName: field.display_name || field.name, + items, + }); + } catch { + // 单个字段聚合失败不影响其他字段 + } + } + + if (!abortController.signal.aborted) { + setBreakdowns(fieldResults); + } + } catch { + setError('统计数据加载失败'); + } finally { if (!abortController.signal.aborted) setLoading(false); } - loadData(); return () => abortController.abort(); - }, [pluginId, selectedEntity, filterableFields.length]); + }, [pluginId, selectedEntity, filterableFields, entityStats]); - const iconMap: Record = { - customer: , - contact: , - communication: , - customer_tag: , - customer_relationship: , - }; + useEffect(() => { + const cleanup = loadData(); + return () => { + cleanup?.then((fn) => fn?.()).catch(() => {}); + }; + }, [loadData]); - if (loading) { - return
; + // 当前选中实体的总数 + 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 ( +
+ + {Array.from({ length: 5 }).map((_, i) => ( + + ))} + + + {Array.from({ length: 3 }).map((_, i) => ( + + ))} + +
+ ); } return (
- +
+
+

+ 统计概览 +

+

+ CRM 数据全景视图,实时掌握业务动态 +

+
({ label: t, value: t }))} + value={relFilter} + options={relTypes.map((t) => ({ + label: ( + + + {getEdgeTypeLabel(t)} + + ({relationships.filter((r) => r.label === t).length}) + + + ), + value: t, + }))} onChange={(v) => setRelFilter(v)} />