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:
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