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:
@@ -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 />} />
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
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;
|
||||
@@ -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 = "0.1.0""
|
||||
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>
|
||||
);
|
||||
|
||||
271
apps/web/src/pages/PluginMarket.tsx
Normal file
271
apps/web/src/pages/PluginMarket.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user