feat(plugin): P2-P4 插件平台演进 — 通用服务 + 质量保障 + 市场
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:
232
apps/web/src/components/PluginSettingsForm.tsx
Normal file
232
apps/web/src/components/PluginSettingsForm.tsx
Normal 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;
|
||||
Reference in New Issue
Block a user