feat(web): 完善插件前端页面 — 数据 API、筛选、视图切换和统计展示

- 新增 pluginData API 层:count/aggregate/stats 端点调用
- PluginCRUDPage 支持 visible_when 条件字段、筛选器下拉、视图切换
- PluginTabsPage 支持 tabs 布局和子实体 CRUD
- PluginTreePage 实现树形数据加载和节点展开/收起
- PluginGraphPage 实现关系图谱可视化展示
- PluginDashboardPage 实现统计卡片和聚合数据展示
- PluginAdmin 状态显示优化
- plugin store 增强 schema 加载逻辑和菜单生成
This commit is contained in:
iven
2026-04-16 23:42:57 +08:00
parent 3483395f5e
commit ae62e2ecb2
10 changed files with 401 additions and 217 deletions

View File

@@ -1,18 +1,8 @@
import { useEffect, useState, useRef } from 'react';
import { Card, Select, Space, Empty, Spin, Statistic, Row, Col } from 'antd';
import { useParams } from 'react-router-dom';
import { Card, Select, Space, Empty, Spin, Statistic, Row, Col, message, theme } 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[];
}
import { getPluginSchema, type PluginFieldSchema, type PluginSchemaResponse } from '../api/plugins';
interface GraphNode {
id: string;
@@ -26,20 +16,22 @@ interface GraphEdge {
label: string;
}
interface GraphConfig {
entity: string;
relationshipEntity: string;
sourceField: string;
targetField: string;
edgeLabelField: string;
nodeLabelField: string;
}
/**
* 客户关系图谱页面
* 使用 Canvas 2D 绘制简单关系图,避免引入 G6 大包
* 插件关系图谱页面 — 通过路由参数自加载 schema
* 路由: /plugins/:pluginId/graph/:entityName
*/
export function PluginGraphPage({
pluginId,
entity,
relationshipEntity,
sourceField,
targetField,
edgeLabelField,
nodeLabelField,
fields,
}: PluginGraphPageProps) {
export function PluginGraphPage() {
const { pluginId, entityName } = useParams<{ pluginId: string; entityName: string }>();
const { token } = theme.useToken();
const canvasRef = useRef<HTMLCanvasElement>(null);
const [customers, setCustomers] = useState<GraphNode[]>([]);
const [relationships, setRelationships] = useState<GraphEdge[]>([]);
@@ -47,20 +39,62 @@ export function PluginGraphPage({
const [selectedCenter, setSelectedCenter] = useState<string | null>(null);
const [relTypes, setRelTypes] = useState<string[]>([]);
const [relFilter, setRelFilter] = useState<string | undefined>();
const [graphConfig, setGraphConfig] = useState<GraphConfig | null>(null);
const [fields, setFields] = useState<PluginFieldSchema[]>([]);
const labelField = fields.find((f) => f.name === nodeLabelField)?.name || fields[1]?.name || 'name';
// 加载 schema
useEffect(() => {
if (!pluginId || !entityName) return;
const abortController = new AbortController();
async function loadSchema() {
try {
const schema: PluginSchemaResponse = await getPluginSchema(pluginId!);
if (abortController.signal.aborted) return;
const pages = schema.ui?.pages || [];
const graphPage = pages.find(
(p): p is typeof p & GraphConfig & { type: 'graph' } =>
p.type === 'graph' && p.entity === entityName,
);
if (graphPage) {
setGraphConfig({
entity: graphPage.entity,
relationshipEntity: graphPage.relationship_entity,
sourceField: graphPage.source_field,
targetField: graphPage.target_field,
edgeLabelField: graphPage.edge_label_field,
nodeLabelField: graphPage.node_label_field,
});
}
const entity = schema.entities?.find((e) => e.name === entityName);
if (entity) setFields(entity.fields);
} catch {
message.warning('Schema 加载失败,部分功能不可用');
}
}
loadSchema();
return () => abortController.abort();
}, [pluginId, entityName]);
// 加载客户和关系数据
useEffect(() => {
if (!pluginId || !graphConfig) return;
const abortController = new AbortController();
const gc = graphConfig;
const labelField = fields.find((f) => f.name === gc.nodeLabelField)?.name || fields[1]?.name || 'name';
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);
if (abortController.signal.aborted) return;
const result = await listPluginData(pluginId!, gc.entity, page, 100);
allCustomers = [
...allCustomers,
...result.data.map((r) => ({
@@ -72,36 +106,39 @@ export function PluginGraphPage({
hasMore = result.data.length === 100 && allCustomers.length < result.total;
page++;
}
if (abortController.signal.aborted) return;
setCustomers(allCustomers);
// 加载所有关系
let allRels: GraphEdge[] = [];
page = 1;
hasMore = true;
const types = new Set<string>();
while (hasMore) {
const result = await listPluginData(pluginId, relationshipEntity, page, 100);
if (abortController.signal.aborted) return;
const result = await listPluginData(pluginId!, gc.relationshipEntity, page, 100);
for (const r of result.data) {
const relType = String(r.data[edgeLabelField] || '');
const relType = String(r.data[gc.edgeLabelField] || '');
types.add(relType);
allRels.push({
source: String(r.data[sourceField] || ''),
target: String(r.data[targetField] || ''),
source: String(r.data[gc.sourceField] || ''),
target: String(r.data[gc.targetField] || ''),
label: relType,
});
}
hasMore = result.data.length === 100 && allRels.length < result.total;
page++;
}
if (abortController.signal.aborted) return;
setRelationships(allRels);
setRelTypes(Array.from(types));
} catch {
// 加载失败
message.warning('数据加载失败');
}
setLoading(false);
if (!abortController.signal.aborted) setLoading(false);
}
loadData();
}, [pluginId, entity, relationshipEntity, sourceField, targetField, edgeLabelField, labelField]);
return () => abortController.abort();
}, [pluginId, graphConfig, fields]);
// 绘制图谱
useEffect(() => {
@@ -113,17 +150,26 @@ export function PluginGraphPage({
const width = canvas.parentElement?.clientWidth || 800;
const height = 600;
canvas.width = width;
canvas.height = height;
// Fix 9: 高 DPI 支持
const dpr = window.devicePixelRatio || 1;
canvas.width = width * dpr;
canvas.height = height * dpr;
canvas.style.width = `${width}px`;
canvas.style.height = `${height}px`;
ctx.scale(dpr, dpr);
ctx.clearRect(0, 0, width, height);
// Fix 11: 暗色主题支持 — 通过 Ant Design token 获取主题色
const textColor = token.colorText;
const lineColor = token.colorBorder;
// 过滤关系
const filteredRels = relFilter
? relationships.filter((r) => r.label === relFilter)
: relationships;
// 确定显示的节点:如果有选中中心,展示 1 跳关系
let visibleNodes: GraphNode[];
let visibleEdges: GraphEdge[];
@@ -143,11 +189,8 @@ export function PluginGraphPage({
visibleEdges = filteredRels;
}
if (visibleNodes.length === 0) {
return;
}
if (visibleNodes.length === 0) return;
// 简单力导向布局(圆形排列)
const centerX = width / 2;
const centerY = height / 2;
const radius = Math.min(width, height) * 0.35;
@@ -160,7 +203,6 @@ export function PluginGraphPage({
nodePositions.set(node.id, { x, y });
});
// 绘制边
const edgeTypeLabels: Record<string, string> = {
parent_child: '母子',
sibling: '兄弟',
@@ -169,7 +211,8 @@ export function PluginGraphPage({
competitor: '竞争',
};
ctx.strokeStyle = '#999';
// 绘制边
ctx.strokeStyle = lineColor;
ctx.lineWidth = 1.5;
for (const edge of visibleEdges) {
const from = nodePositions.get(edge.source);
@@ -181,20 +224,21 @@ export function PluginGraphPage({
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.fillStyle = textColor;
ctx.globalAlpha = 0.6;
ctx.font = '11px sans-serif';
ctx.textAlign = 'center';
ctx.fillText(edgeTypeLabels[edge.label] || edge.label, midX, midY - 4);
ctx.globalAlpha = 1;
}
}
// 绘制节点
const nodeColors = new Map<string, string>();
const colors = ['#4F46E5', '#059669', '#D97706', '#DC2626', '#7C3AED', '#0891B2'];
const nodeColors = new Map<string, string>();
visibleNodes.forEach((node, i) => {
nodeColors.set(node.id, colors[i % colors.length]);
});
@@ -205,7 +249,6 @@ export function PluginGraphPage({
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';
@@ -214,17 +257,15 @@ export function PluginGraphPage({
ctx.lineWidth = isCenter ? 3 : 1.5;
ctx.stroke();
// 节点标签
ctx.fillStyle = '#333';
ctx.fillStyle = textColor;
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]);
}, [customers, relationships, selectedCenter, relFilter, token]);
// 统计数据
const stats = {
totalCustomers: customers.length,
totalRelationships: relationships.length,
@@ -232,11 +273,7 @@ export function PluginGraphPage({
};
if (loading) {
return (
<div style={{ padding: 24, textAlign: 'center' }}>
<Spin />
</div>
);
return <div style={{ padding: 24, textAlign: 'center' }}><Spin /></div>;
}
return (
@@ -281,10 +318,7 @@ export function PluginGraphPage({
showSearch
style={{ width: 200 }}
optionFilterProp="label"
options={customers.map((c) => ({
label: c.label,
value: c.id,
}))}
options={customers.map((c) => ({ label: c.label, value: c.id }))}
onChange={(v) => setSelectedCenter(v || null)}
/>
</Space>