feat: 发布权限 RBAC + PipelineEditor 修复 + 菜单优化

- 新增 DeployRBAC 页面: Jenkins-style Item Role 权限管理
  - 正则匹配流水线 code,实时命中预览
  - 内置用户分配面板,无需跳转系统管理
  - 支持创建/编辑/删除/启用禁用 Item Role
- 修复 PipelineEditor 成员管理 http 双解包 bug
- 修复 PipelineManager 前端 code 路由迁移(delete/trigger/stop/loadRuns)
- 新增批量删除流水线运行记录 API
- Sidebar: 应用发布下新增「发布权限」, 仓库认证→发布配置, 移除用户组
- 恢复系统管理菜单(不受发布权限改动影响)
- 后端 ItemRole CRUD handler + 正则匹配中间件已就绪
This commit is contained in:
attacker committed 2026-06-21 05:26:01 +00:00
1 parent 0fea0ccc20
commit 24f955832e
40 files changed
+7530 -1032

No files matched your search

@@ -0,0 +1,602 @@
import { useState, useEffect, useCallback } from 'react';
import { useParams } from 'react-router-dom';
import {
Plus, Trash2, ChevronUp, ChevronDown, ChevronRight, Save, Play, Loader2,
GitBranch, Wrench, Server, FlaskConical, Zap, Terminal, FileCode, Container,
Clock, Webhook, Hand, Info, Copy, Check, X, StopCircle, ScrollText,
Users, UserPlus, Shield, Eye, FileText,
} from 'lucide-react';
import { Card, CardContent } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Badge } from '@/components/ui/badge';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Textarea } from '@/components/ui/textarea';
import { Switch } from '@/components/ui/switch';
import { Label } from '@/components/ui/label';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { toast } from 'sonner';
import { pipelineApi, deployApi } from '@/services/api';
import { useConfirm } from '@/components/ui/confirm-dialog';
// ---- types ----
interface Task {
id: string; name: string; type: string; icon: string;
config: Record<string, string>; script: string; script_type: string;
}
interface Stage {
id: string; name: string; type: string; tasks: Task[];
}
interface LogEntry {
time: string; level: string; message: string; stage: string;
}
const stageTypeLabels: Record<string, string> = { source: '拉取代码', build: '构建', deploy: '部署', test: '测试', custom: '自定义' };
const taskIcons: Record<string, any> = { GitBranch, Wrench, Server, FlaskConical, Zap, Terminal, FileCode, Container };
function uid() { return `tsk-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`; }
function fmtDuration(s: number | undefined): string {
if (!s) return '-';
if (s < 60) return `${s}s`;
const m = Math.floor(s / 60); const sec = s % 60;
return sec > 0 ? `${m}分${sec}秒` : `${m}分`;
}
// ---- Task Card ----
function TaskCard({ task, selected, onSelect, onDelete }: { task: Task; selected: boolean; onSelect: () => void; onDelete: () => void }) {
const Icon = taskIcons[task.icon] || Wrench;
return (
<div
onClick={onSelect}
className={`flex items-center gap-3 p-3 rounded-md border cursor-pointer transition-all group ${
selected ? 'border-blue-500 bg-blue-500/10' : 'border-[var(--dg-border)] hover:border-slate-400 bg-[var(--dg-bg-secondary)]'
}`}
>
<div className={`p-1.5 rounded ${selected ? 'bg-blue-100' : 'bg-slate-100 dark:bg-slate-800'}`}>
<Icon className={`w-4 h-4 ${selected ? 'text-blue-600' : 'text-[var(--dg-text-secondary)]'}`} />
</div>
<div className="flex-1 min-w-0">
<div className="text-sm font-medium text-[var(--dg-text-primary)] truncate">{task.name}</div>
<div className="text-xs text-[var(--dg-text-secondary)]">{task.type}</div>
</div>
<Button variant="ghost" size="icon" className="h-6 w-6 opacity-0 group-hover:opacity-100" onClick={e => { e.stopPropagation(); onDelete(); }}>
<Trash2 className="w-3.5 h-3.5 text-red-500" />
</Button>
</div>
);
}
// ---- Task Editor Panel ----
function TaskEditor({ task, onUpdate, onClose }: { task: Task | null; onUpdate: (t: Task) => void; onClose: () => void }) {
if (!task) return <div className="p-6 text-center text-[var(--dg-text-secondary)] text-sm">选择一个任务开始编辑</div>;
return (
<div className="space-y-4">
<div className="space-y-1"><Label className="text-xs">任务名称</Label><Input value={task.name} onChange={e => onUpdate({ ...task, name: e.target.value })} className="h-8 text-sm" /></div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1"><Label className="text-xs">类型</Label>
<Select value={task.type} onValueChange={v => onUpdate({ ...task, type: v })}><SelectTrigger className="h-8 text-sm"><SelectValue /></SelectTrigger>
<SelectContent><SelectItem value="build">构建</SelectItem><SelectItem value="deploy">部署</SelectItem><SelectItem value="test">测试</SelectItem><SelectItem value="custom">自定义</SelectItem></SelectContent></Select>
</div>
<div className="space-y-1"><Label className="text-xs">图标</Label>
<Select value={task.icon} onValueChange={v => onUpdate({ ...task, icon: v })}><SelectTrigger className="h-8 text-sm"><SelectValue /></SelectTrigger>
<SelectContent>{Object.entries(taskIcons).map(([k]) => (<SelectItem key={k} value={k}>{k}</SelectItem>))}</SelectContent></Select>
</div>
</div>
<div className="space-y-1"><Label className="text-xs">脚本类型</Label>
<div className="flex gap-2">{['shell', 'python'].map(t => (<Badge key={t} variant={task.script_type === t ? 'default' : 'outline'} className="cursor-pointer text-xs" onClick={() => onUpdate({ ...task, script_type: t })}>{t}</Badge>))}</div>
</div>
<div className="space-y-1"><Label className="text-xs">脚本内容</Label>
<Textarea value={task.script} onChange={e => onUpdate({ ...task, script: e.target.value })} className="font-mono text-xs min-h-[200px] bg-slate-950 text-green-400" placeholder="#!/bin/bash\nset -e\necho 'hello'" />
</div>
</div>
);
}
// ====== Log Dialog ======
function LogDialog({ open, onClose, run, logs }: { open: boolean; onClose: () => void; run: any; logs: LogEntry[] }) {
return (
<Dialog open={open} onOpenChange={onClose}>
<DialogContent className="max-w-3xl max-h-[80vh] overflow-y-auto">
<DialogHeader><DialogTitle className="flex items-center gap-2"><ScrollText className="w-5 h-5" />执行日志 #{run?.build_number || run?.id}</DialogTitle></DialogHeader>
<div className="space-y-3">
<div className="flex flex-wrap gap-3 text-sm text-[var(--dg-text-secondary)]">
<span>触发方式: {run?.triggered_by || 'manual'}</span>
<span>分支: {run?.branch || '-'}</span>
<span>耗时: {fmtDuration(run?.duration)}</span>
<span>{run?.created_at ? new Date(run.created_at).toLocaleString('zh-CN') : '-'}</span>
</div>
<div className="bg-slate-950 rounded-md p-3 max-h-[50vh] overflow-y-auto font-mono text-xs">
{logs.length === 0 ? (
<div className="text-slate-500 py-4 text-center">暂无日志</div>
) : (
logs.map((l, i) => (
<div key={i} className="flex gap-2 py-0.5">
<span className="text-slate-500 shrink-0">[{l.time}]</span>
<span className={
l.level === 'error' ? 'text-red-400' :
l.level === 'warn' ? 'text-yellow-400' :
l.level === 'success' ? 'text-green-400' :
'text-slate-300'
}>
{l.level === 'success' ? '✓' : l.level === 'error' ? '✗' : l.level === 'warn' ? '!' : '>'} {l.message}
</span>
</div>
))
)}
</div>
</div>
</DialogContent>
</Dialog>
);
}
// ====== Main Component ======
export default function PipelineEditor() {
const { code } = useParams<{ code: string }>();
const pipelineCode = code || '';
const [pipeline, setPipeline] = useState<any>(null);
const [stages, setStages] = useState<Stage[]>([]);
const [selectedTaskId, setSelectedTaskId] = useState<string | null>(null);
const [expandedStages, setExpandedStages] = useState<Record<string, boolean>>({});
const [activeTab, setActiveTab] = useState('flow');
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [runs, setRuns] = useState<any[]>([]);
const [environments, setEnvironments] = useState<any[]>([]);
const [members, setMembers] = useState<any[]>([]);
const [loadingMembers, setLoadingMembers] = useState(false);
// trigger config
const [triggerMode, setTriggerMode] = useState('manual');
const [watchBranches, setWatchBranches] = useState('');
const [cronExpr, setCronExpr] = useState('');
const [webhookToken, setWebhookToken] = useState('');
const [variables, setVariables] = useState<[string, string][]>([]);
const [environmentId, setEnvironmentId] = useState<number | string>('');
// member add dialog
const [newMemberUser, setNewMemberUser] = useState('');
const [newMemberRole, setNewMemberRole] = useState('editor');
// log dialog
const [logOpen, setLogOpen] = useState(false);
const [logRun, setLogRun] = useState<any>(null);
const [logs, setLogs] = useState<LogEntry[]>([]);
const _confirm = useConfirm();
const loadEnvironments = useCallback(async () => {
try {
const res = await deployApi.getEnvironments();
setEnvironments(Array.isArray(res) ? res : (res as any)?.list || []);
} catch { /* silent */ }
}, []);
const loadMembers = useCallback(async () => {
setLoadingMembers(true);
try {
const res = await pipelineApi.getMembers(pipelineCode);
setMembers(Array.isArray(res) ? res : (res as any)?.data || []);
} catch { setMembers([]); }
finally { setLoadingMembers(false); }
}, [pipelineCode]);
const loadRuns = useCallback(async () => {
try {
const res = await pipelineApi.getRuns(pipelineCode);
setRuns(Array.isArray(res) ? res : (res as any)?.list || []);
} catch { setRuns([]); }
}, [pipelineCode]);
useEffect(() => {
(async () => {
setLoading(true);
try {
const p = await pipelineApi.getPipelineDetail(pipelineCode) as any;
// http.get 已自动解包 json.data,p 即为 pipeline 对象
if (!p || !p.id) { setPipeline(null); setLoading(false); return; }
setPipeline(p);
setTriggerMode(p.trigger_mode || 'manual');
setWatchBranches(p.watch_branches || '');
setCronExpr(p.cron_expr || '');
setWebhookToken(p.webhook_token || '');
setEnvironmentId(p.environment_id || '');
try {
const parsed = p.variables ? JSON.parse(p.variables) : {};
setVariables(Object.entries(parsed).map(([k, v]) => [k, String(v)]));
} catch { setVariables([]); }
try {
const s = p.stages ? JSON.parse(p.stages) : [];
const stageList = Array.isArray(s) ? s : [];
setStages(stageList);
// Auto-expand all stages initially
const expanded: Record<string, boolean> = {};
stageList.forEach((stg: Stage) => { expanded[stg.id] = true; });
setExpandedStages(expanded);
} catch { setStages([]); }
} catch { toast.error('加载失败'); }
finally { setLoading(false); }
})();
loadEnvironments();
loadMembers();
loadRuns();
}, [pipelineCode, loadEnvironments, loadMembers, loadRuns]);
const selectedTask = stages.flatMap(s => s.tasks).find(t => t.id === selectedTaskId) || null;
const handleUpdateTask = (t: Task) => { setStages(prev => prev.map(s => ({ ...s, tasks: s.tasks.map(x => x.id === t.id ? t : x) }))); };
const handleAddTask = (stageIdx: number) => {
const newTask: Task = { id: uid(), name: '新任务', type: 'build', icon: 'Wrench', config: {}, script: '', script_type: 'shell' };
setStages(prev => prev.map((s, i) => i === stageIdx ? { ...s, tasks: [...s.tasks, newTask] } : s));
};
const handleDeleteTask = (taskId: string) => { setStages(prev => prev.map(s => ({ ...s, tasks: s.tasks.filter(t => t.id !== taskId) }))); if (selectedTaskId === taskId) setSelectedTaskId(null); };
const handleAddStage = () => { const newStage: Stage = { id: `stg-${Date.now()}`, name: '新阶段', type: 'custom', tasks: [] }; setStages(prev => [...prev, newStage]); };
const handleDeleteStage = (idx: number) => { setStages(prev => prev.filter((_, i) => i !== idx)); };
const handleMoveStage = (idx: number, dir: -1 | 1) => { const nx = idx + dir; if (nx < 0 || nx >= stages.length) return; const arr = [...stages]; [arr[idx], arr[nx]] = [arr[nx], arr[idx]]; setStages(arr); };
const handleSave = async () => {
setSaving(true);
try {
await pipelineApi.updatePipeline(pipelineCode, {
stages: JSON.stringify(stages),
trigger_mode: triggerMode,
watch_branches: watchBranches,
cron_expr: cronExpr,
webhook_token: webhookToken,
environment_id: environmentId ? Number(environmentId) : null,
variables: JSON.stringify(Object.fromEntries(variables.filter(([k]) => k.trim()))),
});
toast.success('已保存');
} catch { toast.error('保存失败'); }
finally { setSaving(false); }
};
const handleTrigger = async () => {
try {
await pipelineApi.triggerPipeline(pipelineCode);
toast.success('已触发');
loadRuns();
} catch { toast.error('触发失败'); }
};
const handleStop = async (runId: number | string) => {
const ok = await _confirm({ title: '停止运行', description: '确定要停止此运行吗?', variant: 'destructive' });
if (!ok) return;
try {
await pipelineApi.stopRun(pipelineCode, runId);
toast.success('已停止');
loadRuns();
} catch { toast.error('停止失败'); }
};
const handleViewLog = async (run: any) => {
setLogRun(run);
setLogOpen(true);
try {
const data = await pipelineApi.getRunLog(pipelineCode, run.id) as any;
setLogs(data?.logs || []);
} catch { setLogs([]); }
};
const handleAddMember = async () => {
if (!newMemberUser.trim()) { toast.error('请输入用户名'); return; }
try {
await pipelineApi.addMember(pipelineCode, { username: newMemberUser.trim(), role: newMemberRole });
toast.success('已添加成员');
setNewMemberUser('');
loadMembers();
} catch (e: any) { toast.error(e?.message || '添加失败'); }
};
const handleRemoveMember = async (memberId: number) => {
const ok = await _confirm({ title: '移除成员', description: '确定要移除此成员吗?', variant: 'destructive' });
if (!ok) return;
try { await pipelineApi.removeMember(pipelineCode, memberId); toast.success('已移除'); loadMembers(); }
catch { toast.error('移除失败'); }
};
const envBadge = environments.find(e => String(e.id) === String(environmentId));
if (loading) return <div className="flex items-center justify-center py-20"><Loader2 className="w-6 h-6 animate-spin" /></div>;
if (!pipeline) return <div className="text-center py-20 text-[var(--dg-text-secondary)]">流水线不存在</div>;
return (
<div className="space-y-4">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-xl font-bold flex items-center gap-2 text-[var(--dg-text-primary)]">
<GitBranch className="w-5 h-5 text-blue-500" />
{pipeline.name}
{envBadge && <Badge style={{ backgroundColor: envBadge.color + '20', color: envBadge.color, borderColor: envBadge.color + '40' }} className="text-xs ml-2">{envBadge.display_name}</Badge>}
</h1>
<p className="text-sm text-[var(--dg-text-secondary)] mt-1">code: {pipeline.code} · {pipeline.description}</p>
</div>
<div className="flex items-center gap-2">
<Button variant="outline" onClick={handleSave} disabled={saving}><Save className="w-4 h-4 mr-1" />{saving ? '保存中...' : '保存'}</Button>
<Button variant="default" onClick={handleTrigger}><Play className="w-4 h-4 mr-1" />运行</Button>
</div>
</div>
{/* Environment Selector - quick bar */}
<Card className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
<CardContent className="p-3 flex items-center gap-3">
<Shield className="w-4 h-4 text-[var(--dg-text-secondary)]" />
<span className="text-sm text-[var(--dg-text-secondary)]">部署环境:</span>
<Select value={String(environmentId)} onValueChange={v => setEnvironmentId(v === '__none' ? '' : v)}>
<SelectTrigger className="w-40 h-8 text-sm"><SelectValue placeholder="选择环境" /></SelectTrigger>
<SelectContent>
<SelectItem value="__none">未指定</SelectItem>
{environments.map(env => (
<SelectItem key={env.id} value={String(env.id)}>
<span className="flex items-center gap-1.5">
<span className="w-2 h-2 rounded-full inline-block" style={{ backgroundColor: env.color }} />
{env.display_name}
</span>
</SelectItem>
))}
</SelectContent>
</Select>
<span className="text-xs text-[var(--dg-text-secondary)] opacity-70">选择后流水线执行记录会关联到对应环境</span>
</CardContent>
</Card>
<Tabs value={activeTab} onValueChange={setActiveTab}>
<TabsList>
<TabsTrigger value="flow">流程配置</TabsTrigger>
<TabsTrigger value="triggers">触发设置</TabsTrigger>
<TabsTrigger value="variables">变量</TabsTrigger>
<TabsTrigger value="runs">发布记录 ({runs.length})</TabsTrigger>
<TabsTrigger value="members">成员管理 ({members.length})</TabsTrigger>
</TabsList>
{/* ====== Flow Tab ====== */}
<TabsContent value="flow" className="mt-3">
<div className="flex gap-4">
<div className="flex-1 space-y-2">
<div className="flex items-center justify-between mb-2">
<span className="text-sm font-medium text-[var(--dg-text-primary)]">流水线阶段 ({stages.length})</span>
<Button size="sm" onClick={handleAddStage}><Plus className="w-3.5 h-3.5 mr-1" />添加阶段</Button>
</div>
{stages.length === 0 ? (
<div className="text-center py-12 border-2 border-dashed rounded-lg text-[var(--dg-text-secondary)]">点击「添加阶段」开始</div>
) : (
stages.map((stage, idx) => {
const isExpanded = expandedStages[stage.id] !== false; // default expanded
return (
<div key={stage.id} className="space-y-1">
<div
className="flex items-center gap-2 px-3 py-2 bg-[var(--dg-bg-tertiary)] rounded-t-md border border-b-0 border-[var(--dg-border)] cursor-pointer"
onClick={() => setExpandedStages(prev => ({ ...prev, [stage.id]: !isExpanded }))}
>
<Button variant="ghost" size="icon" className="h-5 w-5 p-0" onClick={(e) => { e.stopPropagation(); setExpandedStages(prev => ({ ...prev, [stage.id]: !isExpanded })); }}>
{isExpanded ? <ChevronDown className="w-3.5 h-3.5" /> : <ChevronRight className="w-3.5 h-3.5" />}
</Button>
<Input
value={stage.name}
onChange={e => setStages(prev => prev.map((s, i) => i === idx ? { ...s, name: e.target.value } : s))}
className="h-7 text-sm font-medium border-none bg-transparent shadow-none flex-1 px-1"
onClick={(e) => e.stopPropagation()}
/>
<span className="text-xs text-[var(--dg-text-secondary)]">{stage.tasks.length} 任务</span>
<Badge variant="secondary" className="text-xs">{stageTypeLabels[stage.type] || stage.type}</Badge>
<Button variant="ghost" size="icon" className="h-6 w-6" onClick={(e) => { e.stopPropagation(); handleMoveStage(idx, -1); }} disabled={idx === 0}><ChevronUp className="w-3.5 h-3.5" /></Button>
<Button variant="ghost" size="icon" className="h-6 w-6" onClick={(e) => { e.stopPropagation(); handleMoveStage(idx, 1); }} disabled={idx === stages.length - 1}><ChevronDown className="w-3.5 h-3.5" /></Button>
<Button variant="ghost" size="icon" className="h-6 w-6 text-red-500" onClick={(e) => { e.stopPropagation(); handleDeleteStage(idx); }}><Trash2 className="w-3.5 h-3.5" /></Button>
</div>
{isExpanded && (
<div className="border border-[var(--dg-border)] rounded-b-md p-3 space-y-2">
{stage.tasks.map(t => (<TaskCard key={t.id} task={t} selected={selectedTaskId === t.id} onSelect={() => setSelectedTaskId(selectedTaskId === t.id ? null : t.id)} onDelete={() => handleDeleteTask(t.id)} />))}
<Button variant="outline" size="sm" className="w-full h-8 text-xs" onClick={() => handleAddTask(idx)}><Plus className="w-3 h-3 mr-1" />添加任务</Button>
</div>
)}
{idx < stages.length - 1 && <div className="flex justify-center py-1"><ChevronRight className="w-4 h-4 rotate-90 text-[var(--dg-text-secondary)] opacity-30" /></div>}
</div>
);
})
)}
</div>
<div className="w-[360px] flex-shrink-0 border border-[var(--dg-border)] rounded-lg p-4 bg-[var(--dg-bg-secondary)]">
<div className="flex items-center justify-between mb-3"><span className="text-sm font-medium text-[var(--dg-text-primary)]">任务编辑</span>{selectedTask && <Button variant="ghost" size="sm" className="h-6 text-xs" onClick={() => setSelectedTaskId(null)}>关闭</Button>}</div>
<TaskEditor task={selectedTask} onUpdate={handleUpdateTask} onClose={() => setSelectedTaskId(null)} />
</div>
</div>
</TabsContent>
{/* ====== Triggers Tab ====== */}
<TabsContent value="triggers" className="mt-3">
<div className="space-y-4 max-w-lg">
<Card className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]"><CardContent className="pt-6 space-y-4">
<div className="flex items-center justify-between"><div className="flex items-center gap-2 text-[var(--dg-text-primary)]"><GitBranch className="w-5 h-5 text-blue-500" /><span className="font-medium">Git Push 触发</span></div><Switch checked={triggerMode === 'git_push'} onCheckedChange={v => setTriggerMode(v ? 'git_push' : 'manual')} /></div>
{triggerMode === 'git_push' && <div className="space-y-1"><Label>监听分支</Label><Input value={watchBranches} onChange={e => setWatchBranches(e.target.value)} placeholder="master, release/*" className="h-9" /></div>}
</CardContent></Card>
<Card className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]"><CardContent className="pt-6 space-y-4">
<div className="flex items-center justify-between"><div className="flex items-center gap-2 text-[var(--dg-text-primary)]"><Clock className="w-5 h-5 text-blue-500" /><span className="font-medium">定时触发</span></div><Switch checked={triggerMode === 'cron'} onCheckedChange={v => setTriggerMode(v ? 'cron' : 'manual')} /></div>
{triggerMode === 'cron' && <div className="space-y-1"><Label>Cron 表达式</Label><Input value={cronExpr} onChange={e => setCronExpr(e.target.value)} placeholder="0 2 * * *" className="h-9 font-mono" /></div>}
</CardContent></Card>
<Card className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]"><CardContent className="pt-6 space-y-4">
<div className="flex items-center justify-between"><div className="flex items-center gap-2 text-[var(--dg-text-primary)]"><Webhook className="w-5 h-5 text-blue-500" /><span className="font-medium">Webhook 触发</span></div><Switch checked={triggerMode === 'webhook'} onCheckedChange={v => setTriggerMode(v ? 'webhook' : 'manual')} /></div>
{triggerMode === 'webhook' && <div className="space-y-1"><Label>Secret Token</Label><Input value={webhookToken} onChange={e => setWebhookToken(e.target.value)} type="password" placeholder="输入签名密钥" className="h-9" /></div>}
</CardContent></Card>
<Card className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]"><CardContent className="pt-6"><div className="flex items-center gap-2 text-[var(--dg-text-primary)]"><Hand className="w-5 h-5 text-blue-500" /><span className="font-medium">手动触发</span><Badge variant="secondary" className="text-xs">默认启用</Badge></div></CardContent></Card>
</div>
</TabsContent>
{/* ====== Variables Tab ====== */}
<TabsContent value="variables" className="mt-3">
<div className="max-w-lg space-y-3">
{variables.map(([k, v], i) => (
<div key={i} className="flex gap-2 items-center">
<Input value={k} onChange={e => { const next = [...variables]; next[i][0] = e.target.value; setVariables(next); }} placeholder="KEY" className="h-9 w-40 font-mono text-sm" />
<span className="text-[var(--dg-text-secondary)]">=</span>
<Input value={v} onChange={e => { const next = [...variables]; next[i][1] = e.target.value; setVariables(next); }} placeholder="VALUE" className="h-9 flex-1" />
<Button variant="ghost" size="icon" className="h-8 w-8 text-red-500" onClick={() => setVariables(variables.filter((_, j) => j !== i))}><Trash2 className="w-3.5 h-3.5" /></Button>
</div>
))}
<Button variant="outline" size="sm" onClick={() => setVariables([...variables, ['', '']])}><Plus className="w-3.5 h-3.5 mr-1" />添加变量</Button>
</div>
</TabsContent>
{/* ====== Runs Tab ====== */}
<TabsContent value="runs" className="mt-3">
{runs.length === 0 ? (
<div className="text-center py-12 text-[var(--dg-text-secondary)]">暂无执行记录</div>
) : (
<div className="rounded-md border border-[var(--dg-border)] overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-[var(--dg-bg-tertiary)] border-b border-[var(--dg-border)]">
<tr>
<th className="px-3 py-2 text-left text-[var(--dg-text-secondary)]">#</th>
<th className="px-3 py-2 text-left text-[var(--dg-text-secondary)]">状态</th>
<th className="px-3 py-2 text-left text-[var(--dg-text-secondary)]">触发方式</th>
<th className="px-3 py-2 text-left text-[var(--dg-text-secondary)]">触发人</th>
<th className="px-3 py-2 text-left text-[var(--dg-text-secondary)]">分支</th>
<th className="px-3 py-2 text-left text-[var(--dg-text-secondary)]">耗时</th>
<th className="px-3 py-2 text-left text-[var(--dg-text-secondary)]">时间</th>
<th className="px-3 py-2 text-right text-[var(--dg-text-secondary)]">操作</th>
</tr>
</thead>
<tbody>
{runs.map((r: any) => (
<tr key={r.id} className="border-b border-[var(--dg-border)] hover:bg-[var(--dg-bg-tertiary)]/50">
<td className="px-3 py-2 font-medium text-[var(--dg-text-primary)]">#{r.build_number || r.id}</td>
<td className="px-3 py-2">
{r.status === 'success' ? <Badge className="bg-emerald-500/20 text-emerald-400 border-emerald-500/30"><Check className="w-3 h-3 mr-0.5" />成功</Badge> :
r.status === 'failed' ? <Badge className="bg-red-500/20 text-red-400 border-red-500/30"><X className="w-3 h-3 mr-0.5" />失败</Badge> :
r.status === 'running' ? <Badge className="bg-blue-500/20 text-blue-400 border-blue-500/30"><Loader2 className="w-3 h-3 animate-spin mr-0.5" />执行中</Badge> :
r.status === 'cancelled' ? <Badge className="bg-yellow-500/20 text-yellow-400 border-yellow-500/30"><StopCircle className="w-3 h-3 mr-0.5" />已停止</Badge> :
<Badge variant="secondary" className="text-[var(--dg-text-secondary)]">{r.status}</Badge>}
</td>
<td className="px-3 py-2 text-[var(--dg-text-secondary)] text-xs">
{r.trigger_mode === 'manual' && <span className="flex items-center gap-1"><Hand className="w-3 h-3" />手动</span>}
{r.trigger_mode === 'cron' && <span className="flex items-center gap-1"><Clock className="w-3 h-3" />定时</span>}
{r.trigger_mode === 'webhook' && <span className="flex items-center gap-1"><Webhook className="w-3 h-3" />Webhook</span>}
{r.trigger_mode === 'git_push' && <span className="flex items-center gap-1"><GitBranch className="w-3 h-3" />Git Push</span>}
{!r.trigger_mode && (r.triggered_by || '手动')}
</td>
<td className="px-3 py-2 text-[var(--dg-text-secondary)] text-xs">{r.triggered_by || 'admin'}</td>
<td className="px-3 py-2 text-[var(--dg-text-secondary)] font-mono text-xs">{r.branch || '-'}</td>
<td className="px-3 py-2 text-[var(--dg-text-secondary)]">{fmtDuration(r.duration)}</td>
<td className="px-3 py-2 text-[var(--dg-text-secondary)] text-xs">{r.created_at ? new Date(r.created_at).toLocaleString('zh-CN') : '-'}</td>
<td className="px-3 py-2 text-right">
<div className="flex items-center justify-end gap-1">
<Button variant="ghost" size="sm" className="h-7 text-xs text-blue-400 hover:text-blue-300" onClick={() => handleViewLog(r)}><FileText className="w-3.5 h-3.5 mr-1" />日志</Button>
{(r.status === 'running' || r.status === 'pending') && (
<Button variant="ghost" size="sm" className="h-7 text-xs text-red-400 hover:text-red-300" onClick={() => handleStop(r.id)}><StopCircle className="w-3.5 h-3.5 mr-1" />停止</Button>
)}
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</TabsContent>
{/* ====== Members Tab ====== */}
<TabsContent value="members" className="mt-3">
<div className="max-w-xl space-y-4">
{/* Add member form */}
<Card className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
<CardContent className="p-4">
<div className="flex items-center gap-3">
<UserPlus className="w-4 h-4 text-blue-500" />
<span className="text-sm font-medium text-[var(--dg-text-primary)]">添加用户</span>
</div>
<div className="flex items-end gap-3 mt-3">
<div className="flex-1 space-y-1">
<Label className="text-xs">用户名</Label>
<Input value={newMemberUser} onChange={e => setNewMemberUser(e.target.value)} placeholder="输入用户名" className="h-9" />
</div>
<div className="space-y-1">
<Label className="text-xs">角色</Label>
<Select value={newMemberRole} onValueChange={setNewMemberRole}>
<SelectTrigger className="h-9 w-28"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="owner">拥有者</SelectItem>
<SelectItem value="editor">编辑者</SelectItem>
<SelectItem value="viewer">查看者</SelectItem>
</SelectContent>
</Select>
</div>
<Button onClick={handleAddMember} className="h-9"><Plus className="w-4 h-4 mr-1" />添加</Button>
</div>
</CardContent>
</Card>
{/* Members list — split direct users vs group assignments */}
{(() => {
const directUsers = members.filter((m: any) => m.username);
const groupAssignments = members.filter((m: any) => m.group_id);
return (
<>
{/* Direct users */}
<Card className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
<CardContent className="p-0">
<div className="px-4 py-2 text-xs font-medium text-[var(--dg-text-secondary)] border-b border-[var(--dg-border)]">
👤 直接用户授权 ({directUsers.length})
</div>
{loadingMembers ? (
<div className="flex items-center justify-center py-6"><Loader2 className="w-5 h-5 animate-spin text-[var(--dg-text-secondary)]" /></div>
) : directUsers.length === 0 ? (
<div className="py-4 text-center text-[var(--dg-text-secondary)] text-xs">暂无直接用户</div>
) : (
<div className="divide-y divide-[var(--dg-border)]">
{directUsers.map((m: any) => (
<div key={m.id} className="flex items-center justify-between px-4 py-3">
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-full bg-blue-500/20 flex items-center justify-center"><Users className="w-4 h-4 text-blue-400" /></div>
<div>
<div className="text-sm font-medium text-[var(--dg-text-primary)]">{m.username}</div>
<div className="text-xs text-[var(--dg-text-secondary)]">{m.role === 'owner' ? <span className="text-amber-400">拥有者</span> : m.role === 'editor' ? <span className="text-blue-400">编辑者</span> : <span className="text-slate-400">查看者</span>}</div>
</div>
</div>
<Button variant="ghost" size="sm" className="h-7 text-xs text-red-400 hover:text-red-300" onClick={() => handleRemoveMember(m.id)}><Trash2 className="w-3.5 h-3.5 mr-1" />移除</Button>
</div>
))}
</div>
)}
</CardContent>
</Card>
{/* Group assignments */}
<Card className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
<CardContent className="p-0">
<div className="px-4 py-2 text-xs font-medium text-[var(--dg-text-secondary)] border-b border-[var(--dg-border)]">
👥 用户组授权 ({groupAssignments.length})
</div>
{groupAssignments.length === 0 ? (
<div className="py-4 text-center text-[var(--dg-text-secondary)] text-xs">
暂无组授权 — 在「<a href="/deploy/groups" className="text-blue-400 underline">用户组管理</a>」页面创建组后,可在此授权
</div>
) : (
<div className="divide-y divide-[var(--dg-border)]">
{groupAssignments.map((m: any) => (
<div key={m.id} className="flex items-center justify-between px-4 py-3">
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-full bg-purple-500/20 flex items-center justify-center"><Shield className="w-4 h-4 text-purple-400" /></div>
<div>
<div className="text-sm font-medium text-[var(--dg-text-primary)]">{m.group?.name || `组 #${m.group_id}`}</div>
<div className="text-xs text-[var(--dg-text-secondary)]">{m.role === 'owner' ? <span className="text-amber-400">拥有者</span> : m.role === 'editor' ? <span className="text-blue-400">编辑者</span> : <span className="text-slate-400">查看者</span>} · 组内全体成员</div>
</div>
</div>
<Button variant="ghost" size="sm" className="h-7 text-xs text-red-400 hover:text-red-300" onClick={() => handleRemoveMember(m.id)}><Trash2 className="w-3.5 h-3.5 mr-1" />撤销授权</Button>
</div>
))}
</div>
)}
</CardContent>
</Card>
</>
);
})()}
</div>
</TabsContent>
</Tabs>
{/* Log Dialog */}
<LogDialog open={logOpen} onClose={() => setLogOpen(false)} run={logRun} logs={logs} />
</div>
);
}