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:
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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[] }
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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} 条` }
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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('');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function PluginTabsPage({ pluginId, label, tabs, entities }: PluginTabsPageProps) {
|
loadSchema();
|
||||||
const [activeKey, setActiveKey] = useState(tabs[0] && 'label' in tabs[0] ? tabs[0].label : '');
|
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 || []}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 类型不生成菜单项
|
||||||
|
|||||||
Reference in New Issue
Block a user