feat(plugin): 集成 WASM 插件系统到主服务并修复链路问题

- 新增 erp-plugin crate:插件管理、WASM 运行时、动态表、数据 CRUD
- 新增前端插件管理页面(PluginAdmin/PluginCRUDPage)和 API 层
- 新增插件数据迁移(plugins/plugin_entities/plugin_event_subscriptions)
- 新增权限补充迁移(为已有租户补充 plugin.admin/plugin.list 权限)
- 修复 PluginAdmin 页面 InstallOutlined 图标不存在的崩溃问题
- 修复 settings 唯一索引迁移顺序错误(先去重再建索引)
- 更新 wiki 和 CLAUDE.md 反映插件系统集成状态
- 新增 dev.ps1 一键启动脚本
This commit is contained in:
iven
2026-04-15 23:32:02 +08:00
parent 7e8fabb095
commit ff352a4c24
46 changed files with 6723 additions and 19 deletions

View File

@@ -14,6 +14,8 @@ const Organizations = lazy(() => import('./pages/Organizations'));
const Workflow = lazy(() => import('./pages/Workflow'));
const Messages = lazy(() => import('./pages/Messages'));
const Settings = lazy(() => import('./pages/Settings'));
const PluginAdmin = lazy(() => import('./pages/PluginAdmin'));
const PluginCRUDPage = lazy(() => import('./pages/PluginCRUDPage'));
function PrivateRoute({ children }: { children: React.ReactNode }) {
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
@@ -135,6 +137,8 @@ export default function App() {
<Route path="/workflow" element={<Workflow />} />
<Route path="/messages" element={<Messages />} />
<Route path="/settings" element={<Settings />} />
<Route path="/plugins/admin" element={<PluginAdmin />} />
<Route path="/plugins/:pluginId/:entityName" element={<PluginCRUDPage />} />
</Routes>
</Suspense>
</MainLayout>

View File

@@ -0,0 +1,71 @@
import client from './client';
export interface PluginDataRecord {
id: string;
data: Record<string, unknown>;
created_at?: string;
updated_at?: string;
version?: number;
}
interface PaginatedDataResponse {
data: PluginDataRecord[];
total: number;
page: number;
page_size: number;
total_pages: number;
}
export async function listPluginData(
pluginId: string,
entity: string,
page = 1,
pageSize = 20,
) {
const { data } = await client.get<{ success: boolean; data: PaginatedDataResponse }>(
`/plugins/${pluginId}/${entity}`,
{ params: { page, page_size: pageSize } },
);
return data.data;
}
export async function getPluginData(pluginId: string, entity: string, id: string) {
const { data } = await client.get<{ success: boolean; data: PluginDataRecord }>(
`/plugins/${pluginId}/${entity}/${id}`,
);
return data.data;
}
export async function createPluginData(
pluginId: string,
entity: string,
recordData: Record<string, unknown>,
) {
const { data } = await client.post<{ success: boolean; data: PluginDataRecord }>(
`/plugins/${pluginId}/${entity}`,
{ data: recordData },
);
return data.data;
}
export async function updatePluginData(
pluginId: string,
entity: string,
id: string,
recordData: Record<string, unknown>,
version: number,
) {
const { data } = await client.put<{ success: boolean; data: PluginDataRecord }>(
`/plugins/${pluginId}/${entity}/${id}`,
{ data: recordData, version },
);
return data.data;
}
export async function deletePluginData(
pluginId: string,
entity: string,
id: string,
) {
await client.delete(`/plugins/${pluginId}/${entity}/${id}`);
}

121
apps/web/src/api/plugins.ts Normal file
View File

@@ -0,0 +1,121 @@
import client from './client';
export interface PaginatedResponse<T> {
data: T[];
total: number;
page: number;
page_size: number;
total_pages: number;
}
export interface PluginEntityInfo {
name: string;
display_name: string;
table_name: string;
}
export interface PluginPermissionInfo {
code: string;
name: string;
description: string;
}
export type PluginStatus = 'uploaded' | 'installed' | 'enabled' | 'running' | 'disabled' | 'uninstalled';
export interface PluginInfo {
id: string;
name: string;
version: string;
description?: string;
author?: string;
status: PluginStatus;
config: Record<string, unknown>;
installed_at?: string;
enabled_at?: string;
entities: PluginEntityInfo[];
permissions?: PluginPermissionInfo[];
record_version: number;
}
export async function listPlugins(page = 1, pageSize = 20, status?: string) {
const { data } = await client.get<{ success: boolean; data: PaginatedResponse<PluginInfo> }>(
'/admin/plugins',
{ params: { page, page_size: pageSize, status: status || undefined } },
);
return data.data;
}
export async function getPlugin(id: string) {
const { data } = await client.get<{ success: boolean; data: PluginInfo }>(
`/admin/plugins/${id}`,
);
return data.data;
}
export async function uploadPlugin(wasmFile: File, manifestToml: string) {
const formData = new FormData();
formData.append('wasm', wasmFile);
formData.append('manifest', manifestToml);
const { data } = await client.post<{ success: boolean; data: PluginInfo }>(
'/admin/plugins/upload',
formData,
{ headers: { 'Content-Type': 'multipart/form-data' }, timeout: 60000 },
);
return data.data;
}
export async function installPlugin(id: string) {
const { data } = await client.post<{ success: boolean; data: PluginInfo }>(
`/admin/plugins/${id}/install`,
);
return data.data;
}
export async function enablePlugin(id: string) {
const { data } = await client.post<{ success: boolean; data: PluginInfo }>(
`/admin/plugins/${id}/enable`,
);
return data.data;
}
export async function disablePlugin(id: string) {
const { data } = await client.post<{ success: boolean; data: PluginInfo }>(
`/admin/plugins/${id}/disable`,
);
return data.data;
}
export async function uninstallPlugin(id: string) {
const { data } = await client.post<{ success: boolean; data: PluginInfo }>(
`/admin/plugins/${id}/uninstall`,
);
return data.data;
}
export async function purgePlugin(id: string) {
await client.delete(`/admin/plugins/${id}`);
}
export async function getPluginHealth(id: string) {
const { data } = await client.get<{
success: boolean;
data: { plugin_id: string; status: string; details: Record<string, unknown> };
}>(`/admin/plugins/${id}/health`);
return data.data;
}
export async function updatePluginConfig(id: string, config: Record<string, unknown>, version: number) {
const { data } = await client.put<{ success: boolean; data: PluginInfo }>(
`/admin/plugins/${id}/config`,
{ config, version },
);
return data.data;
}
export async function getPluginSchema(id: string) {
const { data } = await client.get<{ success: boolean; data: Record<string, unknown> }>(
`/admin/plugins/${id}/schema`,
);
return data.data;
}

View File

@@ -1,4 +1,4 @@
import { useCallback, memo } from 'react';
import { useCallback, memo, useEffect } from 'react';
import { Layout, Avatar, Space, Dropdown, Tooltip, theme } from 'antd';
import {
HomeOutlined,
@@ -14,10 +14,12 @@ import {
SearchOutlined,
BulbOutlined,
BulbFilled,
AppstoreOutlined,
} from '@ant-design/icons';
import { useNavigate, useLocation } from 'react-router-dom';
import { useAppStore } from '../stores/app';
import { useAuthStore } from '../stores/auth';
import { usePluginStore } from '../stores/plugin';
import NotificationPanel from '../components/NotificationPanel';
const { Header, Sider, Content, Footer } = Layout;
@@ -42,6 +44,7 @@ const bizMenuItems: MenuItem[] = [
const sysMenuItems: MenuItem[] = [
{ key: '/settings', icon: <SettingOutlined />, label: '系统设置' },
{ key: '/plugins/admin', icon: <AppstoreOutlined />, label: '插件管理' },
];
const routeTitleMap: Record<string, string> = {
@@ -52,6 +55,7 @@ const routeTitleMap: Record<string, string> = {
'/workflow': '工作流',
'/messages': '消息中心',
'/settings': '系统设置',
'/plugins/admin': '插件管理',
};
// 侧边栏菜单项 - 提取为独立组件避免重复渲染
@@ -82,11 +86,17 @@ const SidebarMenuItem = memo(function SidebarMenuItem({
export default function MainLayout({ children }: { children: React.ReactNode }) {
const { sidebarCollapsed, toggleSidebar, theme: themeMode, setTheme } = useAppStore();
const { user, logout } = useAuthStore();
const { pluginMenuItems, fetchPlugins } = usePluginStore();
theme.useToken();
const navigate = useNavigate();
const location = useLocation();
const currentPath = location.pathname || '/';
// 加载插件菜单
useEffect(() => {
fetchPlugins(1, 'running');
}, [fetchPlugins]);
const handleLogout = useCallback(async () => {
await logout();
navigate('/login');
@@ -159,6 +169,28 @@ export default function MainLayout({ children }: { children: React.ReactNode })
))}
</div>
{/* 菜单组:插件 */}
{pluginMenuItems.length > 0 && (
<>
{!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)}
/>
))}
</div>
</>
)}
{/* 菜单组:系统 */}
{!sidebarCollapsed && <div className="erp-sidebar-group"></div>}
<div className="erp-sidebar-menu">
@@ -187,7 +219,9 @@ export default function MainLayout({ children }: { children: React.ReactNode })
{sidebarCollapsed ? <MenuUnfoldOutlined /> : <MenuFoldOutlined />}
</div>
<span className={`erp-header-title ${isDark ? 'erp-text-dark' : 'erp-text-light'}`}>
{routeTitleMap[currentPath] || '页面'}
{routeTitleMap[currentPath] ||
pluginMenuItems.find((p) => p.key === currentPath)?.label ||
'页面'}
</span>
</Space>

View File

@@ -0,0 +1,342 @@
import { useEffect, useState, useCallback } from 'react';
import {
Table,
Button,
Space,
Tag,
message,
Upload,
Modal,
Input,
Drawer,
Descriptions,
Popconfirm,
Form,
theme,
} from 'antd';
import {
UploadOutlined,
PlayCircleOutlined,
PauseCircleOutlined,
CloudDownloadOutlined,
DeleteOutlined,
ReloadOutlined,
AppstoreOutlined,
HeartOutlined,
} from '@ant-design/icons';
import type { PluginInfo, PluginStatus } from '../api/plugins';
import {
listPlugins,
uploadPlugin,
installPlugin,
enablePlugin,
disablePlugin,
uninstallPlugin,
purgePlugin,
getPluginHealth,
} from '../api/plugins';
const STATUS_CONFIG: Record<PluginStatus, { color: string; label: string }> = {
uploaded: { color: '#64748B', label: '已上传' },
installed: { color: '#2563EB', label: '已安装' },
enabled: { color: '#059669', label: '已启用' },
running: { color: '#059669', label: '运行中' },
disabled: { color: '#DC2626', label: '已禁用' },
uninstalled: { color: '#9333EA', label: '已卸载' },
};
export default function PluginAdmin() {
const [plugins, setPlugins] = useState<PluginInfo[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [loading, setLoading] = useState(false);
const [uploadModalOpen, setUploadModalOpen] = useState(false);
const [manifestText, setManifestText] = useState('');
const [wasmFile, setWasmFile] = useState<File | null>(null);
const [detailPlugin, setDetailPlugin] = useState<PluginInfo | null>(null);
const [healthDetail, setHealthDetail] = useState<Record<string, unknown> | null>(null);
const [actionLoading, setActionLoading] = useState<string | null>(null);
const { token } = theme.useToken();
const fetchPlugins = useCallback(async (p = page) => {
setLoading(true);
try {
const result = await listPlugins(p);
setPlugins(result.data);
setTotal(result.total);
} catch {
message.error('加载插件列表失败');
}
setLoading(false);
}, [page]);
useEffect(() => {
fetchPlugins();
}, [fetchPlugins]);
const handleUpload = async () => {
if (!wasmFile || !manifestText.trim()) {
message.warning('请选择 WASM 文件并填写 Manifest');
return;
}
try {
await uploadPlugin(wasmFile, manifestText);
message.success('插件上传成功');
setUploadModalOpen(false);
setWasmFile(null);
setManifestText('');
fetchPlugins();
} catch {
message.error('插件上传失败');
}
};
const handleAction = async (id: string, action: () => Promise<PluginInfo>, label: string) => {
setActionLoading(id);
try {
await action();
message.success(`${label}成功`);
fetchPlugins();
if (detailPlugin?.id === id) {
setDetailPlugin(null);
}
} catch {
message.error(`${label}失败`);
}
setActionLoading(null);
};
const handleHealthCheck = async (id: string) => {
try {
const result = await getPluginHealth(id);
setHealthDetail(result.details);
} catch {
message.error('健康检查失败');
}
};
const getActions = (record: PluginInfo) => {
const id = record.id;
const btns: React.ReactNode[] = [];
switch (record.status) {
case 'uploaded':
btns.push(
<Button
key="install"
size="small"
icon={<CloudDownloadOutlined />}
loading={actionLoading === id}
onClick={() => handleAction(id, () => installPlugin(id), '安装')}
>
</Button>,
);
break;
case 'installed':
btns.push(
<Button
key="enable"
size="small"
type="primary"
icon={<PlayCircleOutlined />}
loading={actionLoading === id}
onClick={() => handleAction(id, () => enablePlugin(id), '启用')}
>
</Button>,
);
break;
case 'enabled':
case 'running':
btns.push(
<Button
key="disable"
size="small"
danger
icon={<PauseCircleOutlined />}
loading={actionLoading === id}
onClick={() => handleAction(id, () => disablePlugin(id), '停用')}
>
</Button>,
);
break;
case 'disabled':
btns.push(
<Button
key="uninstall"
size="small"
icon={<DeleteOutlined />}
loading={actionLoading === id}
onClick={() => handleAction(id, () => uninstallPlugin(id), '卸载')}
>
</Button>,
);
break;
}
return btns;
};
const columns = [
{ title: '名称', dataIndex: 'name', key: 'name', width: 180 },
{ title: '版本', dataIndex: 'version', key: 'version', width: 80 },
{
title: '状态',
dataIndex: 'status',
key: 'status',
width: 100,
render: (status: PluginStatus) => {
const cfg = STATUS_CONFIG[status] || { color: '#64748B', label: status };
return <Tag color={cfg.color}>{cfg.label}</Tag>;
},
},
{ title: '作者', dataIndex: 'author', key: 'author', width: 120 },
{
title: '描述',
dataIndex: 'description',
key: 'description',
ellipsis: true,
},
{
title: '操作',
key: 'action',
width: 320,
render: (_: unknown, record: PluginInfo) => (
<Space size="small">
{getActions(record)}
<Button size="small" onClick={() => setDetailPlugin(record)}>
</Button>
<Popconfirm
title="确定要清除该插件记录吗?"
onConfirm={() => handleAction(record.id, async () => { await purgePlugin(record.id); return record; }, '清除')}
>
<Button size="small" danger disabled={record.status !== 'uninstalled'}>
</Button>
</Popconfirm>
</Space>
),
},
];
return (
<div style={{ padding: 24 }}>
<div style={{ marginBottom: 16, display: 'flex', justifyContent: 'space-between' }}>
<Space>
<Button icon={<UploadOutlined />} type="primary" onClick={() => setUploadModalOpen(true)}>
</Button>
<Button icon={<ReloadOutlined />} onClick={() => fetchPlugins()}>
</Button>
</Space>
</div>
<Table
columns={columns}
dataSource={plugins}
rowKey="id"
loading={loading}
pagination={{
current: page,
total,
pageSize: 20,
onChange: (p) => setPage(p),
showTotal: (t) => `${t} 个插件`,
}}
/>
<Modal
title="上传插件"
open={uploadModalOpen}
onOk={handleUpload}
onCancel={() => setUploadModalOpen(false)}
okText="上传"
width={600}
>
<Form layout="vertical">
<Form.Item label="WASM 文件" required>
<Upload
beforeUpload={(file) => {
setWasmFile(file);
return false;
}}
maxCount={1}
accept=".wasm"
fileList={wasmFile ? [wasmFile as unknown as Parameters<typeof Upload>[0]] : []}
onRemove={() => setWasmFile(null)}
>
<Button icon={<UploadOutlined />}> WASM </Button>
</Upload>
</Form.Item>
<Form.Item label="Manifest (TOML)" required>
<Input.TextArea
rows={12}
value={manifestText}
onChange={(e) => setManifestText(e.target.value)}
placeholder="[metadata]
id = &quot;my-plugin&quot;
name = &quot;我的插件&quot;
version = &quot;0.1.0&quot;"
/>
</Form.Item>
</Form>
</Modal>
<Drawer
title={detailPlugin ? `插件详情: ${detailPlugin.name}` : '插件详情'}
open={!!detailPlugin}
onClose={() => {
setDetailPlugin(null);
setHealthDetail(null);
}}
width={500}
>
{detailPlugin && (
<Descriptions column={1} bordered size="small">
<Descriptions.Item label="ID">{detailPlugin.id}</Descriptions.Item>
<Descriptions.Item label="名称">{detailPlugin.name}</Descriptions.Item>
<Descriptions.Item label="版本">{detailPlugin.version}</Descriptions.Item>
<Descriptions.Item label="状态">
<Tag color={STATUS_CONFIG[detailPlugin.status]?.color}>
{STATUS_CONFIG[detailPlugin.status]?.label || detailPlugin.status}
</Tag>
</Descriptions.Item>
<Descriptions.Item label="作者">{detailPlugin.author || '-'}</Descriptions.Item>
<Descriptions.Item label="描述">{detailPlugin.description || '-'}</Descriptions.Item>
<Descriptions.Item label="安装时间">{detailPlugin.installed_at || '-'}</Descriptions.Item>
<Descriptions.Item label="启用时间">{detailPlugin.enabled_at || '-'}</Descriptions.Item>
<Descriptions.Item label="实体数量">{detailPlugin.entities.length}</Descriptions.Item>
</Descriptions>
)}
<div style={{ marginTop: 16 }}>
<Button
icon={<HeartOutlined />}
onClick={() => detailPlugin && handleHealthCheck(detailPlugin.id)}
style={{ marginBottom: 8 }}
>
</Button>
{healthDetail && (
<pre
style={{
background: token.colorBgContainer,
padding: 12,
borderRadius: 6,
fontSize: 12,
overflow: 'auto',
}}
>
{JSON.stringify(healthDetail, null, 2)}
</pre>
)}
</div>
</Drawer>
</div>
);
}

View File

@@ -0,0 +1,256 @@
import { useEffect, useState, useCallback } from 'react';
import { useParams } from 'react-router-dom';
import {
Table,
Button,
Space,
Modal,
Form,
Input,
InputNumber,
DatePicker,
Switch,
Select,
Tag,
message,
Popconfirm,
} from 'antd';
import { PlusOutlined, EditOutlined, DeleteOutlined, ReloadOutlined } from '@ant-design/icons';
import {
listPluginData,
createPluginData,
updatePluginData,
deletePluginData,
} from '../api/pluginData';
import { getPluginSchema } from '../api/plugins';
interface FieldDef {
name: string;
field_type: string;
required: boolean;
display_name?: string;
ui_widget?: string;
options?: { label: string; value: string }[];
}
interface EntitySchema {
name: string;
display_name: string;
fields: FieldDef[];
}
export default function PluginCRUDPage() {
const { pluginId, entityName } = useParams<{ pluginId: string; entityName: string }>();
const [records, setRecords] = useState<Record<string, unknown>[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [loading, setLoading] = useState(false);
const [fields, setFields] = useState<FieldDef[]>([]);
const [displayName, setDisplayName] = useState(entityName || '');
const [modalOpen, setModalOpen] = useState(false);
const [editRecord, setEditRecord] = useState<Record<string, unknown> | null>(null);
const [form] = Form.useForm();
// 加载 schema
useEffect(() => {
if (!pluginId) return;
getPluginSchema(pluginId)
.then((schema) => {
const entities = (schema as { entities?: EntitySchema[] }).entities || [];
const entity = entities.find((e) => e.name === entityName);
if (entity) {
setFields(entity.fields);
setDisplayName(entity.display_name || entityName || '');
}
})
.catch(() => {
// schema 加载失败时仍可使用
});
}, [pluginId, entityName]);
const fetchData = useCallback(async (p = page) => {
if (!pluginId || !entityName) return;
setLoading(true);
try {
const result = await listPluginData(pluginId, entityName, p);
setRecords(result.data.map((r) => ({ ...r.data, _id: r.id, _version: r.version })));
setTotal(result.total);
} catch {
message.error('加载数据失败');
}
setLoading(false);
}, [pluginId, entityName, page]);
useEffect(() => {
fetchData();
}, [fetchData]);
const handleSubmit = async (values: Record<string, unknown>) => {
if (!pluginId || !entityName) return;
// 去除内部字段
const { _id, _version, ...data } = values as Record<string, unknown> & { _id?: string; _version?: number };
try {
if (editRecord) {
await updatePluginData(
pluginId,
entityName,
editRecord._id as string,
data,
editRecord._version as number,
);
message.success('更新成功');
} else {
await createPluginData(pluginId, entityName, data);
message.success('创建成功');
}
setModalOpen(false);
setEditRecord(null);
fetchData();
} catch {
message.error('操作失败');
}
};
const handleDelete = async (record: Record<string, unknown>) => {
if (!pluginId || !entityName) return;
try {
await deletePluginData(pluginId, entityName, record._id as string);
message.success('删除成功');
fetchData();
} catch {
message.error('删除失败');
}
};
// 动态生成列
const columns = [
...fields.slice(0, 5).map((f) => ({
title: f.display_name || f.name,
dataIndex: f.name,
key: f.name,
ellipsis: true,
render: (val: unknown) => {
if (typeof val === 'boolean') return val ? <Tag color="green"></Tag> : <Tag></Tag>;
return String(val ?? '-');
},
})),
{
title: '操作',
key: 'action',
width: 150,
render: (_: unknown, record: Record<string, unknown>) => (
<Space size="small">
<Button
size="small"
icon={<EditOutlined />}
onClick={() => {
setEditRecord(record);
form.setFieldsValue(record);
setModalOpen(true);
}}
>
</Button>
<Popconfirm title="确定删除?" onConfirm={() => handleDelete(record)}>
<Button size="small" danger icon={<DeleteOutlined />}>
</Button>
</Popconfirm>
</Space>
),
},
];
// 动态生成表单字段
const renderFormField = (field: FieldDef) => {
const widget = field.ui_widget || field.field_type;
switch (widget) {
case 'number':
case 'integer':
case 'float':
case 'decimal':
return <InputNumber style={{ width: '100%' }} />;
case 'boolean':
return <Switch />;
case 'date':
case 'datetime':
return <DatePicker showTime={widget === 'datetime'} style={{ width: '100%' }} />;
case 'select':
return (
<Select>
{(field.options || []).map((opt) => (
<Select.Option key={String(opt.value)} value={opt.value}>
{opt.label}
</Select.Option>
))}
</Select>
);
default:
return <Input />;
}
};
return (
<div style={{ padding: 24 }}>
<div style={{ marginBottom: 16, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<h2 style={{ margin: 0 }}>{displayName}</h2>
<Space>
<Button
icon={<PlusOutlined />}
type="primary"
onClick={() => {
setEditRecord(null);
form.resetFields();
setModalOpen(true);
}}
>
</Button>
<Button icon={<ReloadOutlined />} onClick={() => fetchData()}>
</Button>
</Space>
</div>
<Table
columns={columns}
dataSource={records}
rowKey="_id"
loading={loading}
pagination={{
current: page,
total,
pageSize: 20,
onChange: (p) => setPage(p),
showTotal: (t) => `${t}`,
}}
/>
<Modal
title={editRecord ? '编辑' : '新增'}
open={modalOpen}
onCancel={() => {
setModalOpen(false);
setEditRecord(null);
}}
onOk={() => form.submit()}
destroyOnClose
>
<Form form={form} layout="vertical" onFinish={handleSubmit}>
{fields.map((field) => (
<Form.Item
key={field.name}
name={field.name}
label={field.display_name || field.name}
rules={field.required ? [{ required: true, message: `请输入${field.display_name || field.name}` }] : []}
valuePropName={field.field_type === 'boolean' ? 'checked' : 'value'}
>
{renderFormField(field)}
</Form.Item>
))}
</Form>
</Modal>
</div>
);
}

View File

@@ -0,0 +1,59 @@
import { create } from 'zustand';
import type { PluginInfo, PluginStatus } from '../api/plugins';
import { listPlugins } from '../api/plugins';
export interface PluginMenuItem {
key: string;
icon: string;
label: string;
pluginId: string;
entity: string;
menuGroup?: string;
}
interface PluginStore {
plugins: PluginInfo[];
loading: boolean;
pluginMenuItems: PluginMenuItem[];
fetchPlugins: (page?: number, status?: PluginStatus) => Promise<void>;
refreshMenuItems: () => void;
}
export const usePluginStore = create<PluginStore>((set, get) => ({
plugins: [],
loading: false,
pluginMenuItems: [],
fetchPlugins: async (page = 1, status?: PluginStatus) => {
set({ loading: true });
try {
const result = await listPlugins(page, 100, status);
set({ plugins: result.data });
get().refreshMenuItems();
} finally {
set({ loading: false });
}
},
refreshMenuItems: () => {
const { plugins } = 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,
});
}
}
set({ pluginMenuItems: items });
},
}));