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

@@ -86,3 +86,40 @@ export async function deletePluginData(
) { ) {
await client.delete(`/plugins/${pluginId}/${entity}/${id}`); await client.delete(`/plugins/${pluginId}/${entity}/${id}`);
} }
export async function countPluginData(
pluginId: string,
entity: string,
options?: { filter?: Record<string, string>; search?: string },
) {
const params: Record<string, string> = {};
if (options?.filter) params.filter = JSON.stringify(options.filter);
if (options?.search) params.search = options.search;
const { data } = await client.get<{ success: boolean; data: number }>(
`/plugins/${pluginId}/${entity}/count`,
{ params },
);
return data.data;
}
export interface AggregateItem {
key: string;
count: number;
}
export async function aggregatePluginData(
pluginId: string,
entity: string,
groupBy: string,
filter?: Record<string, string>,
) {
const params: Record<string, string> = { group_by: groupBy };
if (filter) params.filter = JSON.stringify(filter);
const { data } = await client.get<{ success: boolean; data: AggregateItem[] }>(
`/plugins/${pluginId}/${entity}/aggregate`,
{ params },
);
return data.data;
}

View File

@@ -113,8 +113,8 @@ export async function updatePluginConfig(id: string, config: Record<string, unkn
return data.data; return data.data;
} }
export async function getPluginSchema(id: string) { export async function getPluginSchema(id: string): Promise<PluginSchemaResponse> {
const { data } = await client.get<{ success: boolean; data: Record<string, unknown> }>( const { data } = await client.get<{ success: boolean; data: PluginSchemaResponse }>(
`/admin/plugins/${id}/schema`, `/admin/plugins/${id}/schema`,
); );
return data.data; return data.data;
@@ -155,7 +155,9 @@ export type PluginPageSchema =
| { type: 'crud'; entity: string; label: string; icon?: string; enable_search?: boolean; enable_views?: string[] } | { type: 'crud'; entity: string; label: string; icon?: string; enable_search?: boolean; enable_views?: string[] }
| { type: 'tree'; entity: string; label: string; icon?: string; id_field: string; parent_field: string; label_field: string } | { type: 'tree'; entity: string; label: string; icon?: string; id_field: string; parent_field: string; label_field: string }
| { type: 'detail'; entity: string; label: string; sections: PluginSectionSchema[] } | { type: 'detail'; entity: string; label: string; sections: PluginSectionSchema[] }
| { type: 'tabs'; label: string; icon?: string; tabs: PluginPageSchema[] }; | { type: 'tabs'; label: string; icon?: string; tabs: PluginPageSchema[] }
| { type: 'graph'; entity: string; label: string; relationship_entity: string; source_field: string; target_field: string; edge_label_field: string; node_label_field: string }
| { type: 'dashboard'; label: string };
export type PluginSectionSchema = export type PluginSectionSchema =
| { type: 'fields'; label: string; fields: string[] } | { type: 'fields'; label: string; fields: string[] }

View File

@@ -18,8 +18,6 @@ import {
TeamOutlined, TeamOutlined,
TableOutlined, TableOutlined,
TagsOutlined, TagsOutlined,
UserAddOutlined,
ApartmentOutlined as RelationshipIcon,
} from '@ant-design/icons'; } from '@ant-design/icons';
import { useNavigate, useLocation } from 'react-router-dom'; import { useNavigate, useLocation } from 'react-router-dom';
import { useAppStore } from '../stores/app'; import { useAppStore } from '../stores/app';

View File

@@ -21,7 +21,6 @@ import {
CloudDownloadOutlined, CloudDownloadOutlined,
DeleteOutlined, DeleteOutlined,
ReloadOutlined, ReloadOutlined,
AppstoreOutlined,
HeartOutlined, HeartOutlined,
} from '@ant-design/icons'; } from '@ant-design/icons';
import type { PluginInfo, PluginStatus } from '../api/plugins'; import type { PluginInfo, PluginStatus } from '../api/plugins';
@@ -267,7 +266,7 @@ export default function PluginAdmin() {
}} }}
maxCount={1} maxCount={1}
accept=".wasm" accept=".wasm"
fileList={wasmFile ? [wasmFile as unknown as Parameters<typeof Upload>[0]] : []} fileList={[]}
onRemove={() => setWasmFile(null)} onRemove={() => setWasmFile(null)}
> >
<Button icon={<UploadOutlined />}> WASM </Button> <Button icon={<UploadOutlined />}> WASM </Button>

