Files
FlowStack/frontend/src/pages/PipelineList.tsx
T
Logan.Li 9103b13510 feat: 流水线三级权限体系(手动多选+通配符授权)+ 菜单分级 + SSE 认证修复
权限模型(借鉴 Jenkins Role-Strategy 与云效,默认拒绝):
- 三级权限:查看(流水线+成功/失败日志)/ 发布(+构建、批量发布、重试)/ 管理(+配置、成员);admin 全局豁免
- 权限组(管理员配置):默认手动多选流水线(免正则,操作友好);高级模式仅 * 通配符(cex-*)
- 单流水线成员直授权作为例外补充;AI 批量发布逐 job 校验,越权明确拒绝

后端:
- 新增 PipelineRole/PipelineMember 表与 /api/pipeline-roles、/api/pipelines/:id/members CRUD
- RequirePipelineAccess/RequireRunAccess 中间件挂全流水线与运行路由;列表按可见性过滤并附 accessLevel/canRetry
- /api/me 会话接口;logs/stream SSE 支持 ?token= 认证(原先完全免鉴权)并校验查看权限
- 凭证/主机/集群/构建节点/工具/Webhook/Jenkins/GitOps/插件/设置全部 AdminOnly;触发人以会话身份落库

前端:
- useSession 会话 hook;Sidebar 菜单分级(资源/集成/系统管理 admin-only);admin 路由守卫
- 按钮级权限:执行/配置/删除/保存/重试按等级显隐;新页「流水线权限」与流水线成员弹窗(纯点选)
- 登录页记住密码;GUIDE/API 文档补权限章节
2026-10-27 17:34:41 +08:00

671 lines
30 KiB
TypeScript

