feat(crm): 创建 CRM 插件 crate + 前端 tabs/tree 页面类型 + 动态菜单
- CRM WASM 插件:Cargo.toml + src/lib.rs + plugin.toml(5 实体 + 9 权限 + 6 页面) - 注册 erp-plugin-crm 到 workspace members - PluginTabsPage: 通用标签页容器,递归渲染子页面 - PluginTreePage: 通用树形页面,前端构建树结构 - App.tsx: 新增 /tabs/:pageLabel 和 /tree/:entityName 路由 - plugin store: 从 manifest pages 生成菜单(支持 tabs 聚合) - MainLayout: 动态图标映射(team/user/message/tags/apartment)
This commit is contained in:
61
apps/web/src/pages/PluginTabsPage.tsx
Normal file
61
apps/web/src/pages/PluginTabsPage.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
import { useState } from 'react';
|
||||
import { Tabs } from 'antd';
|
||||
import {
|
||||
PluginPageSchema,
|
||||
PluginEntitySchema,
|
||||
PluginFieldSchema,
|
||||
} from '../api/plugins';
|
||||
|
||||
interface PluginTabsPageProps {
|
||||
pluginId: string;
|
||||
label: string;
|
||||
icon?: string;
|
||||
tabs: PluginPageSchema[];
|
||||
entities: PluginEntitySchema[];
|
||||
}
|
||||
|
||||
export function PluginTabsPage({ pluginId, label, tabs, entities }: PluginTabsPageProps) {
|
||||
const [activeKey, setActiveKey] = useState(tabs[0] && 'label' in tabs[0] ? tabs[0].label : '');
|
||||
|
||||
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 || []}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return <div>不支持的页面类型: {tab.type}</div>;
|
||||
};
|
||||
|
||||
const items = tabs.map((tab) => ({
|
||||
key: 'label' in tab ? tab.label : '',
|
||||
label: 'label' in tab ? tab.label : '',
|
||||
children: renderTabContent(tab),
|
||||
}));
|
||||
|
||||
return (
|
||||
<div style={{ padding: 24 }}>
|
||||
<Tabs activeKey={activeKey} onChange={setActiveKey} items={items} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
142
apps/web/src/pages/PluginTreePage.tsx
Normal file
142
apps/web/src/pages/PluginTreePage.tsx
Normal file
@@ -0,0 +1,142 @@
|
||||
import { useEffect, useState, useMemo } from 'react';
|
||||
import { Tree, Descriptions, Card, Empty, Spin } 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[];
|
||||
}
|
||||
|
||||
interface TreeNode {
|
||||
key: string;
|
||||
title: string;
|
||||
children: TreeNode[];
|
||||
raw: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export function PluginTreePage({
|
||||
pluginId,
|
||||
entity,
|
||||
idField,
|
||||
parentField,
|
||||
labelField,
|
||||
fields,
|
||||
}: PluginTreePageProps) {
|
||||
const [records, setRecords] = useState<PluginDataRecord[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [selectedNode, setSelectedNode] = useState<TreeNode | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
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);
|
||||
allRecords = [...allRecords, ...result.data];
|
||||
hasMore = result.data.length === 100 && allRecords.length < result.total;
|
||||
page++;
|
||||
}
|
||||
setRecords(allRecords);
|
||||
} catch {
|
||||
// 加载失败
|
||||
}
|
||||
setLoading(false);
|
||||
}
|
||||
loadAll();
|
||||
}, [pluginId, entity]);
|
||||
|
||||
// 构建树结构
|
||||
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={entity + ' 层级'} 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user