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:
Logan.Li committed 2026-10-27 02:44:10 +08:00
commit 04d1342b27
115 files changed
+23268

No files matched your search

+273
View File
@@ -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>
);
}