= {
+ 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 (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}