import { useState, type CSSProperties } from 'react';
import {
Search,
Plus,
Play,
Settings,
Trash2,
GitBranch,
Clock,
CheckCircle2,
XCircle,
Loader2,
Filter,
LayoutGrid,
List,
} from 'lucide-react';
import { toast } from 'sonner';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { apiClient } from '@/lib/api';
import { useSession } from '@/providers/session';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
// ---- 工具函数 ----
function fmtDuration(ms?: number | null): string {
if (ms == null || ms < 0) return '-';
const sec = ms / 1000;
if (ms < 1000) return `${Math.max(0, Math.round(sec))}s`;
if (sec < 60) return `${sec.toFixed(1)}s`;
if (sec < 3600) return `${Math.floor(sec / 60)}分${Math.round(sec % 60)}秒`;
return `${Math.floor(sec / 3600)}小时${Math.round((sec % 3600) / 60)}分`;
}
function fmtTime(iso?: string | null): string {
return iso ? new Date(iso).toLocaleString('zh-CN', { hour12: false }) : '-';
}
function timeAgo(iso?: string | null): string {
if (!iso) return '-';
const diff = Date.now() - new Date(iso).getTime();
if (diff < 0) return '刚刚';
const s = Math.floor(diff / 1000);
if (s < 60) return `${s}秒前`;
const m = Math.floor(s / 60);
if (m < 60) return `${m}分钟前`;
const h = Math.floor(m / 60);
if (h < 24) return `${h}小时前`;
return `${Math.floor(h / 24)}天前`;
}
// ---- 类型(与后端 GET /api/pipelines 对齐)----
interface LastRun {
status: string;
startedAt: string;
durationMs: number;
triggerType: string;
}
interface PipelineDTO {
id: number;
name: string;
description?: string;
type: string; // native | jenkins | tekton
sourceBranch?: string;
deployType: string; // host | k8s | gitops
env?: string;
lastRun: LastRun | null;
}
const statusConfig: Record<string, { icon: typeof CheckCircle2; color: string; label: string }> = {
success: { icon: CheckCircle2, color: '#1a7f37', label: '成功' },
failed: { icon: XCircle, color: '#cf222e', label: '失败' },
running: { icon: Loader2, color: '#0969da', label: '执行中' },
pending: { icon: Clock, color: '#9a6700', label: '排队中' },
queued: { icon: Clock, color: '#9a6700', label: '排队中' },
cancelled: { icon: XCircle, color: '#6e7781', label: '已取消' },
none: { icon: Clock, color: '#6e7781', label: '未执行' },
};
const deployTypeConfig: Record<string, { label: string; color: string; bg: string }> = {
k8s: { label: 'Kubernetes', color: '#0969da', bg: '#ddf4ff' },
host: { label: '主机', color: '#1a7f37', bg: '#dafbe1' },
gitops: { label: 'GitOps', color: '#0e7c7b', bg: '#d5f5f6' },
};
const fallbackDeploy = { label: '未知', color: '#6e7781', bg: '#f6f8fa' };
const triggerTypeLabels: Record<string, string> = {
manual: '手动',
webhook: 'Webhook',
cron: '定时',
batch: '批量',
};
interface CreatePipelineForm {
name: string;
description: string;
sourceRepo: string;
sourceBranch: string;
deployType: string;
env: string;
}
const emptyForm: CreatePipelineForm = {
name: '',
description: '',
sourceRepo: '',
sourceBranch: 'main',
deployType: 'host',
env: 'staging',
};
const inputStyle: CSSProperties = {
background: 'var(--panel-bg-secondary)',
borderColor: 'var(--border-color-light)',
color: 'var(--text-primary)',
};
export default function PipelineList({ onSelectPipeline }: { onSelectPipeline: (id: string) => void }) {
const [viewMode, setViewMode] = useState<'card' | 'list'>('card');
const [searchQuery, setSearchQuery] = useState('');
const [statusFilter, setStatusFilter] = useState<string>('all');
const [createOpen, setCreateOpen] = useState(false);
const [form, setForm] = useState<CreatePipelineForm>(emptyForm);
const { isAdmin } = useSession();
const queryClient = useQueryClient();
const { data: pipelines = [], isLoading } = useQuery({
queryKey: ['pipelines'],
queryFn: () => apiClient.listPipelines(),
});
const createMutation = useMutation({
mutationFn: (body: CreatePipelineForm) => apiClient.createPipeline(body),
onSuccess: (_data, body) => {
toast.success('流水线已创建', { description: body.name });
queryClient.invalidateQueries({ queryKey: ['pipelines'] });
setCreateOpen(false);
setForm(emptyForm);
},
onError: (e: Error) => toast.error('创建失败', { description: e.message }),
});
const runMutation = useMutation({
mutationFn: (id: number | string) => apiClient.createRun(id),
onSuccess: (run) => toast.success(`已触发运行 ${run?.runId ?? ''}`),
onError: (e: Error) => toast.error('触发失败', { description: e.message }),
});
const deleteMutation = useMutation({
mutationFn: (id: number | string) => apiClient.deletePipeline(id),
onSuccess: () => {
toast.success('流水线已删除');
queryClient.invalidateQueries({ queryKey: ['pipelines'] });
},
onError: (e: Error) => toast.error('删除失败', { description: e.message }),
});
const items = (pipelines as PipelineDTO[]).map((p) => {
const displayStatus = p.lastRun ? p.lastRun.status : 'none';
return {
raw: p,
id: String(p.id),
name: p.name,
description: p.description || '',
branch: p.sourceBranch || '-',
type: p.type,
// 后端附带的当前用户权限等级:1 查看 / 2 执行 / 3 管理 / 4 管理员
accessLevel: (p as any).accessLevel ?? 4,
deploy: deployTypeConfig[p.deployType] || fallbackDeploy,
// 筛选用映射状态:lastRun 为空记为 pending(未执行)
mappedStatus: p.lastRun?.status ?? 'pending',
status: statusConfig[displayStatus] || statusConfig.none,
isRunning: displayStatus === 'running',
durationText: p.lastRun ? fmtDuration(p.lastRun.durationMs) : '-',
timeText: p.lastRun ? fmtTime(p.lastRun.startedAt) : '-',
timeAgoText: p.lastRun ? timeAgo(p.lastRun.startedAt) : '',
triggerText: p.lastRun ? (triggerTypeLabels[p.lastRun.triggerType] || p.lastRun.triggerType) : '-',
};
});
const filtered = items.filter((p) => {
const q = searchQuery.toLowerCase();
const matchSearch = p.name.toLowerCase().includes(q) || p.description.toLowerCase().includes(q);
const matchStatus = statusFilter === 'all' || p.mappedStatus === statusFilter;
return matchSearch && matchStatus;
});
const statusOptions = [
{ value: 'all', label: '全部状态' },
{ value: 'success', label: '成功' },
{ value: 'failed', label: '失败' },
{ value: 'running', label: '执行中' },
{ value: 'pending', label: '未执行' },
];
const handleDelete = (p: (typeof items)[number]) => {
if (window.confirm(`确定删除流水线「${p.name}」?此操作不可恢复。`)) {
deleteMutation.mutate(p.raw.id);
}
};
const submitCreate = () => {
if (!form.name.trim()) {
toast.error('请输入流水线名称');
return;
}
createMutation.mutate({ ...form, name: form.name.trim() });
};
return (
<div className="h-full flex flex-col" style={{ background: 'var(--canvas-bg)' }}>
{/* Header toolbar */}
<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)' }}
>
{items.length}
</span>
</div>
<div className="flex items-center gap-2">
{/* Search */}
<div
className="flex items-center gap-2 px-3 py-[6px] rounded-md border text-[13px]"
style={{
background: 'var(--panel-bg-secondary)',
borderColor: 'var(--border-color-light)',
color: 'var(--text-secondary)',
}}
>
<Search className="w-3.5 h-3.5" style={{ color: 'var(--text-tertiary)' }} />
<input
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="搜索流水线..."
className="bg-transparent outline-none w-40"
style={{ color: 'var(--text-primary)', fontSize: '13px' }}
/>
</div>
{/* Status filter */}
<div className="relative group">
<button
className="flex items-center gap-1.5 px-3 py-[6px] rounded-md border text-[13px] font-medium"
style={{
background: 'var(--panel-bg)',
borderColor: 'var(--border-color-light)',
color: 'var(--text-secondary)',
}}
>
<Filter className="w-3.5 h-3.5" />
{statusOptions.find(s => s.value === statusFilter)?.label}
</button>
<div className="absolute right-0 top-full mt-1 hidden group-hover:block z-50">
<div
className="rounded-md border shadow-lg py-1 min-w-[120px]"
style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}
>
{statusOptions.map((opt) => (
<button
key={opt.value}
onClick={() => setStatusFilter(opt.value)}
className="w-full text-left px-3 py-[6px] text-[13px] hover:bg-black/5 transition-colors"
style={{ color: statusFilter === opt.value ? 'var(--accent-blue)' : 'var(--text-secondary)' }}
>
{opt.label}
</button>
))}
</div>
</div>
</div>
{/* View toggle */}
<div className="flex items-center border rounded-md overflow-hidden" style={{ borderColor: 'var(--border-color-light)' }}>
<button
onClick={() => setViewMode('card')}
className="p-[6px] transition-colors"
style={{ background: viewMode === 'card' ? 'var(--panel-bg-secondary)' : 'var(--panel-bg)' }}
>
<LayoutGrid className="w-3.5 h-3.5" style={{ color: 'var(--text-secondary)' }} />
</button>
<button
onClick={() => setViewMode('list')}
className="p-[6px] transition-colors"
style={{ background: viewMode === 'list' ? 'var(--panel-bg-secondary)' : 'var(--panel-bg)' }}
>
<List className="w-3.5 h-3.5" style={{ color: 'var(--text-secondary)' }} />
</button>
</div>
{/* New pipeline button(新建/删除仅管理员) */}
{isAdmin && (
<button
className="flex items-center gap-1.5 px-3 py-[6px] text-[13px] font-medium rounded-md text-white"
style={{ background: 'var(--accent-blue)' }}
onClick={() => setCreateOpen(true)}
>
<Plus className="w-3.5 h-3.5" />
新建流水线
</button>
)}
</div>
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto custom-scrollbar p-5">
{isLoading ? (
<div className="flex flex-col items-center justify-center py-20">
<Loader2 className="w-5 h-5 animate-spin mb-2" style={{ color: 'var(--text-tertiary)' }} />
<p className="text-[12px]" style={{ color: 'var(--text-tertiary)' }}>加载中...</p>
</div>
) : filtered.length === 0 ? (
<div className="flex flex-col items-center justify-center py-20">
<div className="w-12 h-12 rounded-full flex items-center justify-center mb-3" style={{ background: 'var(--panel-bg-secondary)' }}>
<Search className="w-5 h-5" style={{ color: 'var(--text-tertiary)' }} />
</div>
<p className="text-[13px] font-medium" style={{ color: 'var(--text-secondary)' }}>未找到流水线</p>
<p className="text-[12px] mt-1" style={{ color: 'var(--text-tertiary)' }}>
{items.length === 0 && !isAdmin
? '暂无被授权的流水线,请联系管理员在「流水线权限」中授权'
: '尝试调整搜索条件或新建流水线'}
</p>
</div>
) : viewMode === 'card' ? (
<div className="grid grid-cols-1 lg:grid-cols-2 xl:grid-cols-3 gap-4">
{filtered.map((pipeline) => {
const status = pipeline.status;
const StatusIcon = status.icon;
return (
<div
key={pipeline.id}
className="rounded-lg border transition-all duration-200 hover:shadow-md cursor-pointer group"
style={{
background: 'var(--panel-bg)',
borderColor: 'var(--border-color)',
}}
onClick={() => onSelectPipeline(pipeline.id)}
>
{/* Card header */}
<div className="px-4 py-3 border-b flex items-center justify-between" style={{ borderColor: 'var(--border-color-light)' }}>
<div className="flex items-center gap-2">
<div
className={`w-2 h-2 rounded-full ${pipeline.isRunning ? 'animate-pulse' : ''}`}
style={{ background: status.color }}
/>
<span className="text-[13px] font-semibold" style={{ color: 'var(--text-primary)' }}>
{pipeline.name}
</span>
</div>
<div className="flex items-center gap-1">
<span
className="text-[10px] px-1.5 py-[2px] rounded font-medium uppercase tracking-wide"
style={{ background: pipeline.deploy.bg, color: pipeline.deploy.color }}
>
{pipeline.deploy.label}
</span>
{pipeline.type === 'jenkins' && (
<span
className="text-[10px] px-1.5 py-[2px] rounded font-medium"
style={{ background: '#fbefff', color: '#8250df' }}
>
Jenkins
</span>
)}
</div>
</div>
{/* Card body */}
<div className="px-4 py-3">
<p className="text-[12px] mb-3 line-clamp-2" style={{ color: 'var(--text-secondary)' }}>
{pipeline.description || '暂无描述'}
</p>
<div className="flex items-center gap-4 text-[11px]" style={{ color: 'var(--text-tertiary)' }}>
<div className="flex items-center gap-1">
<GitBranch className="w-3 h-3" />
{pipeline.branch}
</div>
<div className="flex items-center gap-1">
<Clock className="w-3 h-3" />
{pipeline.durationText}
</div>
</div>
</div>
{/* Card footer */}
<div
className="px-4 py-2 border-t flex items-center justify-between"
style={{ borderColor: 'var(--border-color-light)' }}
>
<div className="flex items-center gap-1.5 min-w-0">
<StatusIcon
className={`w-3.5 h-3.5 flex-shrink-0 ${pipeline.isRunning ? 'animate-pulse' : ''}`}
style={{ color: status.color }}
/>
<span className="text-[11px] font-medium" style={{ color: status.color }}>
{status.label}
</span>
<span
className="text-[11px] truncate"
style={{ color: 'var(--text-tertiary)' }}
title={pipeline.timeAgoText || undefined}
>
{pipeline.timeText}
</span>
</div>
<div className="flex items-center gap-0 opacity-0 group-hover:opacity-100 transition-opacity flex-shrink-0">
{pipeline.accessLevel >= 2 && (
<button
onClick={(e) => {
e.stopPropagation();
runMutation.mutate(pipeline.raw.id);
}}
className="p-1 rounded hover:bg-black/5"
title="执行"
>
<Play className="w-3.5 h-3.5" style={{ color: 'var(--accent-green)' }} />
</button>
)}
<button
className="p-1 rounded hover:bg-black/5"
title={pipeline.accessLevel >= 3 ? '配置' : '查看'}
onClick={(e) => {
e.stopPropagation();
onSelectPipeline(pipeline.id);
}}
>
<Settings className="w-3.5 h-3.5" style={{ color: 'var(--text-tertiary)' }} />
</button>
{isAdmin && (
<button
className="p-1 rounded hover:bg-black/5"
title="删除"
onClick={(e) => {
e.stopPropagation();
handleDelete(pipeline);
}}
>
<Trash2 className="w-3.5 h-3.5" style={{ color: 'var(--text-tertiary)' }} />
</button>
)}
</div>
</div>
</div>
);
})}
</div>
) : (
/* List view */
<div
className="rounded-lg border overflow-hidden"
style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}
>
<table className="w-full">
<thead>
<tr style={{ background: 'var(--panel-bg-secondary)' }}>
{['流水线', '分支', '部署方式', '最近执行', '耗时', '触发方式', '操作'].map((h) => (
<th key={h} className="text-left px-4 py-2 text-[11px] font-semibold uppercase tracking-wider" style={{ color: 'var(--text-tertiary)' }}>
{h}
</th>
))}
</tr>
</thead>
<tbody>
{filtered.map((pipeline) => {
const status = pipeline.status;
const StatusIcon = status.icon;
return (
<tr
key={pipeline.id}
className="border-t cursor-pointer hover:bg-black/[0.02] transition-colors"
style={{ borderColor: 'var(--border-color-light)' }}
onClick={() => onSelectPipeline(pipeline.id)}
>
<td className="px-4 py-3">
<div className="flex items-center gap-2">
<div className={`w-2 h-2 rounded-full flex-shrink-0 ${pipeline.isRunning ? 'animate-pulse' : ''}`} style={{ background: status.color }} />
<div>
<div className="text-[13px] font-medium" style={{ color: 'var(--text-primary)' }}>{pipeline.name}</div>
<div className="text-[11px]" style={{ color: 'var(--text-tertiary)' }}>{pipeline.description || '暂无描述'}</div>
</div>
</div>
</td>
<td className="px-4 py-3 text-[12px]" style={{ color: 'var(--text-secondary)' }}>
<div className="flex items-center gap-1">
<GitBranch className="w-3 h-3" />
{pipeline.branch}
</div>
</td>
<td className="px-4 py-3">
<div className="flex items-center gap-1">
<span
className="text-[10px] px-1.5 py-[2px] rounded font-medium uppercase"
style={{ background: pipeline.deploy.bg, color: pipeline.deploy.color }}
>
{pipeline.deploy.label}
</span>
{pipeline.type === 'jenkins' && (
<span
className="text-[10px] px-1.5 py-[2px] rounded font-medium"
style={{ background: '#fbefff', color: '#8250df' }}
>
Jenkins
</span>
)}
</div>
</td>
<td className="px-4 py-3">
<div className="flex items-center gap-1.5">
<StatusIcon className={`w-3.5 h-3.5 ${pipeline.isRunning ? 'animate-pulse' : ''}`} style={{ color: status.color }} />
<span className="text-[12px]" style={{ color: status.color }}>{status.label}</span>
</div>
<div className="text-[11px] mt-0.5" style={{ color: 'var(--text-tertiary)' }} title={pipeline.timeAgoText || undefined}>
{pipeline.timeText}
</div>
</td>
<td className="px-4 py-3 text-[12px]" style={{ color: 'var(--text-secondary)' }}>{pipeline.durationText}</td>
<td className="px-4 py-3 text-[12px]" style={{ color: 'var(--text-secondary)' }}>{pipeline.triggerText}</td>
<td className="px-4 py-3">
<div className="flex items-center gap-0">
{pipeline.accessLevel >= 2 && (
<button
onClick={(e) => {
e.stopPropagation();
runMutation.mutate(pipeline.raw.id);
}}
className="p-1 rounded hover:bg-black/5"
title="执行"
>
<Play className="w-3.5 h-3.5" style={{ color: 'var(--accent-green)' }} />
</button>
)}
<button
className="p-1 rounded hover:bg-black/5"
title={pipeline.accessLevel >= 3 ? '配置' : '查看'}
onClick={(e) => {
e.stopPropagation();
onSelectPipeline(pipeline.id);
}}
>
<Settings className="w-3.5 h-3.5" style={{ color: 'var(--text-tertiary)' }} />
</button>
{isAdmin && (
<button
className="p-1 rounded hover:bg-black/5"
title="删除"
onClick={(e) => {
e.stopPropagation();
handleDelete(pipeline);
}}
>
<Trash2 className="w-3.5 h-3.5" style={{ color: 'var(--text-tertiary)' }} />
</button>
)}
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</div>
{/* 新建流水线弹窗 */}
<Dialog open={createOpen} onOpenChange={(open) => { setCreateOpen(open); if (!open) setForm(emptyForm); }}>
<DialogContent className="sm:max-w-md" style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}>
<DialogHeader>
<DialogTitle style={{ color: 'var(--text-primary)' }}>新建流水线</DialogTitle>
</DialogHeader>
<div className="space-y-3">
<div>
<label className="block text-[12px] font-medium mb-1.5" style={{ color: 'var(--text-secondary)' }}>
名称 <span style={{ color: '#cf222e' }}>*</span>
</label>
<input
value={form.name}
onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))}
placeholder="例如 aiops-backend"
className="w-full px-3 py-[7px] rounded-md border text-[13px] outline-none"
style={inputStyle}
/>
</div>
<div>
<label className="block text-[12px] font-medium mb-1.5" style={{ color: 'var(--text-secondary)' }}>描述</label>
<input
value={form.description}
onChange={(e) => setForm((f) => ({ ...f, description: e.target.value }))}
placeholder="例如 Go后端服务 - Docker构建 + Kubernetes 部署"
className="w-full px-3 py-[7px] rounded-md border text-[13px] outline-none"
style={inputStyle}
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-[12px] font-medium mb-1.5" style={{ color: 'var(--text-secondary)' }}>代码仓库</label>
<input
value={form.sourceRepo}
onChange={(e) => setForm((f) => ({ ...f, sourceRepo: e.target.value }))}
placeholder="https://gitee.com/xxx.git"
className="w-full px-3 py-[7px] rounded-md border text-[13px] outline-none"
style={inputStyle}
/>
</div>
<div>
<label className="block text-[12px] font-medium mb-1.5" style={{ color: 'var(--text-secondary)' }}>默认分支</label>
<input
value={form.sourceBranch}
onChange={(e) => setForm((f) => ({ ...f, sourceBranch: e.target.value }))}
placeholder="main"
className="w-full px-3 py-[7px] rounded-md border text-[13px] outline-none"
style={inputStyle}
/>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-[12px] font-medium mb-1.5" style={{ color: 'var(--text-secondary)' }}>部署类型</label>
<Select value={form.deployType} onValueChange={(v) => setForm((f) => ({ ...f, deployType: v }))}>
<SelectTrigger className="w-full text-[13px]" style={inputStyle}>
<SelectValue placeholder="选择部署类型" />
</SelectTrigger>
<SelectContent style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}>
<SelectItem value="host">主机</SelectItem>
<SelectItem value="k8s">Kubernetes</SelectItem>
<SelectItem value="gitops">GitOps</SelectItem>
</SelectContent>
</Select>
</div>
<div>
<label className="block text-[12px] font-medium mb-1.5" style={{ color: 'var(--text-secondary)' }}>环境</label>
<Select value={form.env} onValueChange={(v) => setForm((f) => ({ ...f, env: v }))}>
<SelectTrigger className="w-full text-[13px]" style={inputStyle}>
<SelectValue placeholder="选择环境" />
</SelectTrigger>
<SelectContent style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}>
<SelectItem value="dev">开发 (dev)</SelectItem>
<SelectItem value="test">测试 (test)</SelectItem>
<SelectItem value="staging">预发 (staging)</SelectItem>
<SelectItem value="production">生产 (production)</SelectItem>
</SelectContent>
</Select>
</div>
</div>
</div>
<DialogFooter>
<button
onClick={() => setCreateOpen(false)}
className="px-4 py-[7px] text-[13px] font-medium rounded-md border"
style={{ borderColor: 'var(--border-color)', color: 'var(--text-secondary)' }}
>
取消
</button>
<button
onClick={submitCreate}
disabled={!form.name.trim() || createMutation.isPending}
className="px-4 py-[7px] text-[13px] font-medium rounded-md text-white disabled:opacity-50"
style={{ background: 'var(--accent-blue)' }}
>
{createMutation.isPending ? '创建中...' : '创建'}
</button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}