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:
iven
2026-04-16 12:41:17 +08:00
parent e68fe8c1b1
commit 92789e6713
9 changed files with 760 additions and 26 deletions

View File

@@ -16,6 +16,8 @@ const Messages = lazy(() => import('./pages/Messages'));
const Settings = lazy(() => import('./pages/Settings'));
const PluginAdmin = lazy(() => import('./pages/PluginAdmin'));
const PluginCRUDPage = lazy(() => import('./pages/PluginCRUDPage'));
const PluginTabsPage = lazy(() => import('./pages/PluginTabsPage').then((m) => ({ default: m.PluginTabsPage })));
const PluginTreePage = lazy(() => import('./pages/PluginTreePage').then((m) => ({ default: m.PluginTreePage })));
function PrivateRoute({ children }: { children: React.ReactNode }) {
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
@@ -138,6 +140,8 @@ export default function App() {
<Route path="/messages" element={<Messages />} />
<Route path="/settings" element={<Settings />} />
<Route path="/plugins/admin" element={<PluginAdmin />} />
<Route path="/plugins/:pluginId/tabs/:pageLabel" element={<PluginTabsPage />} />
<Route path="/plugins/:pluginId/tree/:entityName" element={<PluginTreePage />} />
<Route path="/plugins/:pluginId/:entityName" element={<PluginCRUDPage />} />
</Routes>
</Suspense>

View File

@@ -15,6 +15,11 @@ import {
BulbOutlined,
BulbFilled,
AppstoreOutlined,
TeamOutlined,
TableOutlined,
TagsOutlined,
UserAddOutlined,
ApartmentOutlined as RelationshipIcon,
} from '@ant-design/icons';
import { useNavigate, useLocation } from 'react-router-dom';
import { useAppStore } from '../stores/app';
@@ -174,19 +179,36 @@ export default function MainLayout({ children }: { children: React.ReactNode })
<>
{!sidebarCollapsed && <div className="erp-sidebar-group"></div>}
<div className="erp-sidebar-menu">
{pluginMenuItems.map((item) => (
<SidebarMenuItem
key={item.key}
item={{
key: item.key,
icon: <AppstoreOutlined />,
label: item.label,
}}
isActive={currentPath === item.key}
collapsed={sidebarCollapsed}
onClick={() => navigate(item.key)}
/>
))}
{pluginMenuItems.map((item) => {
// 动态图标映射
const iconMap: Record<string, React.ReactNode> = {
AppstoreOutlined: <AppstoreOutlined />,
team: <TeamOutlined />,
TeamOutlined: <TeamOutlined />,
user: <UserOutlined />,
UserOutlined: <UserOutlined />,
message: <MessageOutlined />,
MessageOutlined: <MessageOutlined />,
tags: <TagsOutlined />,
TagsOutlined: <TagsOutlined />,
apartment: <ApartmentOutlined />,
ApartmentOutlined: <ApartmentOutlined />,
TableOutlined: <TableOutlined />,
};
return (
<SidebarMenuItem
key={item.key}
item={{
key: item.key,
icon: iconMap[item.icon] || <AppstoreOutlined />,
label: item.label,
}}
isActive={currentPath === item.key}
collapsed={sidebarCollapsed}
onClick={() => navigate(item.key)}
/>
);
})}
</div>
</>
)}

View 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>
);
}

View 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>
);
}

View File

@@ -1,13 +1,14 @@
import { create } from 'zustand';
import type { PluginInfo, PluginStatus } from '../api/plugins';
import { listPlugins } from '../api/plugins';
import type { PluginInfo, PluginStatus, PluginPageSchema, PluginSchemaResponse } from '../api/plugins';
import { listPlugins, getPluginSchema } from '../api/plugins';
export interface PluginMenuItem {
key: string;
icon: string;
label: string;
pluginId: string;
entity: string;
entity?: string;
pageType: 'crud' | 'tree' | 'tabs' | 'detail';
menuGroup?: string;
}
@@ -15,6 +16,7 @@ interface PluginStore {
plugins: PluginInfo[];
loading: boolean;
pluginMenuItems: PluginMenuItem[];
schemaCache: Record<string, PluginSchemaResponse>;
fetchPlugins: (page?: number, status?: PluginStatus) => Promise<void>;
refreshMenuItems: () => void;
}
@@ -23,12 +25,25 @@ export const usePluginStore = create<PluginStore>((set, get) => ({
plugins: [],
loading: false,
pluginMenuItems: [],
schemaCache: {},
fetchPlugins: async (page = 1, status?: PluginStatus) => {
set({ loading: true });
try {
const result = await listPlugins(page, 100, status);
set({ plugins: result.data });
// 预加载所有运行中插件的 schema
const schemas: Record<string, PluginSchemaResponse> = {};
for (const plugin of result.data) {
if (plugin.status !== 'running' && plugin.status !== 'enabled') continue;
try {
schemas[plugin.id] = await getPluginSchema(plugin.id) as PluginSchemaResponse;
} catch {
// schema 加载失败跳过
}
}
set({ schemaCache: schemas });
get().refreshMenuItems();
} finally {
set({ loading: false });
@@ -36,21 +51,59 @@ export const usePluginStore = create<PluginStore>((set, get) => ({
},
refreshMenuItems: () => {
const { plugins } = get();
const { plugins, schemaCache } = get();
const items: PluginMenuItem[] = [];
for (const plugin of plugins) {
if (plugin.status !== 'running' && plugin.status !== 'enabled') continue;
for (const entity of plugin.entities) {
items.push({
key: `/plugins/${plugin.id}/${entity.name}`,
icon: 'AppstoreOutlined',
label: entity.display_name || entity.name,
pluginId: plugin.id,
entity: entity.name,
menuGroup: undefined,
});
const schema = schemaCache[plugin.id];
const pages = (schema as { ui?: { pages: PluginPageSchema[] } })?.ui?.pages;
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,
pluginId: plugin.id,
pageType: 'tabs',
});
} 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,
pluginId: plugin.id,
entity: page.entity,
pageType: 'tree',
});
} 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,
pluginId: plugin.id,
entity: page.entity,
pageType: 'crud',
});
}
// detail 类型不生成菜单项
}
} else {
// 回退:从 entities 生成菜单
for (const entity of plugin.entities) {
items.push({
key: `/plugins/${plugin.id}/${entity.name}`,
icon: 'AppstoreOutlined',
label: entity.display_name || entity.name,
pluginId: plugin.id,
entity: entity.name,
pageType: 'crud',
});
}
}
}