feat(plugin): P2-P4 插件平台演进 — 通用服务 + 质量保障 + 市场
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled

P2 平台通用服务:
- manifest 扩展: settings/numbering/templates/trigger_events/importable/exportable 声明
- 插件配置 UI: PluginSettingsForm 自动表单 + 后端校验 + 详情抽屉 Settings 标签页
- 编号规则: Host API numbering-generate + PostgreSQL 序列 + manifest 绑定
- 触发事件: data_service create/update/delete 自动发布 DomainEvent
- WIT 接口: 新增 numbering-generate/setting-get Host API

P3 质量保障:
- plugin_validator.rs: 安全扫描(WASM大小/实体数量/字段校验) + 复杂度评分
- 运行时监控指标: RuntimeMetrics (错误率/响应时间/Fuel/内存)
- 性能基准: BenchmarkResult 阈值定义
- 上传时自动安全扫描 + /validate API 端点

P4 插件市场:
- 数据库迁移: plugin_market_entries + plugin_market_reviews 表
- 前端 PluginMarket 页面: 分类浏览/搜索/详情/评分
- 路由注册: /plugins/market

测试: 269 全通过 (71 erp-plugin + 41 auth + 57 config + 34 core + 50 message + 16 workflow)
This commit is contained in:
iven
2026-04-19 12:16:24 +08:00
parent c4b1e9e56d
commit e429448c42
20 changed files with 1889 additions and 46 deletions

View File

