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

@@ -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>
);
}