diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 9509e44..5e0c2f2 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -18,6 +18,8 @@ const PluginAdmin = lazy(() => import('./pages/PluginAdmin')); const PluginCRUDPage = lazy(() => import('./pages/PluginCRUDPage')); const PluginTabsPage = lazy(() => import('./pages/PluginTabsPage').then((m) => ({ default: m.PluginTabsPage }))); const PluginTreePage = lazy(() => import('./pages/PluginTreePage').then((m) => ({ default: m.PluginTreePage }))); +const PluginGraphPage = lazy(() => import('./pages/PluginGraphPage').then((m) => ({ default: m.PluginGraphPage }))); +const PluginDashboardPage = lazy(() => import('./pages/PluginDashboardPage').then((m) => ({ default: m.PluginDashboardPage }))); function PrivateRoute({ children }: { children: React.ReactNode }) { const isAuthenticated = useAuthStore((s) => s.isAuthenticated); @@ -142,6 +144,8 @@ export default function App() { } /> } /> } /> + } /> + } /> } /> diff --git a/apps/web/src/pages/PluginDashboardPage.tsx b/apps/web/src/pages/PluginDashboardPage.tsx new file mode 100644 index 0000000..5aa7e77 --- /dev/null +++ b/apps/web/src/pages/PluginDashboardPage.tsx @@ -0,0 +1,147 @@ +import { useEffect, useState } from 'react'; +import { Card, Row, Col, Statistic, Spin, Empty, Select, Tag } from 'antd'; +import { + TeamOutlined, + RiseOutlined, + PhoneOutlined, + TagsOutlined, +} from '@ant-design/icons'; +import { listPluginData } from '../api/pluginData'; +import { PluginFieldSchema, PluginEntitySchema } from '../api/plugins'; + +interface PluginDashboardPageProps { + pluginId: string; + entities: PluginEntitySchema[]; +} + +interface AggregationResult { + key: string; + count: number; +} + +/** + * 插件统计概览页面 + * 使用 listPluginData 加载全量数据前端聚合 + */ +export function PluginDashboardPage({ pluginId, entities }: PluginDashboardPageProps) { + const [loading, setLoading] = useState(false); + const [selectedEntity, setSelectedEntity] = useState( + entities[0]?.name || '', + ); + const [totalCount, setTotalCount] = useState(0); + const [aggregations, setAggregations] = useState([]); + + const currentEntity = entities.find((e) => e.name === selectedEntity); + const filterableFields = currentEntity?.fields.filter((f) => f.filterable) || []; + + useEffect(() => { + if (!pluginId || !selectedEntity) return; + setLoading(true); + + async function loadData() { + try { + let allData: Record[] = []; + let page = 1; + let hasMore = true; + let total = 0; + while (hasMore) { + const result = await listPluginData(pluginId, selectedEntity!, page, 200); + allData = [...allData, ...result.data.map((r) => r.data)]; + total = result.total; + hasMore = result.data.length === 200 && allData.length < result.total; + page++; + } + setTotalCount(total); + + const aggs: AggregationResult[] = []; + for (const field of filterableFields) { + const grouped = new Map(); + for (const item of allData) { + const val = String(item[field.name] ?? '(空)'); + grouped.set(val, (grouped.get(val) || 0) + 1); + } + for (const [key, count] of grouped) { + aggs.push({ key: `${field.display_name || field.name}: ${key}`, count }); + } + } + setAggregations(aggs); + } catch { + // 加载失败 + } + setLoading(false); + } + + loadData(); + }, [pluginId, selectedEntity, filterableFields.length]); + + const iconMap: Record = { + customer: , + contact: , + communication: , + customer_tag: , + customer_relationship: , + }; + + if (loading) { + return ( +
+ +
+ ); + } + + return ( +
+ ({ + label: e.display_name || e.name, + value: e.name, + }))} + onChange={setSelectedEntity} + /> + } + > + + + + } + valueStyle={{ color: '#4F46E5' }} + /> + + + + {aggregations.length > 0 && ( + + + + {aggregations.slice(0, 20).map((agg, idx) => ( + + + {agg.key}: {agg.count} + + + ))} + + + + )} + + {aggregations.length === 0 && totalCount === 0 && ( + + + + )} + + +
+ ); +} diff --git a/apps/web/src/pages/PluginGraphPage.tsx b/apps/web/src/pages/PluginGraphPage.tsx new file mode 100644 index 0000000..29c8ae0 --- /dev/null +++ b/apps/web/src/pages/PluginGraphPage.tsx @@ -0,0 +1,304 @@ +import { useEffect, useState, useRef } from 'react'; +import { Card, Select, Space, Empty, Spin, Statistic, Row, Col } from 'antd'; +import { listPluginData } from '../api/pluginData'; +import { PluginFieldSchema } from '../api/plugins'; + +interface PluginGraphPageProps { + pluginId: string; + entity: string; + relationshipEntity: string; + sourceField: string; + targetField: string; + edgeLabelField: string; + nodeLabelField: string; + fields: PluginFieldSchema[]; +} + +interface GraphNode { + id: string; + label: string; + data: Record; +} + +interface GraphEdge { + source: string; + target: string; + label: string; +} + +/** + * 客户关系图谱页面 + * 使用 Canvas 2D 绘制简单关系图,避免引入 G6 大包 + */ +export function PluginGraphPage({ + pluginId, + entity, + relationshipEntity, + sourceField, + targetField, + edgeLabelField, + nodeLabelField, + fields, +}: PluginGraphPageProps) { + const canvasRef = useRef(null); + const [customers, setCustomers] = useState([]); + const [relationships, setRelationships] = useState([]); + const [loading, setLoading] = useState(false); + const [selectedCenter, setSelectedCenter] = useState(null); + const [relTypes, setRelTypes] = useState([]); + const [relFilter, setRelFilter] = useState(); + + const labelField = fields.find((f) => f.name === nodeLabelField)?.name || fields[1]?.name || 'name'; + + // 加载客户和关系数据 + useEffect(() => { + async function loadData() { + setLoading(true); + try { + // 加载所有客户 + let allCustomers: GraphNode[] = []; + let page = 1; + let hasMore = true; + while (hasMore) { + const result = await listPluginData(pluginId, entity, page, 100); + allCustomers = [ + ...allCustomers, + ...result.data.map((r) => ({ + id: r.id, + label: String(r.data[labelField] || '未命名'), + data: r.data, + })), + ]; + hasMore = result.data.length === 100 && allCustomers.length < result.total; + page++; + } + setCustomers(allCustomers); + + // 加载所有关系 + let allRels: GraphEdge[] = []; + page = 1; + hasMore = true; + const types = new Set(); + while (hasMore) { + const result = await listPluginData(pluginId, relationshipEntity, page, 100); + for (const r of result.data) { + const relType = String(r.data[edgeLabelField] || ''); + types.add(relType); + allRels.push({ + source: String(r.data[sourceField] || ''), + target: String(r.data[targetField] || ''), + label: relType, + }); + } + hasMore = result.data.length === 100 && allRels.length < result.total; + page++; + } + setRelationships(allRels); + setRelTypes(Array.from(types)); + } catch { + // 加载失败 + } + setLoading(false); + } + loadData(); + }, [pluginId, entity, relationshipEntity, sourceField, targetField, edgeLabelField, labelField]); + + // 绘制图谱 + useEffect(() => { + const canvas = canvasRef.current; + if (!canvas) return; + + const ctx = canvas.getContext('2d'); + if (!ctx) return; + + const width = canvas.parentElement?.clientWidth || 800; + const height = 600; + canvas.width = width; + canvas.height = height; + + ctx.clearRect(0, 0, width, height); + + // 过滤关系 + const filteredRels = relFilter + ? relationships.filter((r) => r.label === relFilter) + : relationships; + + // 确定显示的节点:如果有选中中心,展示 1 跳关系 + let visibleNodes: GraphNode[]; + let visibleEdges: GraphEdge[]; + + if (selectedCenter) { + visibleEdges = filteredRels.filter( + (r) => r.source === selectedCenter || r.target === selectedCenter, + ); + const visibleIds = new Set(); + visibleIds.add(selectedCenter); + for (const e of visibleEdges) { + visibleIds.add(e.source); + visibleIds.add(e.target); + } + visibleNodes = customers.filter((n) => visibleIds.has(n.id)); + } else { + visibleNodes = customers; + visibleEdges = filteredRels; + } + + if (visibleNodes.length === 0) { + return; + } + + // 简单力导向布局(圆形排列) + const centerX = width / 2; + const centerY = height / 2; + const radius = Math.min(width, height) * 0.35; + + const nodePositions = new Map(); + visibleNodes.forEach((node, i) => { + const angle = (2 * Math.PI * i) / visibleNodes.length - Math.PI / 2; + const x = centerX + radius * Math.cos(angle); + const y = centerY + radius * Math.sin(angle); + nodePositions.set(node.id, { x, y }); + }); + + // 绘制边 + const edgeTypeLabels: Record = { + parent_child: '母子', + sibling: '兄弟', + partner: '伙伴', + supplier: '供应商', + competitor: '竞争', + }; + + ctx.strokeStyle = '#999'; + ctx.lineWidth = 1.5; + for (const edge of visibleEdges) { + const from = nodePositions.get(edge.source); + const to = nodePositions.get(edge.target); + if (!from || !to) continue; + + ctx.beginPath(); + ctx.moveTo(from.x, from.y); + ctx.lineTo(to.x, to.y); + ctx.stroke(); + + // 边标签 + if (edge.label) { + const midX = (from.x + to.x) / 2; + const midY = (from.y + to.y) / 2; + ctx.fillStyle = '#666'; + ctx.font = '11px sans-serif'; + ctx.textAlign = 'center'; + ctx.fillText(edgeTypeLabels[edge.label] || edge.label, midX, midY - 4); + } + } + + // 绘制节点 + const nodeColors = new Map(); + const colors = ['#4F46E5', '#059669', '#D97706', '#DC2626', '#7C3AED', '#0891B2']; + visibleNodes.forEach((node, i) => { + nodeColors.set(node.id, colors[i % colors.length]); + }); + + for (const node of visibleNodes) { + const pos = nodePositions.get(node.id)!; + const color = nodeColors.get(node.id) || '#4F46E5'; + const isCenter = node.id === selectedCenter; + const r = isCenter ? 28 : 20; + + // 圆形节点 + ctx.beginPath(); + ctx.arc(pos.x, pos.y, r, 0, 2 * Math.PI); + ctx.fillStyle = isCenter ? color : color + '22'; + ctx.fill(); + ctx.strokeStyle = color; + ctx.lineWidth = isCenter ? 3 : 1.5; + ctx.stroke(); + + // 节点标签 + ctx.fillStyle = '#333'; + ctx.font = isCenter ? 'bold 12px sans-serif' : '11px sans-serif'; + ctx.textAlign = 'center'; + const displayLabel = + node.label.length > 6 ? node.label.slice(0, 6) + '...' : node.label; + ctx.fillText(displayLabel, pos.x, pos.y + r + 14); + } + }, [customers, relationships, selectedCenter, relFilter]); + + // 统计数据 + const stats = { + totalCustomers: customers.length, + totalRelationships: relationships.length, + centerNode: customers.find((c) => c.id === selectedCenter), + }; + + if (loading) { + return ( +
+ +
+ ); + } + + return ( +
+ + + + + + + + + + + + + + + + + + + + ({ + label: c.label, + value: c.id, + }))} + onChange={(v) => setSelectedCenter(v || null)} + /> + + } + > + {customers.length === 0 ? ( + + ) : ( + + )} + +
+ ); +}