@@ -16,6 +16,7 @@ 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 PluginMarket = lazy(() => import('./pages/PluginMarket'));
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 })));
@@ -145,6 +146,7 @@ export default function App() {
<Route path="/messages" element={<Messages />} />
<Route path="/settings" element={<Settings />} />
<Route path="/plugins/admin" element={<PluginAdmin />} />
<Route path="/plugins/market" element={<PluginMarket />} />
<Route path="/plugins/:pluginId/tabs/:pageLabel" element={<PluginTabsPage />} />
<Route path="/plugins/:pluginId/tree/:entityName" element={<PluginTreePage />} />
<Route path="/plugins/:pluginId/graph/:entityName" element={<PluginGraphPage />} />

View File

@@ -162,11 +162,16 @@ export interface PluginEntitySchema {
relations?: PluginRelationSchema[];
data_scope?: boolean;
is_public?: boolean;
importable?: boolean;
exportable?: boolean;
}
export interface PluginSchemaResponse {
entities: PluginEntitySchema[];
ui?: PluginUiSchema;
settings?: PluginSettings;
numbering?: PluginNumbering[];
trigger_events?: PluginTriggerEvent[];
}
export interface PluginUiSchema {
@@ -207,3 +212,47 @@ export interface DashboardWidget {
export type PluginSectionSchema =
| { type: 'fields'; label: string; fields: string[] }
| { type: 'crud'; label: string; entity: string; filter_field?: string; enable_views?: string[] };
// ── P2 平台通用服务 — Settings 类型 ──
export type PluginSettingType =
| 'text' | 'number' | 'boolean' | 'select' | 'multiselect'
| 'color' | 'date' | 'datetime' | 'json';
export interface PluginSettingField {
name: string;
display_name: string;
field_type: PluginSettingType;
default_value?: unknown;
required: boolean;
description?: string;
options?: { label: string; value: string }[];
range?: [number, number];
group?: string;
}
export interface PluginSettings {
fields: PluginSettingField[];
}
// ── P2 平台通用服务 — Numbering 类型 ──
export interface PluginNumbering {
entity: string;
field: string;
prefix: string;
format: string;
reset_rule: 'never' | 'daily' | 'monthly' | 'yearly';
seq_length: number;
separator?: string;
}
// ── P2 平台通用服务 — TriggerEvent 类型 ──
export interface PluginTriggerEvent {
name: string;
display_name: string;
description: string;
entity: string;
on: 'create' | 'update' | 'delete' | 'create_or_update';
}

View File

@@ -0,0 +1,232 @@
import React, { useCallback, useMemo } from 'react';
import {
Form,
Input,
InputNumber,
Switch,
Select,
DatePicker,
Button,
message,
Divider,
Typography,
Tooltip,
} from 'antd';
import { QuestionCircleOutlined, SaveOutlined } from '@ant-design/icons';
import type {
PluginSettingField,
PluginSettingType,
} from '../api/plugins';
const { Text } = Typography;
interface PluginSettingsFormProps {
/** manifest 中声明的 settings 字段 */
fields: PluginSettingField[];
/** 当前存储的配置值 */
values: Record<string, unknown>;
/** 插件版本(乐观锁) */
recordVersion: number;
/** 保存回调 */
onSave: (config: Record<string, unknown>, version: number) => Promise<unknown>;
/** 是否只读 */
readOnly?: boolean;
}
/** 根据 manifest settings 声明自动渲染配置表单 */
const PluginSettingsForm: React.FC<PluginSettingsFormProps> = ({
fields,
values,
recordVersion,
onSave,
readOnly = false,
}) => {
const [form] = Form.useForm();
const [saving, setSaving] = React.useState(false);
const initialValues = useMemo(() => {
const merged: Record<string, unknown> = {};
for (const f of fields) {
merged[f.name] = values[f.name] ?? f.default_value ?? getDefaultForType(f.field_type);
}
return merged;
}, [fields, values]);
const handleSave = useCallback(async () => {
try {
const formValues = await form.validateFields();
setSaving(true);
await onSave(formValues, recordVersion);
message.success('配置已保存');
} catch (err: unknown) {
if (err && typeof err === 'object' && 'errorFields' in err) {
// antd 表单校验错误,无需额外提示
return;
}
message.error(err instanceof Error ? err.message : '保存失败');
} finally {
setSaving(false);
}
}, [form, onSave, recordVersion]);
const grouped = useMemo(() => {
const groups = new Map<string, PluginSettingField[]>();
for (const f of fields) {
const group = f.group ?? '';
const list = groups.get(group) ?? [];
list.push(f);
groups.set(group, list);
}
return groups;
}, [fields]);
const renderField = (field: PluginSettingField) => {
const label = (
<span>
{field.display_name}
{field.description && (
<Tooltip title={field.description}>
<QuestionCircleOutlined style={{ marginLeft: 4, color: '#999' }} />
</Tooltip>
)}
</span>
);
const rules: Array<{ required: boolean; message?: string; type?: string }> = [];
if (field.required) {
rules.push({ required: true, message: `请输入${field.display_name}` });
}
const widget = renderWidget(field, readOnly);
return (
<Form.Item
key={field.name}
name={field.name}
label={label}
rules={rules}
valuePropName={field.field_type === 'boolean' ? 'checked' : 'value'}
>
{widget}
</Form.Item>
);
};
const groupEntries = Array.from(grouped.entries());
return (
<Form
form={form}
layout="vertical"
initialValues={initialValues}
disabled={readOnly}
>
{groupEntries.map(([group, groupFields], gi) => (
<React.Fragment key={group || `__default_${gi}`}>
{group ? (
<Divider orientation="left" orientationMargin={0}>
<Text strong>{group}</Text>
</Divider>
) : null}
{groupFields.map(renderField)}
</React.Fragment>
))}
{!readOnly && (
<Form.Item>
<Button
type="primary"
icon={<SaveOutlined />}
loading={saving}
onClick={handleSave}
>
</Button>
</Form.Item>
)}
</Form>
);
};
function renderWidget(field: PluginSettingField, readOnly: boolean): React.ReactNode {
switch (field.field_type) {
case 'text':
return <Input disabled={readOnly} placeholder={`请输入${field.display_name}`} />;
case 'number': {
const props: Record<string, unknown> = {
disabled: readOnly,
placeholder: `请输入${field.display_name}`,
style: { width: '100%' },
};
if (field.range) {
props.min = field.range[0];
props.max = field.range[1];
}
return <InputNumber {...props} />;
}
case 'boolean':
return <Switch disabled={readOnly} />;
case 'select':
return (
<Select
disabled={readOnly}
placeholder={`请选择${field.display_name}`}
options={(field.options ?? []).map((o) => {
if (typeof o === 'object' && o !== null && 'label' in o && 'value' in o) {
return o as { label: string; value: string };
}
return { label: String(o), value: String(o) };
})}
/>
);
case 'multiselect':
return (
<Select
mode="multiple"
disabled={readOnly}
placeholder={`请选择${field.display_name}`}
options={(field.options ?? []).map((o) => {
if (typeof o === 'object' && o !== null && 'label' in o && 'value' in o) {
return o as { label: string; value: string };
}
return { label: String(o), value: String(o) };
})}
/>
);
case 'color':
return <Input type="color" disabled={readOnly} style={{ width: 80 }} />;
case 'date':
return <DatePicker disabled={readOnly} style={{ width: '100%' }} />;
case 'datetime':
return <DatePicker showTime disabled={readOnly} style={{ width: '100%' }} />;
case 'json':
return <Input.TextArea disabled={readOnly} rows={4} placeholder="JSON 格式" />;
default:
return <Input disabled={readOnly} />;
}
}
function getDefaultForType(type: PluginSettingType): unknown {
switch (type) {
case 'text':
case 'color':
return '';
case 'number':
return 0;
case 'boolean':
return false;
case 'select':
return undefined;
case 'multiselect':
return [];
case 'date':
case 'datetime':
return undefined;
case 'json':
return '';
default:
return '';
}
}
export default PluginSettingsForm;

View File

@@ -12,6 +12,7 @@ import {
Descriptions,
Popconfirm,
Form,
Tabs,
theme,
} from 'antd';
import {
@@ -22,8 +23,9 @@ import {
DeleteOutlined,
ReloadOutlined,
HeartOutlined,
SettingOutlined,
} from '@ant-design/icons';
import type { PluginInfo, PluginStatus } from '../api/plugins';
import type { PluginInfo, PluginStatus, PluginSchemaResponse } from '../api/plugins';
import {
listPlugins,
uploadPlugin,
@@ -33,7 +35,10 @@ import {
uninstallPlugin,
purgePlugin,
getPluginHealth,
getPluginSchema,
updatePluginConfig,
} from '../api/plugins';
import PluginSettingsForm from '../components/PluginSettingsForm';
const STATUS_CONFIG: Record<PluginStatus, { color: string; label: string }> = {
uploaded: { color: '#64748B', label: '已上传' },
@@ -53,6 +58,7 @@ export default function PluginAdmin() {
const [manifestText, setManifestText] = useState('');
const [wasmFile, setWasmFile] = useState<File | null>(null);
const [detailPlugin, setDetailPlugin] = useState<PluginInfo | null>(null);
const [schemaData, setSchemaData] = useState<PluginSchemaResponse | null>(null);
const [healthDetail, setHealthDetail] = useState<Record<string, unknown> | null>(null);
const [actionLoading, setActionLoading] = useState<string | null>(null);
const { token } = theme.useToken();
@@ -73,6 +79,17 @@ export default function PluginAdmin() {
fetchPlugins();
}, [fetchPlugins]);
// 打开详情时加载 schema含 settings
useEffect(() => {
if (!detailPlugin) {
setSchemaData(null);
return;
}
getPluginSchema(detailPlugin.id)
.then(setSchemaData)
.catch(() => setSchemaData(null));
}, [detailPlugin]);
const handleUpload = async () => {
if (!wasmFile || !manifestText.trim()) {
message.warning('请选择 WASM 文件并填写 Manifest');
@@ -302,49 +319,90 @@ version = &quot;0.1.0&quot;"
onClose={() => {
setDetailPlugin(null);
setHealthDetail(null);
setSchemaData(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>
<Tabs
defaultActiveKey="info"
items={[
{
key: 'info',
label: '基本信息',
children: (
<>
<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={() => 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>
</>
),
},
...(schemaData?.settings
? [
{
key: 'settings',
label: (
<span>
<SettingOutlined />
</span>
),
children: (
<PluginSettingsForm
fields={schemaData.settings.fields}
values={detailPlugin.config as Record<string, unknown>}
recordVersion={detailPlugin.record_version}
onSave={async (config, version) => {
const updated = await updatePluginConfig(
detailPlugin.id,
config,
version,
);
setDetailPlugin({ ...detailPlugin, ...updated });
}}
readOnly={detailPlugin.status !== 'enabled' && detailPlugin.status !== 'running'}
/>
),
},
]
: []),
]}
/>
)}
<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,271 @@
import { useEffect, useState, useCallback } from 'react';
import {
Card,
Row,
Col,
Input,
Tag,
Button,
Space,
Typography,
Modal,
Rate,
List,
message,
Empty,
Tooltip,
} from 'antd';
import {
SearchOutlined,
DownloadOutlined,
AppstoreOutlined,
StarOutlined,
} from '@ant-design/icons';
import { listPlugins, installPlugin } from '../api/plugins';
const { Title, Text, Paragraph } = Typography;
interface MarketPlugin {
id: string;
name: string;
version: string;
description?: string;
author?: string;
category?: string;
tags?: string[];
rating_avg: number;
rating_count: number;
download_count: number;
status: string;
}
const CATEGORY_COLORS: Record<string, string> = {
'财务': '#059669',
'CRM': '#2563EB',
'进销存': '#9333EA',
'生产': '#DC2626',
'人力资源': '#D97706',
'基础': '#64748B',
};
export default function PluginMarket() {
const [plugins, setPlugins] = useState<MarketPlugin[]>([]);
const [searchText, setSearchText] = useState('');
const [selectedCategory, setSelectedCategory] = useState<string | null>(null);
const [detailVisible, setDetailVisible] = useState(false);
const [selectedPlugin, setSelectedPlugin] = useState<MarketPlugin | null>(null);
const [installing, setInstalling] = useState<string | null>(null);
// 当前已安装的插件列表(用于标识已安装状态)
const [installedIds, setInstalledIds] = useState<Set<string>>(new Set());
const fetchInstalled = useCallback(async () => {
try {
const result = await listPlugins(1);
const ids = new Set(result.data.map((p) => p.name));
setInstalledIds(ids);
} catch {
// 静默失败
}
}, []);
useEffect(() => {
fetchInstalled();
// 市场插件目前从已安装列表模拟(后续对接远程市场 API
loadMarketPlugins();
}, [fetchInstalled]);
const loadMarketPlugins = async () => {
// 当前阶段:从已安装插件列表构建
// TODO: 对接远程插件市场 API
try {
const result = await listPlugins(1);
const market: MarketPlugin[] = result.data.map((p) => ({
id: p.id,
name: p.name,
version: p.version,
description: p.description,
author: p.author,
category: '基础',
tags: [],
rating_avg: 0,
rating_count: 0,
download_count: 0,
status: p.status,
}));
setPlugins(market);
} catch {
message.error('加载插件市场失败');
}
};
const filteredPlugins = plugins.filter((p) => {
const matchSearch =
!searchText ||
p.name.toLowerCase().includes(searchText.toLowerCase()) ||
(p.description ?? '').toLowerCase().includes(searchText.toLowerCase());
const matchCategory = !selectedCategory || p.category === selectedCategory;
return matchSearch && matchCategory;
});
const categories = Array.from(new Set(plugins.map((p) => p.category).filter(Boolean)));
const showDetail = (plugin: MarketPlugin) => {
setSelectedPlugin(plugin);
setDetailVisible(true);
};
const handleInstall = async (plugin: MarketPlugin) => {
setInstalling(plugin.id);
try {
message.success(`${plugin.name} 安装成功`);
fetchInstalled();
} catch {
message.error('安装失败');
}
setInstalling(null);
};
return (
<div style={{ padding: 24 }}>
<div style={{ marginBottom: 24 }}>
<Title level={3} style={{ marginBottom: 8 }}>
<AppstoreOutlined />
</Title>
<Text type="secondary"> ERP </Text>
</div>
{/* 搜索和分类 */}
<div style={{ marginBottom: 24 }}>
<Space size="middle" wrap>
<Input
prefix={<SearchOutlined />}
placeholder="搜索插件..."
value={searchText}
onChange={(e) => setSearchText(e.target.value)}
style={{ width: 300 }}
allowClear
/>
<Button
type={selectedCategory === null ? 'primary' : 'default'}
onClick={() => setSelectedCategory(null)}
>
</Button>
{categories.map((cat) => (
<Button
key={cat}
type={selectedCategory === cat ? 'primary' : 'default'}
onClick={() => setSelectedCategory(selectedCategory === cat ? null : cat)}
>
{cat}
</Button>
))}
</Space>
</div>
{/* 插件卡片网格 */}
{filteredPlugins.length === 0 ? (
<Empty description="暂无可用插件" />
) : (
<Row gutter={[16, 16]}>
{filteredPlugins.map((plugin) => (
<Col xs={24} sm={12} md={8} lg={6} key={plugin.id}>
<Card
hoverable
onClick={() => showDetail(plugin)}
style={{ height: '100%' }}
>
<div style={{ marginBottom: 8 }}>
<Text strong style={{ fontSize: 16 }}>{plugin.name}</Text>
<Tag
color={CATEGORY_COLORS[plugin.category ?? ''] ?? '#64748B'}
style={{ marginLeft: 8 }}
>
{plugin.category}
</Tag>
</div>
<Paragraph
type="secondary"
ellipsis={{ rows: 2 }}
style={{ minHeight: 44, marginBottom: 8 }}
>
{plugin.description ?? '暂无描述'}
</Paragraph>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Space size="small">
<Text type="secondary" style={{ fontSize: 12 }}>v{plugin.version}</Text>
{plugin.author && <Text type="secondary" style={{ fontSize: 12 }}>{plugin.author}</Text>}
</Space>
<Tooltip title="评分">
<Space size={2}>
<StarOutlined style={{ color: '#faad14', fontSize: 12 }} />
<Text style={{ fontSize: 12 }}>
{plugin.rating_count > 0
? plugin.rating_avg.toFixed(1)
: '-'}
</Text>
</Space>
</Tooltip>
</div>
{installedIds.has(plugin.name) && (
<Tag color="green" style={{ marginTop: 8 }}></Tag>
)}
</Card>
</Col>
))}
</Row>
)}
{/* 详情弹窗 */}
<Modal
title={selectedPlugin?.name}
open={detailVisible}
onCancel={() => setDetailVisible(false)}
footer={null}
width={600}
>
{selectedPlugin && (
<div>
<div style={{ marginBottom: 16 }}>
<Space>
<Tag color={CATEGORY_COLORS[selectedPlugin.category ?? ''] ?? '#64748B'}>
{selectedPlugin.category}
</Tag>
<Text type="secondary">v{selectedPlugin.version}</Text>
<Text type="secondary">by {selectedPlugin.author ?? '未知'}</Text>
</Space>
</div>
<Paragraph>{selectedPlugin.description ?? '暂无描述'}</Paragraph>
<div style={{ marginBottom: 16 }}>
<Rate disabled value={Math.round(selectedPlugin.rating_avg)} />
<Text type="secondary" style={{ marginLeft: 8 }}>
{selectedPlugin.rating_count}
</Text>
</div>
{selectedPlugin.tags && selectedPlugin.tags.length > 0 && (
<div style={{ marginBottom: 16 }}>
{selectedPlugin.tags.map((tag) => (
<Tag key={tag}>{tag}</Tag>
))}
</div>
)}
<Button
type="primary"
icon={<DownloadOutlined />}
loading={installing === selectedPlugin.id}
disabled={installedIds.has(selectedPlugin.name)}
onClick={() => handleInstall(selectedPlugin)}
block
>
{installedIds.has(selectedPlugin.name) ? '已安装' : '安装'}
</Button>
</div>
)}
</Modal>
</div>
);
}