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

@@ -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;