View File

@@ -31,14 +31,14 @@ import {
createPluginData, createPluginData,
updatePluginData, updatePluginData,
deletePluginData, deletePluginData,
PluginDataListOptions, type PluginDataListOptions,
} from '../api/pluginData'; } from '../api/pluginData';
import { import {
getPluginSchema, getPluginSchema,
PluginFieldSchema, type PluginFieldSchema,
PluginEntitySchema, type PluginEntitySchema,
PluginPageSchema, type PluginPageSchema,
PluginSectionSchema, type PluginSectionSchema,
} from '../api/plugins'; } from '../api/plugins';
const { Search } = Input; const { Search } = Input;
@@ -133,8 +133,12 @@ export default function PluginCRUDPage({
// 加载 schema // 加载 schema
useEffect(() => { useEffect(() => {
if (!pluginId) return; if (!pluginId) return;
getPluginSchema(pluginId) const abortController = new AbortController();
.then((schema) => {
async function loadSchema() {
try {
const schema = await getPluginSchema(pluginId!);
if (abortController.signal.aborted) return;
const entities: PluginEntitySchema[] = (schema as { entities?: PluginEntitySchema[] }).entities || []; const entities: PluginEntitySchema[] = (schema as { entities?: PluginEntitySchema[] }).entities || [];
setAllEntities(entities); setAllEntities(entities);
const entity = entities.find((e) => e.name === entityName); const entity = entities.find((e) => e.name === entityName);
@@ -145,7 +149,6 @@ export default function PluginCRUDPage({
const ui = (schema as { ui?: { pages: PluginPageSchema[] } }).ui; const ui = (schema as { ui?: { pages: PluginPageSchema[] } }).ui;
if (ui?.pages) { if (ui?.pages) {
setAllPages(ui.pages); setAllPages(ui.pages);
// 找到 detail 页面的 sections
const detailPage = ui.pages.find( const detailPage = ui.pages.find(
(p) => p.type === 'detail' && 'entity' in p && p.entity === entityName, (p) => p.type === 'detail' && 'entity' in p && p.entity === entityName,
); );
@@ -153,19 +156,21 @@ export default function PluginCRUDPage({
setDetailSections(detailPage.sections); setDetailSections(detailPage.sections);
} }
} }
}) } catch {
.catch(() => { message.warning('Schema 加载失败,部分功能不可用');
// schema 加载失败时仍可使用 }
}); }
loadSchema();
return () => abortController.abort();
}, [pluginId, entityName]); }, [pluginId, entityName]);
const fetchData = useCallback( const fetchData = useCallback(
async (p = page) => { async (p = page, overrides?: { search?: string; sort_by?: string; sort_order?: 'asc' | 'desc' }) => {
if (!pluginId || !entityName) return; if (!pluginId || !entityName) return;
setLoading(true); setLoading(true);
try { try {
const options: PluginDataListOptions = {}; const options: PluginDataListOptions = {};
// 自动添加 filterField 过滤detail 页面内嵌 CRUD
const mergedFilters = { ...filters }; const mergedFilters = { ...filters };
if (filterField && filterValue) { if (filterField && filterValue) {
mergedFilters[filterField] = filterValue; mergedFilters[filterField] = filterValue;
@@ -173,10 +178,13 @@ export default function PluginCRUDPage({
if (Object.keys(mergedFilters).length > 0) { if (Object.keys(mergedFilters).length > 0) {
options.filter = mergedFilters; options.filter = mergedFilters;
} }
if (searchText) options.search = searchText; const effectiveSearch = overrides?.search ?? searchText;
if (sortBy) { if (effectiveSearch) options.search = effectiveSearch;
options.sort_by = sortBy; const effectiveSortBy = overrides?.sort_by ?? sortBy;
options.sort_order = sortOrder; const effectiveSortOrder = overrides?.sort_order ?? sortOrder;
if (effectiveSortBy) {
options.sort_by = effectiveSortBy;
options.sort_order = effectiveSortOrder;
} }
const result = await listPluginData(pluginId, entityName, p, 20, options); const result = await listPluginData(pluginId, entityName, p, 20, options);
setRecords( setRecords(
@@ -505,7 +513,7 @@ export default function PluginCRUDPage({
onSearch={(value) => { onSearch={(value) => {
setSearchText(value); setSearchText(value);
setPage(1); setPage(1);
fetchData(1); fetchData(1, { search: value });
}} }}
/> />
)} )}
@@ -529,6 +537,21 @@ export default function PluginCRUDPage({
rowKey="_id" rowKey="_id"
loading={loading} loading={loading}
size={compact ? 'small' : undefined} size={compact ? 'small' : undefined}
onChange={(_pagination, _filters, sorter) => {
if (!Array.isArray(sorter) && sorter.field) {
const newSortBy = String(sorter.field);
const newSortOrder = sorter.order === 'ascend' ? 'asc' as const : 'desc' as const;
setSortBy(newSortBy);
setSortOrder(newSortOrder);
setPage(1);
fetchData(1, { sort_by: newSortBy, sort_order: newSortOrder });
} else if (!sorter || (Array.isArray(sorter) && sorter.length === 0)) {
setSortBy(undefined);
setSortOrder('desc');
setPage(1);
fetchData(1, { sort_by: undefined, sort_order: undefined });
}
}}
pagination={ pagination={
compact compact
? { pageSize: 5, showTotal: (t) => `${t}` } ? { pageSize: 5, showTotal: (t) => `${t}` }

View File

@@ -1,77 +1,90 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { Card, Row, Col, Statistic, Spin, Empty, Select, Tag } from 'antd'; import { useParams } from 'react-router-dom';
import { Card, Row, Col, Statistic, Spin, Empty, Select, Tag, message } from 'antd';
import { import {
TeamOutlined, TeamOutlined,
RiseOutlined, RiseOutlined,
PhoneOutlined, PhoneOutlined,
TagsOutlined, TagsOutlined,
} from '@ant-design/icons'; } from '@ant-design/icons';
import { listPluginData } from '../api/pluginData'; import { countPluginData, aggregatePluginData, type AggregateItem } from '../api/pluginData';
import { PluginFieldSchema, PluginEntitySchema } from '../api/plugins'; import { getPluginSchema, type PluginEntitySchema, type PluginSchemaResponse } from '../api/plugins';
interface PluginDashboardPageProps {
pluginId: string;
entities: PluginEntitySchema[];
}
interface AggregationResult {
key: string;
count: number;
}
/** /**
* 插件统计概览页面 * 插件统计概览页面 — 通过路由参数自加载 schema使用后端 aggregate API
* 使用 listPluginData 加载全量数据前端聚合 * 路由: /plugins/:pluginId/dashboard
*/ */
export function PluginDashboardPage({ pluginId, entities }: PluginDashboardPageProps) { export function PluginDashboardPage() {
const { pluginId } = useParams<{ pluginId: string }>();
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [selectedEntity, setSelectedEntity] = useState<string>( const [entities, setEntities] = useState<PluginEntitySchema[]>([]);
entities[0]?.name || '', const [selectedEntity, setSelectedEntity] = useState<string>('');
);
const [totalCount, setTotalCount] = useState(0); const [totalCount, setTotalCount] = useState(0);
const [aggregations, setAggregations] = useState<AggregationResult[]>([]); const [aggregations, setAggregations] = useState<AggregateItem[]>([]);
// 加载 schema 获取 entities
useEffect(() => {
if (!pluginId) return;
const abortController = new AbortController();
async function loadSchema() {
try {
const schema: PluginSchemaResponse = await getPluginSchema(pluginId!);
if (abortController.signal.aborted) return;
const entityList = schema.entities || [];
setEntities(entityList);
if (entityList.length > 0) {
setSelectedEntity(entityList[0].name);
}
} catch {
message.warning('Schema 加载失败,部分功能不可用');
}
}
loadSchema();
return () => abortController.abort();
}, [pluginId]);
const currentEntity = entities.find((e) => e.name === selectedEntity); const currentEntity = entities.find((e) => e.name === selectedEntity);
const filterableFields = currentEntity?.fields.filter((f) => f.filterable) || []; const filterableFields = currentEntity?.fields.filter((f) => f.filterable) || [];
// 使用后端 count/aggregate API
useEffect(() => { useEffect(() => {
if (!pluginId || !selectedEntity) return; if (!pluginId || !selectedEntity) return;
setLoading(true); const abortController = new AbortController();
async function loadData() { async function loadData() {
setLoading(true);
try { try {
let allData: Record<string, unknown>[] = []; const total = await countPluginData(pluginId!, selectedEntity!);
let page = 1; if (abortController.signal.aborted) return;
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); setTotalCount(total);
const aggs: AggregationResult[] = []; const aggs: AggregateItem[] = [];
for (const field of filterableFields) { for (const field of filterableFields) {
const grouped = new Map<string, number>(); if (abortController.signal.aborted) return;
for (const item of allData) { try {
const val = String(item[field.name] ?? '(空)'); const items = await aggregatePluginData(pluginId!, selectedEntity!, field.name);
grouped.set(val, (grouped.get(val) || 0) + 1); for (const item of items) {
aggs.push({
key: `${field.display_name || field.name}: ${item.key || '(空)'}`,
count: item.count,
});
} }
for (const [key, count] of grouped) { } catch {
aggs.push({ key: `${field.display_name || field.name}: ${key}`, count }); // 单个字段聚合失败不影响其他字段
} }
} }
if (abortController.signal.aborted) return;
setAggregations(aggs); setAggregations(aggs);
} catch { } catch {
// 加载失败 message.warning('统计数据加载失败');
} }
setLoading(false); if (!abortController.signal.aborted) setLoading(false);
} }
loadData(); loadData();
return () => abortController.abort();
}, [pluginId, selectedEntity, filterableFields.length]); }, [pluginId, selectedEntity, filterableFields.length]);
const iconMap: Record<string, React.ReactNode> = { const iconMap: Record<string, React.ReactNode> = {
@@ -83,11 +96,7 @@ export function PluginDashboardPage({ pluginId, entities }: PluginDashboardPageP
}; };
if (loading) { if (loading) {
return ( return <div style={{ padding: 24, textAlign: 'center' }}><Spin /></div>;
<div style={{ padding: 24, textAlign: 'center' }}>
<Spin />
</div>
);
} }
return ( return (
@@ -97,7 +106,7 @@ export function PluginDashboardPage({ pluginId, entities }: PluginDashboardPageP
size="small" size="small"
extra={ extra={
<Select <Select
value={selectedEntity} value={selectedEntity || undefined}
style={{ width: 150 }} style={{ width: 150 }}
options={entities.map((e) => ({ options={entities.map((e) => ({
label: e.display_name || e.name, label: e.display_name || e.name,
@@ -111,9 +120,9 @@ export function PluginDashboardPage({ pluginId, entities }: PluginDashboardPageP
<Col span={24}> <Col span={24}>
<Card> <Card>
<Statistic <Statistic
title={currentEntity?.display_name || selectedEntity + ' 总数'} title={currentEntity?.display_name || (selectedEntity ? selectedEntity + ' 总数' : '总数')}
value={totalCount} value={totalCount}
prefix={iconMap[selectedEntity] || <TeamOutlined />} prefix={selectedEntity ? (iconMap[selectedEntity] || <TeamOutlined />) : <TeamOutlined />}
valueStyle={{ color: '#4F46E5' }} valueStyle={{ color: '#4F46E5' }}
/> />
</Card> </Card>

View File

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

View File

@@ -1,46 +1,67 @@
import { useState } from 'react'; import { useEffect, useState } from 'react';
import { Tabs } from 'antd'; import { useParams } from 'react-router-dom';
import { import { Tabs, Spin, message } from 'antd';
PluginPageSchema, import { getPluginSchema, type PluginPageSchema, type PluginSchemaResponse } from '../api/plugins';
PluginEntitySchema, import PluginCRUDPage from './PluginCRUDPage';
PluginFieldSchema, import { PluginTreePage } from './PluginTreePage';
} from '../api/plugins';
interface PluginTabsPageProps { /**
pluginId: string; * 插件 Tabs 页面 — 通过路由参数自加载 schema
label: string; * 路由: /plugins/:pluginId/tabs/:pageLabel
icon?: string; */
tabs: PluginPageSchema[]; export function PluginTabsPage() {
entities: PluginEntitySchema[]; const { pluginId, pageLabel } = useParams<{ pluginId: string; pageLabel: string }>();
} const [loading, setLoading] = useState(true);
const [tabs, setTabs] = useState<PluginPageSchema[]>([]);
const [activeKey, setActiveKey] = useState('');
export function PluginTabsPage({ pluginId, label, tabs, entities }: PluginTabsPageProps) { useEffect(() => {
const [activeKey, setActiveKey] = useState(tabs[0] && 'label' in tabs[0] ? tabs[0].label : ''); if (!pluginId || !pageLabel) return;
const abortController = new AbortController();
async function loadSchema() {
try {
const schema: PluginSchemaResponse = await getPluginSchema(pluginId!);
const pages = schema.ui?.pages || [];
const tabsPage = pages.find(
(p): p is PluginPageSchema & { type: 'tabs' } =>
p.type === 'tabs' && p.label === pageLabel,
);
if (tabsPage && 'tabs' in tabsPage) {
setTabs(tabsPage.tabs);
const firstLabel = tabsPage.tabs.find((t) => 'label' in t)?.label || '';
setActiveKey(firstLabel);
}
} catch {
message.warning('Schema 加载失败,部分功能不可用');
} finally {
if (!abortController.signal.aborted) setLoading(false);
}
}
loadSchema();
return () => abortController.abort();
}, [pluginId, pageLabel]);
if (loading) {
return <div style={{ padding: 24, textAlign: 'center' }}><Spin /></div>;
}
const renderTabContent = (tab: PluginPageSchema) => { const renderTabContent = (tab: PluginPageSchema) => {
if (tab.type === 'crud') { if (tab.type === 'crud') {
// 懒加载 PluginCRUDPage 避免循环依赖
const PluginCRUDPage = require('./PluginCRUDPage').default;
return ( return (
<PluginCRUDPage <PluginCRUDPage
pluginIdOverride={pluginId} pluginIdOverride={pluginId}
entityOverride={tab.entity} entityOverride={tab.entity}
enableSearch={tab.enable_search}
enableViews={tab.enable_views} enableViews={tab.enable_views}
/> />
); );
} }
if (tab.type === 'tree') { if (tab.type === 'tree') {
const PluginTreePage = require('./PluginTreePage').PluginTreePage;
const entity = entities.find((e) => e.name === tab.entity);
return ( return (
<PluginTreePage <PluginTreePage
pluginId={pluginId} pluginIdOverride={pluginId}
entity={tab.entity} entityOverride={tab.entity}
idField={tab.id_field}
parentField={tab.parent_field}
labelField={tab.label_field}
fields={entity?.fields || []}
/> />
); );
} }

View File

@@ -1,17 +1,14 @@
import { useEffect, useState, useMemo } from 'react'; import { useEffect, useState, useMemo } from 'react';
import { Tree, Descriptions, Card, Empty, Spin } from 'antd'; import { useParams } from 'react-router-dom';
import { Tree, Descriptions, Card, Empty, Spin, message } from 'antd';
import type { TreeProps } from 'antd'; import type { TreeProps } from 'antd';
import { listPluginData, PluginDataRecord } from '../api/pluginData'; import { listPluginData, type PluginDataRecord } from '../api/pluginData';
import { PluginFieldSchema } from '../api/plugins'; import {
getPluginSchema,
interface PluginTreePageProps { type PluginFieldSchema,
pluginId: string; type PluginPageSchema,
entity: string; type PluginSchemaResponse,
idField: string; } from '../api/plugins';
parentField: string;
labelField: string;
fields: PluginFieldSchema[];
}
interface TreeNode { interface TreeNode {
key: string; key: string;
@@ -20,47 +17,105 @@ interface TreeNode {
raw: Record<string, unknown>; raw: Record<string, unknown>;
} }
export function PluginTreePage({ interface PluginTreePageProps {
pluginId, pluginIdOverride?: string;
entity, entityOverride?: string;
idField, }
parentField,
labelField, /**
fields, * 插件树形页面 — 通过路由参数自加载 schema
}: PluginTreePageProps) { * 路由: /plugins/:pluginId/tree/:entityName
* 也支持通过 props 覆盖(用于 tabs 内嵌)
*/
export function PluginTreePage({ pluginIdOverride, entityOverride }: PluginTreePageProps = {}) {
const routeParams = useParams<{ pluginId: string; entityName: string }>();
const pluginId = pluginIdOverride || routeParams.pluginId || '';
const entityName = entityOverride || routeParams.entityName || '';
const [records, setRecords] = useState<PluginDataRecord[]>([]); const [records, setRecords] = useState<PluginDataRecord[]>([]);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [selectedNode, setSelectedNode] = useState<TreeNode | null>(null); const [selectedNode, setSelectedNode] = useState<TreeNode | null>(null);
const [fields, setFields] = useState<PluginFieldSchema[]>([]);
const [treeConfig, setTreeConfig] = useState<{
idField: string;
parentField: string;
labelField: string;
} | null>(null);
// 加载 schema
useEffect(() => { 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 entity = schema.entities?.find((e) => e.name === entityName);
if (entity) {
setFields(entity.fields);
}
const pages = schema.ui?.pages || [];
const treePage = pages.find(
(p): p is PluginPageSchema & { type: 'tree'; entity: string; id_field: string; parent_field: string; label_field: string } =>
p.type === 'tree' && p.entity === entityName,
);
if (treePage) {
setTreeConfig({
idField: treePage.id_field,
parentField: treePage.parent_field,
labelField: treePage.label_field,
});
}
} catch {
message.warning('Schema 加载失败,部分功能不可用');
}
}
loadSchema();
return () => abortController.abort();
}, [pluginId, entityName]);
// 加载数据
useEffect(() => {
if (!pluginId || !entityName) return;
const abortController = new AbortController();
async function loadAll() { async function loadAll() {
setLoading(true); setLoading(true);
try { try {
// 加载全量数据构建树
let allRecords: PluginDataRecord[] = []; let allRecords: PluginDataRecord[] = [];
let page = 1; let page = 1;
let hasMore = true; let hasMore = true;
while (hasMore) { while (hasMore) {
const result = await listPluginData(pluginId, entity, page, 100); if (abortController.signal.aborted) return;
const result = await listPluginData(pluginId!, entityName!, page, 100);
allRecords = [...allRecords, ...result.data]; allRecords = [...allRecords, ...result.data];
hasMore = result.data.length === 100 && allRecords.length < result.total; hasMore = result.data.length === 100 && allRecords.length < result.total;
page++; page++;
} }
if (!abortController.signal.aborted) {
setRecords(allRecords); setRecords(allRecords);
} catch {
// 加载失败
} }
setLoading(false); } catch {
message.warning('数据加载失败');
}
if (!abortController.signal.aborted) setLoading(false);
} }
loadAll(); loadAll();
}, [pluginId, entity]); return () => abortController.abort();
}, [pluginId, entityName]);
const idField = treeConfig?.idField || 'id';
const parentField = treeConfig?.parentField || 'parent_id';
const labelField = treeConfig?.labelField || fields[1]?.name || 'name';
// 构建树结构 // 构建树结构
const treeData = useMemo(() => { const treeData = useMemo(() => {
const nodeMap = new Map<string, TreeNode>(); const nodeMap = new Map<string, TreeNode>();
const rootNodes: TreeNode[] = []; const rootNodes: TreeNode[] = [];
// 创建所有节点
for (const record of records) { for (const record of records) {
const data = record.data; const data = record.data;
const key = String(data[idField] || record.id); const key = String(data[idField] || record.id);
@@ -73,7 +128,6 @@ export function PluginTreePage({
}); });
} }
// 构建父子关系
for (const record of records) { for (const record of records) {
const data = record.data; const data = record.data;
const key = String(data[idField] || record.id); const key = String(data[idField] || record.id);
@@ -99,26 +153,17 @@ export function PluginTreePage({
}; };
if (loading) { if (loading) {
return ( return <div style={{ padding: 24, textAlign: 'center' }}><Spin /></div>;
<div style={{ padding: 24, textAlign: 'center' }}>
<Spin />
</div>
);
} }
return ( return (
<div style={{ padding: 24, display: 'flex', gap: 16 }}> <div style={{ padding: 24, display: 'flex', gap: 16 }}>
<div style={{ flex: 1, minWidth: 0 }}> <div style={{ flex: 1, minWidth: 0 }}>
<Card title={entity + ' 层级'} size="small"> <Card title={(entityName || '') + ' 层级'} size="small">
{treeData.length === 0 ? ( {treeData.length === 0 ? (
<Empty description="暂无数据" /> <Empty description="暂无数据" />
) : ( ) : (
<Tree <Tree showLine defaultExpandAll treeData={treeData} onSelect={onSelect} />
showLine
defaultExpandAll
treeData={treeData}
onSelect={onSelect}
/>
)} )}
</Card> </Card>
</div> </div>

View File

@@ -8,7 +8,7 @@ export interface PluginMenuItem {
label: string; label: string;
pluginId: string; pluginId: string;
entity?: string; entity?: string;
pageType: 'crud' | 'tree' | 'tabs' | 'detail'; pageType: 'crud' | 'tree' | 'tabs' | 'detail' | 'graph' | 'dashboard';
menuGroup?: string; menuGroup?: string;
} }
@@ -63,31 +63,47 @@ export const usePluginStore = create<PluginStore>((set, get) => ({
if (pages && pages.length > 0) { if (pages && pages.length > 0) {
for (const page of pages) { for (const page of pages) {
if (page.type === 'tabs') { if (page.type === 'tabs') {
// tabs 类型聚合为一个菜单项
items.push({ items.push({
key: `/plugins/${plugin.id}/tabs/${encodeURIComponent('label' in page ? page.label : '')}`, key: `/plugins/${plugin.id}/tabs/${encodeURIComponent(page.label)}`,
icon: ('icon' in page ? page.icon : 'AppstoreOutlined') || 'AppstoreOutlined', icon: page.icon || 'AppstoreOutlined',
label: ('label' in page ? page.label : plugin.name) as string, label: page.label,
pluginId: plugin.id, pluginId: plugin.id,
pageType: 'tabs', pageType: 'tabs' as const,
}); });
} else if (page.type === 'tree') { } else if (page.type === 'tree') {
items.push({ items.push({
key: `/plugins/${plugin.id}/tree/${page.entity}`, key: `/plugins/${plugin.id}/tree/${page.entity}`,
icon: ('icon' in page ? page.icon : 'ApartmentOutlined') || 'ApartmentOutlined', icon: page.icon || 'ApartmentOutlined',
label: ('label' in page ? page.label : page.entity) as string, label: page.label,
pluginId: plugin.id, pluginId: plugin.id,
entity: page.entity, entity: page.entity,
pageType: 'tree', pageType: 'tree' as const,
}); });
} else if (page.type === 'crud') { } else if (page.type === 'crud') {
items.push({ items.push({
key: `/plugins/${plugin.id}/${page.entity}`, key: `/plugins/${plugin.id}/${page.entity}`,
icon: ('icon' in page ? page.icon : 'TableOutlined') || 'TableOutlined', icon: page.icon || 'TableOutlined',
label: ('label' in page ? page.label : page.entity) as string, label: page.label,
pluginId: plugin.id, pluginId: plugin.id,
entity: page.entity, entity: page.entity,
pageType: 'crud', pageType: 'crud' as const,
});
} else if (page.type === 'graph') {
items.push({
key: `/plugins/${plugin.id}/graph/${page.entity}`,
icon: 'ApartmentOutlined',
label: page.label,
pluginId: plugin.id,
entity: page.entity,
pageType: 'graph' as const,
});
} else if (page.type === 'dashboard') {
items.push({
key: `/plugins/${plugin.id}/dashboard`,
icon: 'DashboardOutlined',
label: page.label,
pluginId: plugin.id,
pageType: 'dashboard' as const,
}); });
} }
// detail 类型不生成菜单项 // detail 类型不生成菜单项