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}`);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
export async function getPluginSchema(id: string) {
|
||||
const { data } = await client.get<{ success: boolean; data: Record<string, unknown> }>(
|
||||
export async function getPluginSchema(id: string): Promise<PluginSchemaResponse> {
|
||||
const { data } = await client.get<{ success: boolean; data: PluginSchemaResponse }>(
|
||||
`/admin/plugins/${id}/schema`,
|
||||
);
|
||||
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: '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: '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 =
|
||||
| { type: 'fields'; label: string; fields: string[] }
|
||||
|
||||
@@ -18,8 +18,6 @@ import {
|
||||
TeamOutlined,
|
||||
TableOutlined,
|
||||
TagsOutlined,
|
||||
UserAddOutlined,
|
||||
ApartmentOutlined as RelationshipIcon,
|
||||
} from '@ant-design/icons';
|
||||
import { useNavigate, useLocation } from 'react-router-dom';
|
||||
import { useAppStore } from '../stores/app';
|
||||
|
||||
@@ -21,7 +21,6 @@ import {
|
||||
CloudDownloadOutlined,
|
||||
DeleteOutlined,
|
||||
ReloadOutlined,
|
||||
AppstoreOutlined,
|
||||
HeartOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import type { PluginInfo, PluginStatus } from '../api/plugins';
|
||||
@@ -267,7 +266,7 @@ export default function PluginAdmin() {
|
||||
}}
|
||||
maxCount={1}
|
||||
accept=".wasm"
|
||||
fileList={wasmFile ? [wasmFile as unknown as Parameters<typeof Upload>[0]] : []}
|
||||
fileList={[]}
|
||||
onRemove={() => setWasmFile(null)}
|
||||
>
|
||||
<Button icon={<UploadOutlined />}>选择 WASM 文件</Button>
|
||||
|
||||
@@ -31,14 +31,14 @@ import {
|
||||
createPluginData,
|
||||
updatePluginData,
|
||||
deletePluginData,
|
||||
PluginDataListOptions,
|
||||
type PluginDataListOptions,
|
||||
} from '../api/pluginData';
|
||||
import {
|
||||
getPluginSchema,
|
||||
PluginFieldSchema,
|
||||
PluginEntitySchema,
|
||||
PluginPageSchema,
|
||||
PluginSectionSchema,
|
||||
type PluginFieldSchema,
|
||||
type PluginEntitySchema,
|
||||
type PluginPageSchema,
|
||||
type PluginSectionSchema,
|
||||
} from '../api/plugins';
|
||||
|
||||
const { Search } = Input;
|
||||
@@ -133,8 +133,12 @@ export default function PluginCRUDPage({
|
||||
// 加载 schema
|
||||
useEffect(() => {
|
||||
if (!pluginId) return;
|
||||
getPluginSchema(pluginId)
|
||||
.then((schema) => {
|
||||
const abortController = new AbortController();
|
||||
|
||||
async function loadSchema() {
|
||||
try {
|
||||
const schema = await getPluginSchema(pluginId!);
|
||||
if (abortController.signal.aborted) return;
|
||||
const entities: PluginEntitySchema[] = (schema as { entities?: PluginEntitySchema[] }).entities || [];
|
||||
setAllEntities(entities);
|
||||
const entity = entities.find((e) => e.name === entityName);
|
||||
@@ -145,7 +149,6 @@ export default function PluginCRUDPage({
|
||||
const ui = (schema as { ui?: { pages: PluginPageSchema[] } }).ui;
|
||||
if (ui?.pages) {
|
||||
setAllPages(ui.pages);
|
||||
// 找到 detail 页面的 sections
|
||||
const detailPage = ui.pages.find(
|
||||
(p) => p.type === 'detail' && 'entity' in p && p.entity === entityName,
|
||||
);
|
||||
@@ -153,19 +156,21 @@ export default function PluginCRUDPage({
|
||||
setDetailSections(detailPage.sections);
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
// schema 加载失败时仍可使用
|
||||
});
|
||||
} catch {
|
||||
message.warning('Schema 加载失败,部分功能不可用');
|
||||
}
|
||||
}
|
||||
|
||||
loadSchema();
|
||||
return () => abortController.abort();
|
||||
}, [pluginId, entityName]);
|
||||
|
||||
const fetchData = useCallback(
|
||||
async (p = page) => {
|
||||
async (p = page, overrides?: { search?: string; sort_by?: string; sort_order?: 'asc' | 'desc' }) => {
|
||||
if (!pluginId || !entityName) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const options: PluginDataListOptions = {};
|
||||
// 自动添加 filterField 过滤(detail 页面内嵌 CRUD)
|
||||
const mergedFilters = { ...filters };
|
||||
if (filterField && filterValue) {
|
||||
mergedFilters[filterField] = filterValue;
|
||||
@@ -173,10 +178,13 @@ export default function PluginCRUDPage({
|
||||
if (Object.keys(mergedFilters).length > 0) {
|
||||
options.filter = mergedFilters;
|
||||
}
|
||||
if (searchText) options.search = searchText;
|
||||
if (sortBy) {
|
||||
options.sort_by = sortBy;
|
||||
options.sort_order = sortOrder;
|
||||
const effectiveSearch = overrides?.search ?? searchText;
|
||||
if (effectiveSearch) options.search = effectiveSearch;
|
||||
const effectiveSortBy = overrides?.sort_by ?? sortBy;
|
||||
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);
|
||||
setRecords(
|
||||
@@ -505,7 +513,7 @@ export default function PluginCRUDPage({
|
||||
onSearch={(value) => {
|
||||
setSearchText(value);
|
||||
setPage(1);
|
||||
fetchData(1);
|
||||
fetchData(1, { search: value });
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
@@ -529,6 +537,21 @@ export default function PluginCRUDPage({
|
||||
rowKey="_id"
|
||||
loading={loading}
|
||||
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={
|
||||
compact
|
||||
? { pageSize: 5, showTotal: (t) => `共 ${t} 条` }
|
||||
|
||||
@@ -1,77 +1,90 @@
|
||||
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 {
|
||||
TeamOutlined,
|
||||
RiseOutlined,
|
||||
PhoneOutlined,
|
||||
TagsOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { listPluginData } from '../api/pluginData';
|
||||
import { PluginFieldSchema, PluginEntitySchema } from '../api/plugins';
|
||||
|
||||
interface PluginDashboardPageProps {
|
||||
pluginId: string;
|
||||
entities: PluginEntitySchema[];
|
||||
}
|
||||
|
||||
interface AggregationResult {
|
||||
key: string;
|
||||
count: number;
|
||||
}
|
||||
import { countPluginData, aggregatePluginData, type AggregateItem } from '../api/pluginData';
|
||||
import { getPluginSchema, type PluginEntitySchema, type PluginSchemaResponse } from '../api/plugins';
|
||||
|
||||
/**
|
||||
* 插件统计概览页面
|
||||
* 使用 listPluginData 加载全量数据前端聚合
|
||||
* 插件统计概览页面 — 通过路由参数自加载 schema,使用后端 aggregate API
|
||||
* 路由: /plugins/:pluginId/dashboard
|
||||
*/
|
||||
export function PluginDashboardPage({ pluginId, entities }: PluginDashboardPageProps) {
|
||||
export function PluginDashboardPage() {
|
||||
const { pluginId } = useParams<{ pluginId: string }>();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [selectedEntity, setSelectedEntity] = useState<string>(
|
||||
entities[0]?.name || '',
|
||||
);
|
||||
const [entities, setEntities] = useState<PluginEntitySchema[]>([]);
|
||||
const [selectedEntity, setSelectedEntity] = useState<string>('');
|
||||
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 filterableFields = currentEntity?.fields.filter((f) => f.filterable) || [];
|
||||
|
||||
// 使用后端 count/aggregate API
|
||||
useEffect(() => {
|
||||
if (!pluginId || !selectedEntity) return;
|
||||
setLoading(true);
|
||||
const abortController = new AbortController();
|
||||
|
||||
async function loadData() {
|
||||
setLoading(true);
|
||||
try {
|
||||
let allData: Record<string, unknown>[] = [];
|
||||
let page = 1;
|
||||
let hasMore = true;
|
||||
let total = 0;
|
||||
while (hasMore) {
|
||||
const result = await listPluginData(pluginId, selectedEntity!, page, 200);
|
||||
allData = [...allData, ...result.data.map((r) => r.data)];
|
||||
total = result.total;
|
||||
hasMore = result.data.length === 200 && allData.length < result.total;
|
||||
page++;
|
||||
}
|
||||
const total = await countPluginData(pluginId!, selectedEntity!);
|
||||
if (abortController.signal.aborted) return;
|
||||
setTotalCount(total);
|
||||
|
||||
const aggs: AggregationResult[] = [];
|
||||
const aggs: AggregateItem[] = [];
|
||||
for (const field of filterableFields) {
|
||||
const grouped = new Map<string, number>();
|
||||
for (const item of allData) {
|
||||
const val = String(item[field.name] ?? '(空)');
|
||||
grouped.set(val, (grouped.get(val) || 0) + 1);
|
||||
}
|
||||
for (const [key, count] of grouped) {
|
||||
aggs.push({ key: `${field.display_name || field.name}: ${key}`, count });
|
||||
if (abortController.signal.aborted) return;
|
||||
try {
|
||||
const items = await aggregatePluginData(pluginId!, selectedEntity!, field.name);
|
||||
for (const item of items) {
|
||||
aggs.push({
|
||||
key: `${field.display_name || field.name}: ${item.key || '(空)'}`,
|
||||
count: item.count,
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// 单个字段聚合失败不影响其他字段
|
||||
}
|
||||
}
|
||||
if (abortController.signal.aborted) return;
|
||||
setAggregations(aggs);
|
||||
} catch {
|
||||
// 加载失败
|
||||
message.warning('统计数据加载失败');
|
||||
}
|
||||
setLoading(false);
|
||||
if (!abortController.signal.aborted) setLoading(false);
|
||||
}
|
||||
|
||||
loadData();
|
||||
return () => abortController.abort();
|
||||
}, [pluginId, selectedEntity, filterableFields.length]);
|
||||
|
||||
const iconMap: Record<string, React.ReactNode> = {
|
||||
@@ -83,11 +96,7 @@ export function PluginDashboardPage({ pluginId, entities }: PluginDashboardPageP
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div style={{ padding: 24, textAlign: 'center' }}>
|
||||
<Spin />
|
||||
</div>
|
||||
);
|
||||
return <div style={{ padding: 24, textAlign: 'center' }}><Spin /></div>;
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -97,7 +106,7 @@ export function PluginDashboardPage({ pluginId, entities }: PluginDashboardPageP
|
||||
size="small"
|
||||
extra={
|
||||
<Select
|
||||
value={selectedEntity}
|
||||
value={selectedEntity || undefined}
|
||||
style={{ width: 150 }}
|
||||
options={entities.map((e) => ({
|
||||
label: e.display_name || e.name,
|
||||
@@ -111,9 +120,9 @@ export function PluginDashboardPage({ pluginId, entities }: PluginDashboardPageP
|
||||
<Col span={24}>
|
||||
<Card>
|
||||
<Statistic
|
||||
title={currentEntity?.display_name || selectedEntity + ' 总数'}
|
||||
title={currentEntity?.display_name || (selectedEntity ? selectedEntity + ' 总数' : '总数')}
|
||||
value={totalCount}
|
||||
prefix={iconMap[selectedEntity] || <TeamOutlined />}
|
||||
prefix={selectedEntity ? (iconMap[selectedEntity] || <TeamOutlined />) : <TeamOutlined />}
|
||||
valueStyle={{ color: '#4F46E5' }}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -1,46 +1,67 @@
|
||||
import { useState } from 'react';
|
||||
import { Tabs } from 'antd';
|
||||
import {
|
||||
PluginPageSchema,
|
||||
PluginEntitySchema,
|
||||
PluginFieldSchema,
|
||||
} from '../api/plugins';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { Tabs, Spin, message } from 'antd';
|
||||
import { getPluginSchema, type PluginPageSchema, type PluginSchemaResponse } from '../api/plugins';
|
||||
import PluginCRUDPage from './PluginCRUDPage';
|
||||
import { PluginTreePage } from './PluginTreePage';
|
||||
|
||||
interface PluginTabsPageProps {
|
||||
pluginId: string;
|
||||
label: string;
|
||||
icon?: string;
|
||||
tabs: PluginPageSchema[];
|
||||
entities: PluginEntitySchema[];
|
||||
}
|
||||
/**
|
||||
* 插件 Tabs 页面 — 通过路由参数自加载 schema
|
||||
* 路由: /plugins/:pluginId/tabs/:pageLabel
|
||||
*/
|
||||
export function PluginTabsPage() {
|
||||
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) {
|
||||
const [activeKey, setActiveKey] = useState(tabs[0] && 'label' in tabs[0] ? tabs[0].label : '');
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
loadSchema();
|
||||
return () => abortController.abort();
|
||||
}, [pluginId, pageLabel]);
|
||||
|
||||
if (loading) {
|
||||
return <div style={{ padding: 24, textAlign: 'center' }}><Spin /></div>;
|
||||
}
|
||||
|
||||
const renderTabContent = (tab: PluginPageSchema) => {
|
||||
if (tab.type === 'crud') {
|
||||
// 懒加载 PluginCRUDPage 避免循环依赖
|
||||
const PluginCRUDPage = require('./PluginCRUDPage').default;
|
||||
return (
|
||||
<PluginCRUDPage
|
||||
pluginIdOverride={pluginId}
|
||||
entityOverride={tab.entity}
|
||||
enableSearch={tab.enable_search}
|
||||
enableViews={tab.enable_views}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (tab.type === 'tree') {
|
||||
const PluginTreePage = require('./PluginTreePage').PluginTreePage;
|
||||
const entity = entities.find((e) => e.name === tab.entity);
|
||||
return (
|
||||
<PluginTreePage
|
||||
pluginId={pluginId}
|
||||
entity={tab.entity}
|
||||
idField={tab.id_field}
|
||||
parentField={tab.parent_field}
|
||||
labelField={tab.label_field}
|
||||
fields={entity?.fields || []}
|
||||
pluginIdOverride={pluginId}
|
||||
entityOverride={tab.entity}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,17 +1,14 @@
|
||||
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 { listPluginData, PluginDataRecord } from '../api/pluginData';
|
||||
import { PluginFieldSchema } from '../api/plugins';
|
||||
|
||||
interface PluginTreePageProps {
|
||||
pluginId: string;
|
||||
entity: string;
|
||||
idField: string;
|
||||
parentField: string;
|
||||
labelField: string;
|
||||
fields: PluginFieldSchema[];
|
||||
}
|
||||
import { listPluginData, type PluginDataRecord } from '../api/pluginData';
|
||||
import {
|
||||
getPluginSchema,
|
||||
type PluginFieldSchema,
|
||||
type PluginPageSchema,
|
||||
type PluginSchemaResponse,
|
||||
} from '../api/plugins';
|
||||
|
||||
interface TreeNode {
|
||||
key: string;
|
||||
@@ -20,47 +17,105 @@ interface TreeNode {
|
||||
raw: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export function PluginTreePage({
|
||||
pluginId,
|
||||
entity,
|
||||
idField,
|
||||
parentField,
|
||||
labelField,
|
||||
fields,
|
||||
}: PluginTreePageProps) {
|
||||
interface PluginTreePageProps {
|
||||
pluginIdOverride?: string;
|
||||
entityOverride?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 插件树形页面 — 通过路由参数自加载 schema
|
||||
* 路由: /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 [loading, setLoading] = useState(false);
|
||||
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(() => {
|
||||
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() {
|
||||
setLoading(true);
|
||||
try {
|
||||
// 加载全量数据构建树
|
||||
let allRecords: PluginDataRecord[] = [];
|
||||
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!, entityName!, page, 100);
|
||||
allRecords = [...allRecords, ...result.data];
|
||||
hasMore = result.data.length === 100 && allRecords.length < result.total;
|
||||
page++;
|
||||
}
|
||||
setRecords(allRecords);
|
||||
if (!abortController.signal.aborted) {
|
||||
setRecords(allRecords);
|
||||
}
|
||||
} catch {
|
||||
// 加载失败
|
||||
message.warning('数据加载失败');
|
||||
}
|
||||
setLoading(false);
|
||||
if (!abortController.signal.aborted) setLoading(false);
|
||||
}
|
||||
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 nodeMap = new Map<string, TreeNode>();
|
||||
const rootNodes: TreeNode[] = [];
|
||||
|
||||
// 创建所有节点
|
||||
for (const record of records) {
|
||||
const data = record.data;
|
||||
const key = String(data[idField] || record.id);
|
||||
@@ -73,7 +128,6 @@ export function PluginTreePage({
|
||||
});
|
||||
}
|
||||
|
||||
// 构建父子关系
|
||||
for (const record of records) {
|
||||
const data = record.data;
|
||||
const key = String(data[idField] || record.id);
|
||||
@@ -99,26 +153,17 @@ export function PluginTreePage({
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div style={{ padding: 24, textAlign: 'center' }}>
|
||||
<Spin />
|
||||
</div>
|
||||
);
|
||||
return <div style={{ padding: 24, textAlign: 'center' }}><Spin /></div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ padding: 24, display: 'flex', gap: 16 }}>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<Card title={entity + ' 层级'} size="small">
|
||||
<Card title={(entityName || '') + ' 层级'} size="small">
|
||||
{treeData.length === 0 ? (
|
||||
<Empty description="暂无数据" />
|
||||
) : (
|
||||
<Tree
|
||||
showLine
|
||||
defaultExpandAll
|
||||
treeData={treeData}
|
||||
onSelect={onSelect}
|
||||
/>
|
||||
<Tree showLine defaultExpandAll treeData={treeData} onSelect={onSelect} />
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
@@ -8,7 +8,7 @@ export interface PluginMenuItem {
|
||||
label: string;
|
||||
pluginId: string;
|
||||
entity?: string;
|
||||
pageType: 'crud' | 'tree' | 'tabs' | 'detail';
|
||||
pageType: 'crud' | 'tree' | 'tabs' | 'detail' | 'graph' | 'dashboard';
|
||||
menuGroup?: string;
|
||||
}
|
||||
|
||||
@@ -63,31 +63,47 @@ export const usePluginStore = create<PluginStore>((set, get) => ({
|
||||
if (pages && pages.length > 0) {
|
||||
for (const page of pages) {
|
||||
if (page.type === 'tabs') {
|
||||
// tabs 类型聚合为一个菜单项
|
||||
items.push({
|
||||
key: `/plugins/${plugin.id}/tabs/${encodeURIComponent('label' in page ? page.label : '')}`,
|
||||
icon: ('icon' in page ? page.icon : 'AppstoreOutlined') || 'AppstoreOutlined',
|
||||
label: ('label' in page ? page.label : plugin.name) as string,
|
||||
key: `/plugins/${plugin.id}/tabs/${encodeURIComponent(page.label)}`,
|
||||
icon: page.icon || 'AppstoreOutlined',
|
||||
label: page.label,
|
||||
pluginId: plugin.id,
|
||||
pageType: 'tabs',
|
||||
pageType: 'tabs' as const,
|
||||
});
|
||||
} else if (page.type === 'tree') {
|
||||
items.push({
|
||||
key: `/plugins/${plugin.id}/tree/${page.entity}`,
|
||||
icon: ('icon' in page ? page.icon : 'ApartmentOutlined') || 'ApartmentOutlined',
|
||||
label: ('label' in page ? page.label : page.entity) as string,
|
||||
icon: page.icon || 'ApartmentOutlined',
|
||||
label: page.label,
|
||||
pluginId: plugin.id,
|
||||
entity: page.entity,
|
||||
pageType: 'tree',
|
||||
pageType: 'tree' as const,
|
||||
});
|
||||
} else if (page.type === 'crud') {
|
||||
items.push({
|
||||
key: `/plugins/${plugin.id}/${page.entity}`,
|
||||
icon: ('icon' in page ? page.icon : 'TableOutlined') || 'TableOutlined',
|
||||
label: ('label' in page ? page.label : page.entity) as string,
|
||||
icon: page.icon || 'TableOutlined',
|
||||
label: page.label,
|
||||
pluginId: plugin.id,
|
||||
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 类型不生成菜单项
|
||||
|
||||
Reference in New Issue
Block a user