Files
hms/apps/web/src/pages/PluginTreePage.tsx
iven ae62e2ecb2 feat(web): 完善插件前端页面 — 数据 API、筛选、视图切换和统计展示
- 新增 pluginData API 层:count/aggregate/stats 端点调用
- PluginCRUDPage 支持 visible_when 条件字段、筛选器下拉、视图切换
- PluginTabsPage 支持 tabs 布局和子实体 CRUD
- PluginTreePage 实现树形数据加载和节点展开/收起
- PluginGraphPage 实现关系图谱可视化展示
- PluginDashboardPage 实现统计卡片和聚合数据展示
- PluginAdmin 状态显示优化
- plugin store 增强 schema 加载逻辑和菜单生成
2026-04-16 23:42:57 +08:00

188 lines
5.9 KiB
TypeScript

import { useEffect, useState, useMemo } from 'react';
import { useParams } from 'react-router-dom';
import { Tree, Descriptions, Card, Empty, Spin, message } from 'antd';
import type { TreeProps } from 'antd';
import { listPluginData, type PluginDataRecord } from '../api/pluginData';
import {
getPluginSchema,
type PluginFieldSchema,
type PluginPageSchema,
type PluginSchemaResponse,
} from '../api/plugins';
interface TreeNode {
key: string;
title: string;
children: TreeNode[];
raw: Record<string, unknown>;
}
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) {
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++;
}
if (!abortController.signal.aborted) {
setRecords(allRecords);
}
} catch {
message.warning('数据加载失败');
}
if (!abortController.signal.aborted) setLoading(false);
}
loadAll();
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);
const title = String(data[labelField] || '未命名');
nodeMap.set(key, {
key,
title,
children: [],
raw: { ...data, _id: record.id, _version: record.version },
});
}
for (const record of records) {
const data = record.data;
const key = String(data[idField] || record.id);
const parentKey = data[parentField] ? String(data[parentField]) : null;
const node = nodeMap.get(key)!;
if (parentKey && nodeMap.has(parentKey)) {
nodeMap.get(parentKey)!.children.push(node);
} else {
rootNodes.push(node);
}
}
return rootNodes;
}, [records, idField, parentField, labelField]);
const onSelect: TreeProps['onSelect'] = (selectedKeys, info) => {
if (selectedKeys.length > 0) {
setSelectedNode(info.node as unknown as TreeNode);
} else {
setSelectedNode(null);
}
};
if (loading) {
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={(entityName || '') + ' 层级'} size="small">
{treeData.length === 0 ? (
<Empty description="暂无数据" />
) : (
<Tree showLine defaultExpandAll treeData={treeData} onSelect={onSelect} />
)}
</Card>
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<Card title="节点详情" size="small">
{selectedNode ? (
<Descriptions column={1} bordered size="small">
{fields.map((field) => (
<Descriptions.Item key={field.name} label={field.display_name || field.name}>
{String(selectedNode.raw[field.name] ?? '-')}
</Descriptions.Item>
))}
</Descriptions>
) : (
<Empty description="点击左侧节点查看详情" />
)}
</Card>
</div>
</div>
);
}