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:
256
apps/web/src/pages/PluginCRUDPage.tsx
Normal file
256
apps/web/src/pages/PluginCRUDPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user