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 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>
|
||||
|
||||
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