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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user