feat(web): 新增 PluginGraphPage 关系图谱和 PluginDashboardPage 统计概览

- PluginGraphPage: Canvas 2D 绘制客户关系图谱,支持中心节点选择和关系类型筛选
- PluginDashboardPage: 全量数据前端聚合统计,支持按 filterable 字段分组计数
- App.tsx: 注册 /graph/:entityName 和 /dashboard 路由
This commit is contained in:
iven
2026-04-16 16:15:32 +08:00
parent a6d3a0efcc
commit 169e6d1fe5
3 changed files with 455 additions and 0 deletions

View File

@@ -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() {
<Route path="/plugins/admin" element={<PluginAdmin />} />
<Route path="/plugins/:pluginId/tabs/:pageLabel" element={<PluginTabsPage />} />
<Route path="/plugins/:pluginId/tree/:entityName" element={<PluginTreePage />} />
<Route path="/plugins/:pluginId/graph/:entityName" element={<PluginGraphPage />} />
<Route path="/plugins/:pluginId/dashboard" element={<PluginDashboardPage />} />
<Route path="/plugins/:pluginId/:entityName" element={<PluginCRUDPage />} />
</Routes>
</Suspense>

View File

@@ -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<string>(
entities[0]?.name || '',
);
const [totalCount, setTotalCount] = useState(0);
const [aggregations, setAggregations] = useState<AggregationResult[]>([]);
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<string, unknown>[] = [];
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<string, number>();
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<string, React.ReactNode> = {
customer: <TeamOutlined />,
contact: <TeamOutlined />,
communication: <PhoneOutlined />,
customer_tag: <TagsOutlined />,
customer_relationship: <RiseOutlined />,
};
if (loading) {
return (
<div style={{ padding: 24, textAlign: 'center' }}>
<Spin />
</div>
);
}
return (
<div style={{ padding: 24 }}>
<Card
title="统计概览"
size="small"
extra={
<Select
value={selectedEntity}
style={{ width: 150 }}
options={entities.map((e) => ({
label: e.display_name || e.name,
value: e.name,
}))}
onChange={setSelectedEntity}
/>
}
>
<Row gutter={[16, 16]}>
<Col span={24}>
<Card>
<Statistic
title={currentEntity?.display_name || selectedEntity + ' 总数'}
value={totalCount}
prefix={iconMap[selectedEntity] || <TeamOutlined />}
valueStyle={{ color: '#4F46E5' }}
/>
</Card>
</Col>
{aggregations.length > 0 && (
<Col span={24}>
<Card title="分组统计" size="small">
<Row gutter={[8, 8]}>
{aggregations.slice(0, 20).map((agg, idx) => (
<Col span={6} key={idx}>
<Tag color="blue" style={{ width: '100%', textAlign: 'center' }}>
{agg.key}: {agg.count}
</Tag>
</Col>
))}
</Row>
</Card>
</Col>
)}
{aggregations.length === 0 && totalCount === 0 && (
<Col span={24}>
<Empty description="暂无数据" />
</Col>
)}
</Row>
</Card>
</div>
);
}

View File

@@ -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<string, unknown>;
}
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<HTMLCanvasElement>(null);
const [customers, setCustomers] = useState<GraphNode[]>([]);
const [relationships, setRelationships] = useState<GraphEdge[]>([]);
const [loading, setLoading] = useState(false);
const [selectedCenter, setSelectedCenter] = useState<string | null>(null);
const [relTypes, setRelTypes] = useState<string[]>([]);
const [relFilter, setRelFilter] = useState<string | undefined>();
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<string>();
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<string>();
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<string, { x: number; y: number }>();
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<string, string> = {
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<string, string>();
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 (
<div style={{ padding: 24, textAlign: 'center' }}>
<Spin />
</div>
);
}
return (
<div style={{ padding: 24 }}>
<Row gutter={16} style={{ marginBottom: 16 }}>
<Col span={8}>
<Card size="small">
<Statistic title="客户总数" value={stats.totalCustomers} />
</Card>
</Col>
<Col span={8}>
<Card size="small">
<Statistic title="关系总数" value={stats.totalRelationships} />
</Card>
</Col>
<Col span={8}>
<Card size="small">
<Statistic
title="当前中心"
value={stats.centerNode?.label || '未选择'}
valueStyle={{ fontSize: 20 }}
/>
</Card>
</Col>
</Row>
<Card
title="客户关系图谱"
size="small"
extra={
<Space>
<Select
placeholder="筛选关系类型"
allowClear
style={{ width: 150 }}
options={relTypes.map((t) => ({ label: t, value: t }))}
onChange={(v) => setRelFilter(v)}
/>
<Select
placeholder="选择中心客户"
allowClear
showSearch
style={{ width: 200 }}
optionFilterProp="label"
options={customers.map((c) => ({
label: c.label,
value: c.id,
}))}
onChange={(v) => setSelectedCenter(v || null)}
/>
</Space>
}
>
{customers.length === 0 ? (
<Empty description="暂无客户数据" />
) : (
<canvas
ref={canvasRef}
style={{ width: '100%', height: 600, border: '1px solid #f0f0f0', borderRadius: 8 }}
/>
)}
</Card>
</div>
);
}