feat(web): 新增 PluginGraphPage 关系图谱和 PluginDashboardPage 统计概览
- PluginGraphPage: Canvas 2D 绘制客户关系图谱,支持中心节点选择和关系类型筛选 - PluginDashboardPage: 全量数据前端聚合统计,支持按 filterable 字段分组计数 - App.tsx: 注册 /graph/:entityName 和 /dashboard 路由
This commit is contained in:
@@ -18,6 +18,8 @@ const PluginAdmin = lazy(() => import('./pages/PluginAdmin'));
|
|||||||
const PluginCRUDPage = lazy(() => import('./pages/PluginCRUDPage'));
|
const PluginCRUDPage = lazy(() => import('./pages/PluginCRUDPage'));
|
||||||
const PluginTabsPage = lazy(() => import('./pages/PluginTabsPage').then((m) => ({ default: m.PluginTabsPage })));
|
const PluginTabsPage = lazy(() => import('./pages/PluginTabsPage').then((m) => ({ default: m.PluginTabsPage })));
|
||||||
const PluginTreePage = lazy(() => import('./pages/PluginTreePage').then((m) => ({ default: m.PluginTreePage })));
|
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 }) {
|
function PrivateRoute({ children }: { children: React.ReactNode }) {
|
||||||
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
|
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
|
||||||
@@ -142,6 +144,8 @@ export default function App() {
|
|||||||
<Route path="/plugins/admin" element={<PluginAdmin />} />
|
<Route path="/plugins/admin" element={<PluginAdmin />} />
|
||||||
<Route path="/plugins/:pluginId/tabs/:pageLabel" element={<PluginTabsPage />} />
|
<Route path="/plugins/:pluginId/tabs/:pageLabel" element={<PluginTabsPage />} />
|
||||||
<Route path="/plugins/:pluginId/tree/:entityName" element={<PluginTreePage />} />
|
<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 />} />
|
<Route path="/plugins/:pluginId/:entityName" element={<PluginCRUDPage />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</Suspense>
|
</Suspense>
|
||||||
|
|||||||
147
apps/web/src/pages/PluginDashboardPage.tsx
Normal file
147
apps/web/src/pages/PluginDashboardPage.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
304
apps/web/src/pages/PluginGraphPage.tsx
Normal file
304
apps/web/src/pages/PluginGraphPage.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user