feat: FlowStack 发布系统首个可运行版本 (gin + React)
- Go gin 后端:流水线/节点/连线/运行/日志/GitOps/插件 REST API, SQLite(GORM) 自动建表 + seed,模拟执行引擎 + SSE 实时日志推送, 前端产物 go:embed 嵌入单二进制,一个端口同时服务 API 与页面 - React 19 前端:画布式流水线编辑器(React Flow + Tekton 任务库)、 GitOps 部署、批量发布、发布记录等 14 个模块页面, tRPC 全部替换为 REST(TanStack Query) - 首次启动生成随机密码管理员账号(日志 + data/initial-admin-password.txt) - run.sh 一键构建启动(默认端口 9527,支持 start/stop/restart/build/dev)
This commit is contained in:
commit
04d1342b27
115 files changed
+23268
No files matched your search
@@ -0,0 +1,273 @@
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
Puzzle, Search, Download, RefreshCw,
|
||||
Star, Users, Trash2,
|
||||
Zap, Palette, Code, Globe, Database, ShieldCheck
|
||||
} from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { api } from '@/lib/api';
|
||||
|
||||
|
||||
type TabType = 'installed' | 'available' | 'updates' | 'advanced';
|
||||
|
||||
const categoryColors: Record<string, string> = {
|
||||
'部署': '#0969da', '构建': '#1a7f37', '主题': '#8250df', '扫描': '#9a6700',
|
||||
'安全': '#cf222e', '运维': '#6e7781', '通知': '#bf8700',
|
||||
};
|
||||
|
||||
const iconMap: Record<string, typeof Puzzle> = {
|
||||
'部署': Globe, '构建': Code, '主题': Palette, '扫描': ShieldCheck,
|
||||
'安全': ShieldCheck, '运维': Database, '通知': Zap, 'general': Puzzle,
|
||||
};
|
||||
|
||||
export default function PluginManager() {
|
||||
const [activeTab, setActiveTab] = useState<TabType>('installed');
|
||||
const [search, setSearch] = useState('');
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
const { data: installed = [] } = useQuery({ queryKey: ['plugins'], queryFn: () => api<any[]>('/api/plugins') });
|
||||
const { data: available = [] } = useQuery({ queryKey: ['plugins', 'available'], queryFn: () => api<any[]>('/api/plugins/available') });
|
||||
const invalidate = () => queryClient.invalidateQueries({ queryKey: ['plugins'] });
|
||||
|
||||
const installMutation = useMutation({
|
||||
mutationFn: (body: any) => api('/api/plugins/install', { method: 'POST', body: JSON.stringify(body) }),
|
||||
onSuccess: () => { toast.success('插件安装成功'); invalidate(); },
|
||||
onError: (err: Error) => toast.error(err.message),
|
||||
});
|
||||
const uninstallMutation = useMutation({
|
||||
mutationFn: (id: number) => api(`/api/plugins/${id}`, { method: 'DELETE' }),
|
||||
onSuccess: () => { toast.success('插件已卸载'); invalidate(); },
|
||||
onError: (err: Error) => toast.error(err.message),
|
||||
});
|
||||
const toggleMutation = useMutation({
|
||||
mutationFn: (input: { id: number; enabled: boolean }) =>
|
||||
api(`/api/plugins/${input.id}/toggle`, { method: 'POST', body: JSON.stringify({ enabled: input.enabled }) }),
|
||||
onSuccess: () => invalidate(),
|
||||
onError: (err: Error) => toast.error(err.message),
|
||||
});
|
||||
|
||||
const installedPlugins = installed.map((p: any) => ({
|
||||
id: String(p.id),
|
||||
name: p.name,
|
||||
description: p.description || '',
|
||||
version: p.version,
|
||||
latestVersion: p.latestVersion || p.version,
|
||||
author: p.author || 'Unknown',
|
||||
installs: 0,
|
||||
rating: 4.5,
|
||||
category: p.category || 'general',
|
||||
installed: true,
|
||||
hasUpdate: p.latestVersion && p.latestVersion !== p.version,
|
||||
enabled: p.enabled,
|
||||
icon: iconMap[p.category || 'general'] || Puzzle,
|
||||
}));
|
||||
|
||||
const availablePlugins = available.map((p: any) => ({
|
||||
id: p.pluginId,
|
||||
name: p.name,
|
||||
description: p.description || '',
|
||||
version: p.version,
|
||||
latestVersion: p.version,
|
||||
author: p.author || 'Unknown',
|
||||
installs: p.installs || 0,
|
||||
rating: 4.5,
|
||||
category: p.category || 'general',
|
||||
installed: false,
|
||||
hasUpdate: false,
|
||||
enabled: false,
|
||||
icon: iconMap[p.category || 'general'] || Puzzle,
|
||||
}));
|
||||
|
||||
const updatePlugins = installedPlugins.filter((p: any) => p.hasUpdate);
|
||||
const allPlugins = [...installedPlugins, ...availablePlugins];
|
||||
|
||||
const filteredPlugins = () => {
|
||||
const list = activeTab === 'installed' ? installedPlugins : activeTab === 'available' ? availablePlugins : activeTab === 'updates' ? updatePlugins : allPlugins;
|
||||
return list.filter((p: any) => p.name.toLowerCase().includes(search.toLowerCase()) || p.description.toLowerCase().includes(search.toLowerCase()));
|
||||
};
|
||||
|
||||
const handleInstall = (plugin: any) => {
|
||||
installMutation.mutate({
|
||||
pluginId: plugin.id,
|
||||
name: plugin.name,
|
||||
description: plugin.description,
|
||||
version: plugin.version,
|
||||
author: plugin.author,
|
||||
category: plugin.category,
|
||||
});
|
||||
};
|
||||
|
||||
const handleUninstall = (id: string) => {
|
||||
const numId = Number(id);
|
||||
if (!isNaN(numId)) uninstallMutation.mutate(numId);
|
||||
};
|
||||
|
||||
const handleToggle = (id: string) => {
|
||||
const numId = Number(id);
|
||||
const plugin = installedPlugins.find((p: any) => p.id === id);
|
||||
if (!isNaN(numId) && plugin) {
|
||||
toggleMutation.mutate({ id: numId, enabled: !plugin.enabled });
|
||||
}
|
||||
};
|
||||
|
||||
const tabs: { id: TabType; label: string; count: number }[] = [
|
||||
{ id: 'installed', label: '已安装', count: installedPlugins.length },
|
||||
{ id: 'available', label: '可安装', count: availablePlugins.length },
|
||||
{ id: 'updates', label: '更新', count: updatePlugins.length },
|
||||
{ id: 'advanced', label: '高级设置', count: 0 },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col" style={{ background: 'var(--canvas-bg)' }}>
|
||||
{/* Header */}
|
||||
<div className="px-5 py-3 border-b flex items-center justify-between flex-shrink-0" style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}>
|
||||
<div className="flex items-center gap-3">
|
||||
<h1 className="text-[15px] font-semibold" style={{ color: 'var(--text-primary)' }}>插件管理</h1>
|
||||
<span className="text-[11px] px-2 py-[2px] rounded-full font-medium" style={{ background: 'var(--panel-bg-secondary)', color: 'var(--text-tertiary)' }}>
|
||||
{installedPlugins.length} 已安装 / {availablePlugins.length} 可安装
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center border rounded-md overflow-hidden" style={{ borderColor: 'var(--border-color-light)' }}>
|
||||
<Search className="w-3.5 h-3.5 ml-2.5" style={{ color: 'var(--text-tertiary)' }} />
|
||||
<input value={search} onChange={e => setSearch(e.target.value)} placeholder="搜索插件..."
|
||||
className="px-2 py-[5px] text-[12px] outline-none w-48" style={{ background: 'transparent', color: 'var(--text-primary)' }} />
|
||||
</div>
|
||||
<button onClick={() => toast.success('刷新插件列表')} className="flex items-center gap-1 px-3 py-[5px] text-[12px] font-medium rounded-md border" style={{ borderColor: 'var(--border-color-light)', color: 'var(--text-secondary)' }}>
|
||||
<RefreshCw className="w-3.5 h-3.5" />刷新
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="px-5 pt-3 border-b flex items-center gap-0 flex-shrink-0" style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}>
|
||||
{tabs.map(tab => (
|
||||
<button key={tab.id} onClick={() => setActiveTab(tab.id)}
|
||||
className="px-4 py-[8px] text-[13px] font-medium border-b-2 transition-colors"
|
||||
style={{
|
||||
borderColor: activeTab === tab.id ? 'var(--accent-blue)' : 'transparent',
|
||||
color: activeTab === tab.id ? 'var(--accent-blue)' : 'var(--text-secondary)',
|
||||
}}>
|
||||
{tab.label}
|
||||
{tab.count > 0 && <span className="ml-1.5 text-[10px] px-1.5 py-[1px] rounded-full font-medium" style={{ background: activeTab === tab.id ? 'var(--accent-blue-light)' : 'var(--panel-bg-secondary)', color: activeTab === tab.id ? 'var(--accent-blue)' : 'var(--text-tertiary)' }}>{tab.count}</span>}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-y-auto custom-scrollbar p-5">
|
||||
{activeTab === 'advanced' ? (
|
||||
<div className="max-w-2xl space-y-4">
|
||||
<div className="rounded-lg border p-4" style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}>
|
||||
<h3 className="text-[14px] font-semibold mb-3" style={{ color: 'var(--text-primary)' }}>插件安装源</h3>
|
||||
<div className="space-y-2">
|
||||
{['https://plugins.opsx.dev/stable', 'https://plugins.opsx.dev/beta', 'https://plugins.opsx.dev/community'].map((url, idx) => (
|
||||
<div key={idx} className="flex items-center justify-between p-2.5 rounded-md border" style={{ borderColor: 'var(--border-color-light)' }}>
|
||||
<div className="flex items-center gap-2">
|
||||
<Globe className="w-4 h-4" style={{ color: 'var(--text-tertiary)' }} />
|
||||
<span className="text-[12px] font-mono" style={{ color: 'var(--text-primary)' }}>{url}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[10px] px-1.5 py-[1px] rounded font-medium" style={{ background: idx === 0 ? '#dafbe1' : '#fff8c5', color: idx === 0 ? '#1a7f37' : '#9a6700' }}>{idx === 0 ? '默认' : '备用'}</span>
|
||||
<button className="p-1 rounded hover:bg-black/[0.02]" style={{ color: 'var(--text-tertiary)' }}><Trash2 className="w-3.5 h-3.5" /></button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<button onClick={() => toast.info('添加插件源')} className="mt-2 flex items-center gap-1 px-3 py-[5px] text-[12px] font-medium rounded-md border" style={{ borderColor: 'var(--border-color-light)', color: 'var(--text-secondary)' }}>
|
||||
<Download className="w-3.5 h-3.5" />添加源
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border p-4" style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}>
|
||||
<h3 className="text-[14px] font-semibold mb-3" style={{ color: 'var(--text-primary)' }}>代理设置</h3>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div><label className="block text-[11px] mb-1" style={{ color: 'var(--text-tertiary)' }}>HTTP 代理</label>
|
||||
<input placeholder="http://proxy:8080" className="w-full px-3 py-[6px] text-[12px] rounded-md border outline-none" style={{ background: 'var(--panel-bg-secondary)', borderColor: 'var(--border-color-light)', color: 'var(--text-primary)' }} /></div>
|
||||
<div><label className="block text-[11px] mb-1" style={{ color: 'var(--text-tertiary)' }}>HTTPS 代理</label>
|
||||
<input placeholder="https://proxy:8080" className="w-full px-3 py-[6px] text-[12px] rounded-md border outline-none" style={{ background: 'var(--panel-bg-secondary)', borderColor: 'var(--border-color-light)', color: 'var(--text-primary)' }} /></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border p-4" style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}>
|
||||
<h3 className="text-[14px] font-semibold mb-3" style={{ color: 'var(--text-primary)' }}>其他设置</h3>
|
||||
<div className="space-y-2">
|
||||
<label className="flex items-center gap-2 text-[12px] cursor-pointer">
|
||||
<input type="checkbox" defaultChecked className="w-4 h-4" style={{ accentColor: 'var(--accent-blue)' }} />
|
||||
<span style={{ color: 'var(--text-primary)' }}>安装后自动启用插件</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-2 text-[12px] cursor-pointer">
|
||||
<input type="checkbox" defaultChecked className="w-4 h-4" style={{ accentColor: 'var(--accent-blue)' }} />
|
||||
<span style={{ color: 'var(--text-primary)' }}>自动检查插件更新</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-2 text-[12px] cursor-pointer">
|
||||
<input type="checkbox" className="w-4 h-4" style={{ accentColor: 'var(--accent-blue)' }} />
|
||||
<span style={{ color: 'var(--text-primary)' }}>允许安装未签名插件</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{filteredPlugins().map(plugin => {
|
||||
const Icon = plugin.icon;
|
||||
return (
|
||||
<div key={plugin.id} className="rounded-lg border p-4" style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="w-10 h-10 rounded-lg flex items-center justify-center flex-shrink-0" style={{ background: 'var(--panel-bg-secondary)' }}>
|
||||
<Icon className="w-5 h-5" style={{ color: 'var(--accent-blue)' }} />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-0.5">
|
||||
<span className="text-[14px] font-semibold" style={{ color: 'var(--text-primary)' }}>{plugin.name}</span>
|
||||
<span className="text-[10px] px-1.5 py-[1px] rounded font-medium" style={{ background: categoryColors[plugin.category] + '15', color: categoryColors[plugin.category] }}>{plugin.category}</span>
|
||||
{plugin.installed && (
|
||||
<span className="text-[10px] px-1.5 py-[1px] rounded font-medium" style={{ background: '#dafbe1', color: '#1a7f37' }}>已安装</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-[12px] mb-2" style={{ color: 'var(--text-secondary)' }}>{plugin.description}</p>
|
||||
<div className="flex items-center gap-3 text-[11px]" style={{ color: 'var(--text-tertiary)' }}>
|
||||
<span className="flex items-center gap-1"><Star className="w-3 h-3" style={{ color: '#9a6700' }} />{plugin.rating}</span>
|
||||
<span className="flex items-center gap-1"><Users className="w-3 h-3" />{plugin.installs.toLocaleString()} 次安装</span>
|
||||
<span>作者: {plugin.author}</span>
|
||||
<span className="font-mono">v{plugin.version}</span>
|
||||
{plugin.hasUpdate && <span className="text-[10px] px-1.5 py-[1px] rounded font-medium" style={{ background: '#ddf4ff', color: 'var(--accent-blue)' }}>v{plugin.latestVersion} 可用</span>}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 flex-shrink-0">
|
||||
{plugin.installed ? (
|
||||
<>
|
||||
<button onClick={() => handleToggle(plugin.id)}
|
||||
className="px-3 py-[5px] text-[11px] font-medium rounded-md border transition-colors"
|
||||
style={{ borderColor: 'var(--border-color-light)', color: plugin.enabled ? 'var(--accent-blue)' : 'var(--text-tertiary)' }}>
|
||||
{plugin.enabled ? '已启用' : '已禁用'}
|
||||
</button>
|
||||
{plugin.hasUpdate && (
|
||||
<button onClick={() => toast.info('更新插件', { description: `${plugin.name} 将更新到 v${plugin.latestVersion}` })}
|
||||
className="flex items-center gap-1 px-3 py-[5px] text-[11px] font-medium rounded-md text-white" style={{ background: 'var(--accent-blue)' }}>
|
||||
<RefreshCw className="w-3 h-3" />更新
|
||||
</button>
|
||||
)}
|
||||
<button onClick={() => handleUninstall(plugin.id)}
|
||||
className="p-1.5 rounded-md transition-colors hover:bg-black/[0.02]" style={{ color: 'var(--text-tertiary)' }}>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<button onClick={() => handleInstall(plugin)}
|
||||
className="flex items-center gap-1 px-3 py-[5px] text-[12px] font-medium rounded-md text-white" style={{ background: 'var(--accent-blue)' }}>
|
||||
<Download className="w-3.5 h-3.5" />安装
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user