feat: 全站 PageShell 设计系统与专业 UI 统一

- 新增 PageShell/PageTabs/ContentPanel/PageLoading/PageEmpty 页面骨架
- index.css 设计令牌文档化,布局令牌与纯色主按钮(去除渐变粉彩)
- 全站路由页接入 PageShell,云账单三 Tab 为标杆
- 主题体系:跟随系统 + 主流/开发者分组,新增 Dracula/Tokyo Night,One Dark 暖灰差异化
- 登录页/侧栏/顶栏去渐变光球,企业克制风
- 修复 AIAlertAnalyzer JSX 与构建依赖导入问题
This commit is contained in:
Logan committed 2026-07-01 12:45:38 +08:00
1 parent 1acb0c49af
commit e363ded445
213 files changed
+7148 -6818

No files matched your search

+77 -134
View File
@@ -38,6 +38,10 @@ import { toast } from 'sonner';
import { getStatusBadge, getEnvBadge } from './releaseHelpers';
import { ReleaseDetailDialog } from './ReleaseDetailDialog';
import { ReleaseCreateDialog } from './ReleaseCreateDialog';
import { PageShell } from '@/components/layout/PageShell';
import { PageLoading } from '@/components/layout/PageLoading';
import { ContentPanel } from '@/components/layout/ContentPanel';
import { StatStrip } from '@/components/ui/stat-strip';
export function AppReleaseManager() {
const navigate = useNavigate();
@@ -84,113 +88,23 @@ export function AppReleaseManager() {
}
};
return (
<div className="space-y-6">
{/* Page Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-slate-100">应用发布</h1>
<p className="text-sm text-slate-500 mt-1">CI/CD流水线管理与发布历史</p>
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
className="border-slate-700 text-slate-300 hover:text-slate-100 hover:bg-slate-800"
onClick={() => navigate('/pipelines')}
>
<Settings className="h-4 w-4 mr-2" />
流水线配置
</Button>
<Button
className="bg-blue-600 hover:bg-blue-700 text-white"
onClick={() => setIsCreateDialogOpen(true)}
>
<Plus className="h-4 w-4 mr-2" />
新建发布
</Button>
</div>
</div>
{/* Stats Cards */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<Card className="bg-slate-900 border-slate-800">
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-slate-500">今日发布</p>
<p className="text-2xl font-bold text-slate-100">{releases.length}</p>
</div>
<div className="p-3 rounded-lg bg-blue-500/10 text-blue-400">
<Rocket className="h-5 w-5" />
</div>
</div>
</CardContent>
</Card>
<Card className="bg-slate-900 border-slate-800">
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-slate-500">发布成功</p>
<p className="text-2xl font-bold text-emerald-400">
{releases.filter((r) => r.status === 'success').length}
</p>
</div>
<div className="p-3 rounded-lg bg-emerald-500/10 text-emerald-400">
<CheckCircle className="h-5 w-5" />
</div>
</div>
</CardContent>
</Card>
<Card className="bg-slate-900 border-slate-800">
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-slate-500">发布失败</p>
<p className="text-2xl font-bold text-red-400">
{releases.filter((r) => r.status === 'failed').length}
</p>
</div>
<div className="p-3 rounded-lg bg-red-500/10 text-red-400">
<XCircle className="h-5 w-5" />
</div>
</div>
</CardContent>
</Card>
<Card className="bg-slate-900 border-slate-800">
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-slate-500">成功率</p>
<p className="text-2xl font-bold text-blue-400">
{releases.length > 0
? `${Math.round((releases.filter((r) => r.status === 'success').length / releases.length) * 100)}%`
: '0%'}
</p>
</div>
<div className="p-3 rounded-lg bg-blue-500/10 text-blue-400">
<History className="h-5 w-5" />
</div>
</div>
</CardContent>
</Card>
</div>
{/* Filters */}
<div className="flex items-center gap-3 flex-wrap">
const successCount = releases.filter((r) => r.status === 'success').length;
const toolbar = (
<div className="flex items-center gap-3 flex-wrap">
<div className="relative w-64">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-slate-500" />
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 dg-text-muted" />
<Input
placeholder="搜索应用名称或版本..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-9 h-9 bg-slate-800/50 border-slate-700 text-slate-200 placeholder:text-slate-500"
className="pl-9 h-9 dg-input"
/>
</div>
<Select value={envFilter} onValueChange={setEnvFilter}>
<SelectTrigger className="w-32 h-9 bg-slate-800/50 border-slate-700 text-slate-200">
<SelectTrigger className="w-32 h-9 dg-input">
<SelectValue placeholder="环境" />
</SelectTrigger>
<SelectContent className="bg-slate-800 border-slate-700">
<SelectContent className="dg-input">
<SelectItem value="all">全部环境</SelectItem>
<SelectItem value="dev">开发环境</SelectItem>
<SelectItem value="test">测试环境</SelectItem>
@@ -199,10 +113,10 @@ export function AppReleaseManager() {
</SelectContent>
</Select>
<Select value={statusFilter} onValueChange={setStatusFilter}>
<SelectTrigger className="w-32 h-9 bg-slate-800/50 border-slate-700 text-slate-200">
<SelectTrigger className="w-32 h-9 dg-input">
<SelectValue placeholder="状态" />
</SelectTrigger>
<SelectContent className="bg-slate-800 border-slate-700">
<SelectContent className="dg-input">
<SelectItem value="all">全部状态</SelectItem>
<SelectItem value="success">成功</SelectItem>
<SelectItem value="failed">失败</SelectItem>
@@ -210,50 +124,79 @@ export function AppReleaseManager() {
<SelectItem value="rolled_back">已回滚</SelectItem>
</SelectContent>
</Select>
</div>
</div>
);
{/* Releases Table */}
<Card className="bg-slate-900 border-slate-800">
<CardContent className="p-0">
<div className="border-t border-slate-800">
return (
<PageShell
variant="list"
title="应用发布"
subtitle="CI/CD 流水线管理与发布历史"
icon={<Rocket className="h-5 w-5" />}
toolbar={toolbar}
metrics={(
<StatStrip
items={[
{ label: '今日发布', value: releases.length, color: 'blue' },
{ label: '发布成功', value: successCount, color: 'green' },
{ label: '发布失败', value: releases.filter((r) => r.status === 'failed').length, color: 'red' },
{
label: '成功率',
value: releases.length > 0 ? `${Math.round((successCount / releases.length) * 100)}%` : '0%',
color: 'cyan',
},
]}
/>
)}
actions={(
<>
<Button variant="outline" className="dg-btn-secondary" onClick={() => navigate('/pipelines')}>
<Settings className="h-4 w-4 mr-2" />
流水线配置
</Button>
<Button className="dg-btn-primary" onClick={() => setIsCreateDialogOpen(true)}>
<Plus className="h-4 w-4 mr-2" />
新建发布
</Button>
</>
)}
>
<ContentPanel noPadding>
{loading ? (
<PageLoading />
) : (
<div>
<Table>
<TableHeader>
<TableRow className="border-slate-800 hover:bg-transparent">
<TableHead className="text-slate-400">应用名称</TableHead>
<TableHead className="text-slate-400">版本</TableHead>
<TableHead className="text-slate-400">环境</TableHead>
<TableHead className="text-slate-400">状态</TableHead>
<TableHead className="text-slate-400">代码</TableHead>
<TableHead className="text-slate-400">发布人</TableHead>
<TableHead className="text-slate-400">开始时间</TableHead>
<TableHead className="text-slate-400 text-right">操作</TableHead>
<TableRow className="border-[var(--dg-border)] hover:bg-transparent">
<TableHead className="dg-text-secondary">应用名称</TableHead>
<TableHead className="dg-text-secondary">版本</TableHead>
<TableHead className="dg-text-secondary">环境</TableHead>
<TableHead className="dg-text-secondary">状态</TableHead>
<TableHead className="dg-text-secondary">代码</TableHead>
<TableHead className="dg-text-secondary">发布人</TableHead>
<TableHead className="dg-text-secondary">开始时间</TableHead>
<TableHead className="dg-text-secondary text-right">操作</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{loading ? (
<TableRow className="border-slate-800">
<TableCell colSpan={8} className="text-center text-slate-500 py-8">
<Loader2 className="h-5 w-5 animate-spin inline mr-2" />
加载中...
</TableCell>
</TableRow>
) : filteredReleases.map((release) => (
<TableRow key={release.id} className="border-slate-800 hover:bg-slate-800/50">
{filteredReleases.map((release) => (
<TableRow key={release.id} className="border-[var(--dg-border)] hover:bg-[var(--dg-bg-hover)]">
<TableCell>
<div className="flex items-center gap-2">
<Rocket className="h-4 w-4 text-slate-500" />
<span className="text-sm font-medium text-slate-200">{release.app_name}</span>
<Rocket className="h-4 w-4 dg-text-muted" />
<span className="text-sm font-medium text-[var(--dg-text-primary)]">{release.app_name}</span>
</div>
</TableCell>
<TableCell>
<Badge variant="outline" className="border-slate-700 text-slate-400">
<Badge variant="outline" className="border-[var(--dg-border)] dg-text-secondary">
{release.version}
</Badge>
</TableCell>
<TableCell>{getEnvBadge(release.environment)}</TableCell>
<TableCell>{getStatusBadge(release.status)}</TableCell>
<TableCell>
<div className="flex items-center gap-1 text-xs text-slate-400">
<div className="flex items-center gap-1 text-xs dg-text-secondary">
<GitBranch className="h-3 w-3" />
<span>{release.branch}</span>
<span className="mx-1">·</span>
@@ -262,10 +205,10 @@ export function AppReleaseManager() {
</div>
</TableCell>
<TableCell>
<span className="text-sm text-slate-300">{release.deployer}</span>
<span className="text-sm text-[var(--dg-text-secondary)]">{release.deployer}</span>
</TableCell>
<TableCell>
<span className="text-sm text-slate-400">
<span className="text-sm dg-text-secondary">
{release.created_at ? new Date(release.created_at).toLocaleString() : '--'}
</span>
</TableCell>
@@ -274,7 +217,7 @@ export function AppReleaseManager() {
<Button
variant="ghost"
size="sm"
className="h-8 text-slate-400 hover:text-slate-100"
className="h-8 dg-text-secondary hover:text-[var(--dg-text-primary)]"
onClick={() => setSelectedRelease(release)}
>
<Eye className="h-4 w-4 mr-1" />
@@ -284,7 +227,7 @@ export function AppReleaseManager() {
<Button
variant="ghost"
size="sm"
className="h-8 text-slate-400 hover:text-blue-400"
className="h-8 dg-text-secondary hover:dg-text-blue"
onClick={async () => {
try {
await releaseApi.deployRelease(release.id);
@@ -303,7 +246,7 @@ export function AppReleaseManager() {
<Button
variant="ghost"
size="sm"
className="h-8 text-slate-400 hover:text-amber-400"
className="h-8 dg-text-secondary hover:dg-text-yellow"
onClick={() => handleRollback(release.id)}
>
<RotateCcw className="h-4 w-4 mr-1" />
@@ -317,8 +260,8 @@ export function AppReleaseManager() {
</TableBody>
</Table>
</div>
</CardContent>
</Card>
)}
</ContentPanel>
{/* Release Detail Dialog */}
<ReleaseDetailDialog
@@ -334,6 +277,6 @@ export function AppReleaseManager() {
onOpenChange={setIsCreateDialogOpen}
onSuccess={loadData}
/>
</div>
</PageShell>
);
}
@@ -130,7 +130,7 @@ export default function CredentialFormDialog({ open, onOpenChange, type, editId,
</SelectContent>
</Select>
{filteredCredentials.length === 0 && (
<p className="text-xs text-amber-400 flex items-center gap-1 mt-1">
<p className="text-xs dg-text-yellow flex items-center gap-1 mt-1">
<Plus className="h-3 w-3" />
暂无{isRepo ? '' : 'Docker 密码'}类型凭证,请在凭证配置页面新增
</p>
@@ -2,7 +2,7 @@ import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import {
Plus, Pencil, Trash2, RefreshCw, Key, Shield, Search,
Clock, CheckCircle2, XCircle, Zap,
Clock, CheckCircle2, XCircle,
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
@@ -15,6 +15,10 @@ import type { Credential, SecretType } from './credential-manager/types';
import { TYPE_CONFIG, TYPE_BADGE_CLASS } from './credential-manager/constants';
import { CredDetailDialog } from './credential-manager/CredDetailDialog';
import { CredFormDialog } from './credential-manager/CredFormDialog';
import { PageShell } from '@/components/layout/PageShell';
import { ContentPanel } from '@/components/layout/ContentPanel';
import { PageEmpty } from '@/components/layout/PageEmpty';
import { StatStrip } from '@/components/ui/stat-strip';
// 凭据管理页编排器 // Logan.Li
export default function CredentialManager() {
@@ -66,64 +70,77 @@ export default function CredentialManager() {
const typeOptions = Object.entries(TYPE_CONFIG) as [SecretType, typeof TYPE_CONFIG[SecretType]][];
const activeCount = credentials.filter(c => c.is_active).length;
const toolbar = (
<div className="flex items-center gap-3 flex-wrap">
<Select value={filterType} onValueChange={setFilterType}>
<SelectTrigger className="w-[140px] h-9 dg-input text-sm">
<SelectValue placeholder={t('deploy.label.allType')} />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">{t('deploy.label.allType')}</SelectItem>
{typeOptions.map(([k, v]) => (
<SelectItem key={k} value={k}>
<span className="flex items-center gap-2"><v.icon className="h-3.5 w-3.5" />{v.label}</span>
</SelectItem>
))}
</SelectContent>
</Select>
<div className="relative flex-1 max-w-xs">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-4 w-4 dg-text-muted" />
<Input
value={filterSearch}
onChange={e => setFilterSearch(e.target.value)}
placeholder={t('deploy.label.searchPlaceholder')}
className="pl-8 h-9 dg-input text-sm"
/>
</div>
<span className="text-xs dg-text-muted">{t('deploy.cred.credCount', { count: credentials.length })}</span>
</div>
);
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
<h2 className="text-lg font-semibold text-[var(--dg-text-primary)]">{t('deploy.cred.title')}</h2>
<p className="text-sm text-[var(--dg-text-muted)] mt-0.5">
{t('deploy.cred.subtitle')}
</p>
</div>
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" onClick={() => refetch()}>
<PageShell
variant="list"
title={t('deploy.cred.title')}
subtitle={t('deploy.cred.subtitle')}
icon={<Key className="h-5 w-5" />}
loading={loading}
loadingLabel={t('deploy.label.loading')}
metrics={(
<StatStrip
items={[
{ label: t('deploy.cred.title'), value: credentials.length, color: 'blue' },
{ label: t('common.enable'), value: activeCount, color: 'emerald' },
]}
/>
)}
toolbar={toolbar}
actions={(
<>
<Button variant="outline" size="sm" className="dg-btn-secondary" onClick={() => refetch()}>
<RefreshCw className="w-4 h-4 mr-1" />{t('common.refresh')}
</Button>
<Button size="sm" onClick={() => { setEditing(null); setDialogOpen(true); }}>
<Button size="sm" className="dg-btn-primary" onClick={() => { setEditing(null); setDialogOpen(true); }}>
<Plus className="w-4 h-4 mr-1" />{t('deploy.cred.addCredential')}
</Button>
</div>
</div>
{/* 搜索与筛选 // Logan.Li */}
<div className="flex items-center gap-3 flex-wrap">
<Select value={filterType} onValueChange={setFilterType}>
<SelectTrigger className="w-[140px] bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)] h-9 text-sm">
<SelectValue placeholder={t('deploy.label.allType')} />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">{t('deploy.label.allType')}</SelectItem>
{typeOptions.map(([k, v]) => (
<SelectItem key={k} value={k}>
<span className="flex items-center gap-2"><v.icon className="h-3.5 w-3.5" />{v.label}</span>
</SelectItem>
))}
</SelectContent>
</Select>
<div className="relative flex-1 max-w-xs">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-4 w-4 text-[var(--dg-text-muted)]" />
<Input value={filterSearch} onChange={e => setFilterSearch(e.target.value)}
placeholder={t('deploy.label.searchPlaceholder')}
className="pl-8 bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)] h-9 text-sm" />
</div>
<span className="text-xs text-[var(--dg-text-muted)]">{t('deploy.cred.credCount', { count: credentials.length })}</span>
</div>
{loading ? (
<div className="text-center py-16 text-[var(--dg-text-secondary)]">
<Zap className="w-6 h-6 animate-spin mx-auto mb-2 text-[var(--dg-blue)]" />{t('deploy.label.loading')}
</div>
) : credentials.length === 0 ? (
<div className="text-center py-16 border border-dashed border-[var(--dg-border)] rounded-lg">
<Shield className="w-12 h-12 mx-auto mb-3 text-[var(--dg-text-muted)]" />
<p className="text-[var(--dg-text-secondary)] mb-2">{t('deploy.cred.noCredentials')}</p>
<p className="text-xs text-[var(--dg-text-muted)] mb-4">{t('deploy.cred.subtitle')}</p>
<Button onClick={() => { setEditing(null); setDialogOpen(true); }}>
<Plus className="w-4 h-4 mr-1" />{t('deploy.cred.createFirst')}
</Button>
</div>
</>
)}
>
{!loading && credentials.length === 0 ? (
<PageEmpty
icon={<Shield className="h-12 w-12" />}
title={t('deploy.cred.noCredentials')}
description={t('deploy.cred.subtitle')}
action={(
<Button onClick={() => { setEditing(null); setDialogOpen(true); }}>
<Plus className="w-4 h-4 mr-1" />{t('deploy.cred.createFirst')}
</Button>
)}
/>
) : (
<div className="rounded-md border border-[var(--dg-border)] overflow-hidden">
<ContentPanel noPadding>
<div className="overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-[var(--dg-bg-tertiary)] border-b border-[var(--dg-border)]">
<tr>
@@ -183,12 +200,12 @@ export default function CredentialManager() {
<td className="px-3 py-2.5">
<div className="flex items-center gap-1">
{cred.is_active ? (
<Badge className="bg-green-500/20 text-green-400 text-xs border-0">{t('common.enable')}</Badge>
<Badge className="bg-green-500/20 dg-text-green text-xs border-0">{t('common.enable')}</Badge>
) : (
<Badge className="bg-red-500/20 text-red-400 text-xs border-0">{t('common.disable')}</Badge>
<Badge className="bg-red-500/20 dg-text-red text-xs border-0">{t('common.disable')}</Badge>
)}
{isExpired && (
<Badge className="bg-yellow-500/20 text-yellow-400 text-xs border-0"><Clock className="h-2.5 w-2.5 mr-0.5" />{t('deploy.status.expired')}</Badge>
<Badge className="bg-yellow-500/20 dg-text-yellow text-xs border-0"><Clock className="h-2.5 w-2.5 mr-0.5" />{t('deploy.status.expired')}</Badge>
)}
</div>
</td>
@@ -201,7 +218,7 @@ export default function CredentialManager() {
<Button variant="ghost" size="icon" className="h-7 w-7"
onClick={() => handleToggleActive(cred as Credential)}
title={cred.is_active ? t('common.disable') : t('common.enable')}>
{cred.is_active ? <XCircle className="w-3.5 h-3.5 text-yellow-500" /> : <CheckCircle2 className="w-3.5 h-3.5 text-green-500" />}
{cred.is_active ? <XCircle className="w-3.5 h-3.5 text-yellow-500" /> : <CheckCircle2 className="w-3.5 h-3.5 dg-text-green" />}
</Button>
<Button variant="ghost" size="icon" className="h-7 w-7 text-red-500"
onClick={() => handleDelete(cred as Credential)} title={t('common.delete')}>
@@ -214,11 +231,12 @@ export default function CredentialManager() {
})}
</tbody>
</table>
</div>
</div>
</ContentPanel>
)}
<CredFormDialog open={dialogOpen} onClose={() => setDialogOpen(false)} onSaved={() => refetch()} editData={editing} />
<CredDetailDialog open={detailOpen} onClose={() => setDetailOpen(false)} credId={detailId} />
</div>
</PageShell>
);
}
@@ -1,9 +1,12 @@
import { useState, useEffect } from 'react';
import { Plus, Pencil, Trash2, RefreshCw, Loader2, GitBranch, Container, Globe, Wrench} from 'lucide-react';
import { Plus, Pencil, Trash2, RefreshCw, GitBranch, Container, Globe, Settings2 } from 'lucide-react';
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 { PageShell } from '@/components/layout/PageShell';
import { ContentPanel } from '@/components/layout/ContentPanel';
import { PageEmpty } from '@/components/layout/PageEmpty';
import { StatStrip } from '@/components/ui/stat-strip';
import { Label } from '@/components/ui/label';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
@@ -106,6 +109,7 @@ export default function DeployCredentials() {
const [editingId, setEditingId] = useState<number | null>(null);
const [envDialogOpen, setEnvDialogOpen] = useState(false);
const [editEnv, setEditEnv] = useState<Environment | null>(null);
const [activeTab, setActiveTab] = useState('repos');
const fetchAll = async () => {
setLoading(true);
@@ -134,34 +138,52 @@ export default function DeployCredentials() {
const envTypeLabel: Record<string, string> = { production: '生产', staging: '预发布', testing: '测试', development: '开发' };
const pageTabs = [
{ id: 'repos', label: 'Git 仓库', badge: repos.length || undefined },
{ id: 'registries', label: '镜像仓库', badge: registries.length || undefined },
{ id: 'environments', label: '环境管理', badge: environments.length || undefined },
{ id: 'tools', label: '工具配置' },
];
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h2 className="text-lg font-semibold text-[var(--dg-text-primary)]">发布配置管理</h2>
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" onClick={fetchAll}><RefreshCw className="w-4 h-4 mr-1" />刷新</Button>
<Button size="sm" onClick={() => { setEditType('repo'); setEditingId(null); setEditorOpen(true); }}>
<PageShell
variant="list"
title="发布配置管理"
subtitle="Git 仓库、镜像仓库、环境与工具配置"
icon={<Settings2 className="h-5 w-5" />}
loading={loading}
tabs={pageTabs}
activeTab={activeTab}
onTabChange={setActiveTab}
metrics={(
<StatStrip
items={[
{ label: 'Git 仓库', value: repos.length, color: 'blue' },
{ label: '镜像仓库', value: registries.length, color: 'cyan' },
{ label: '环境', value: environments.length, color: 'purple' },
]}
/>
)}
actions={(
<>
<Button variant="outline" size="sm" className="dg-btn-secondary" onClick={fetchAll}>
<RefreshCw className="w-4 h-4 mr-1" />刷新
</Button>
<Button size="sm" className="dg-btn-primary" onClick={() => { setEditType('repo'); setEditingId(null); setEditorOpen(true); }}>
<Plus className="w-4 h-4 mr-1" />新增 Git 仓库
</Button>
<Button size="sm" variant="secondary" onClick={() => { setEditType('registry'); setEditingId(null); setEditorOpen(true); }}>
<Plus className="w-4 h-4 mr-1" />新增镜像仓库
</Button>
</div>
</div>
<Tabs defaultValue="repos">
<TabsList>
<TabsTrigger value="repos" className="gap-1"><GitBranch className="w-4 h-4" />Git 仓库 ({repos.length})</TabsTrigger>
<TabsTrigger value="registries" className="gap-1"><Container className="w-4 h-4" />镜像仓库 ({registries.length})</TabsTrigger>
<TabsTrigger value="environments" className="gap-1"><Globe className="w-4 h-4" />环境管理 ({environments.length})</TabsTrigger>
<TabsTrigger value="tools" className="gap-1"><Wrench className="w-4 h-4" />工具配置</TabsTrigger>
</TabsList>
<TabsContent value="repos" className="mt-3">
{loading ? <div className="text-center py-8 text-[var(--dg-text-secondary)]"><Loader2 className="w-5 h-5 animate-spin mx-auto mb-2" />加载中...</div>
: repos.length === 0 ? <div className="text-center py-8 text-[var(--dg-text-secondary)]">暂无 Git 仓库认证配置</div>
: (
<div className="rounded-md border border-[var(--dg-border)] overflow-hidden">
</>
)}
>
{activeTab === 'repos' && (
repos.length === 0 ? (
<PageEmpty icon={<GitBranch className="h-10 w-10" />} title="暂无 Git 仓库认证配置" />
) : (
<ContentPanel noPadding>
<div className="overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-[var(--dg-bg-tertiary)] border-b border-[var(--dg-border)]">
<tr>
@@ -179,7 +201,7 @@ export default function DeployCredentials() {
<td className="px-3 py-2 text-[var(--dg-text-secondary)] hidden md:table-cell font-mono text-xs max-w-[300px] truncate">{r.url}</td>
<td className="px-3 py-2"><Badge variant="outline" className="text-xs">{r.auth_type}</Badge></td>
<td className="px-3 py-2">
<Badge className={r.enabled ? 'bg-emerald-500/20 text-emerald-400 border-emerald-500/30' : 'bg-slate-500/20 text-slate-400 border-slate-500/30'}>{r.enabled ? '启用' : '禁用'}</Badge>
<Badge className={r.enabled ? 'bg-emerald-500/20 dg-text-green border-emerald-500/30' : 'bg-[var(--dg-bg-active)]/20 dg-text-secondary border-[var(--dg-border)]'}>{r.enabled ? '启用' : '禁用'}</Badge>
</td>
<td className="px-3 py-2 text-right">
<div className="flex justify-end gap-1">
@@ -192,14 +214,16 @@ export default function DeployCredentials() {
</tbody>
</table>
</div>
)}
</TabsContent>
</ContentPanel>
)
)}
<TabsContent value="registries" className="mt-3">
{loading ? <div className="text-center py-8 text-[var(--dg-text-secondary)]"><Loader2 className="w-5 h-5 animate-spin mx-auto mb-2" />加载中...</div>
: registries.length === 0 ? <div className="text-center py-8 text-[var(--dg-text-secondary)]">暂无镜像仓库认证配置</div>
: (
<div className="rounded-md border border-[var(--dg-border)] overflow-hidden">
{activeTab === 'registries' && (
registries.length === 0 ? (
<PageEmpty icon={<Container className="h-10 w-10" />} title="暂无镜像仓库认证配置" />
) : (
<ContentPanel noPadding>
<div className="overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-[var(--dg-bg-tertiary)] border-b border-[var(--dg-border)]">
<tr>
@@ -219,7 +243,7 @@ export default function DeployCredentials() {
<td className="px-3 py-2 text-[var(--dg-text-secondary)] hidden sm:table-cell">{r.namespace || '-'}</td>
<td className="px-3 py-2"><Badge variant="outline" className="text-xs">{r.auth_type}</Badge></td>
<td className="px-3 py-2">
<Badge className={r.enabled ? 'bg-emerald-500/20 text-emerald-400 border-emerald-500/30' : 'bg-slate-500/20 text-slate-400 border-slate-500/30'}>{r.enabled ? '启用' : '禁用'}</Badge>
<Badge className={r.enabled ? 'bg-emerald-500/20 dg-text-green border-emerald-500/30' : 'bg-[var(--dg-bg-active)]/20 dg-text-secondary border-[var(--dg-border)]'}>{r.enabled ? '启用' : '禁用'}</Badge>
</td>
<td className="px-3 py-2 text-right">
<div className="flex justify-end gap-1">
@@ -232,20 +256,31 @@ export default function DeployCredentials() {
</tbody>
</table>
</div>
)}
</TabsContent>
</ContentPanel>
)
)}
{/* Environments tab */}
<TabsContent value="environments" className="mt-3">
{activeTab === 'environments' && (
<>
<div className="flex items-center justify-end mb-3">
<Button size="sm" onClick={() => { setEditEnv(null); setEnvDialogOpen(true); }}>
<Button size="sm" className="dg-btn-primary" onClick={() => { setEditEnv(null); setEnvDialogOpen(true); }}>
<Plus className="w-4 h-4 mr-1" />新增环境
</Button>
</div>
{loading ? <div className="text-center py-8 text-[var(--dg-text-secondary)]"><Loader2 className="w-5 h-5 animate-spin mx-auto mb-2" />加载中...</div>
: environments.length === 0 ? <div className="text-center py-8 text-[var(--dg-text-secondary)]">暂无环境配置,点击"新增环境"创建</div>
: (
<div className="rounded-md border border-[var(--dg-border)] overflow-hidden">
{environments.length === 0 ? (
<PageEmpty
icon={<Globe className="h-10 w-10" />}
title="暂无环境配置"
description='点击"新增环境"创建'
action={(
<Button size="sm" onClick={() => { setEditEnv(null); setEnvDialogOpen(true); }}>
<Plus className="w-4 h-4 mr-1" />新增环境
</Button>
)}
/>
) : (
<ContentPanel noPadding>
<div className="overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-[var(--dg-bg-tertiary)] border-b border-[var(--dg-border)]">
<tr>
@@ -272,7 +307,7 @@ export default function DeployCredentials() {
<td className="px-3 py-2"><div className="w-6 h-6 rounded border" style={{ backgroundColor: env.color }} /></td>
<td className="px-3 py-2 text-[var(--dg-text-secondary)]">{env.sort_order}</td>
<td className="px-3 py-2">
<Badge className={env.enabled ? 'bg-emerald-500/20 text-emerald-400 border-emerald-500/30' : 'bg-slate-500/20 text-slate-400 border-slate-500/30'}>{env.enabled ? '启用' : '禁用'}</Badge>
<Badge className={env.enabled ? 'bg-emerald-500/20 dg-text-green border-emerald-500/30' : 'bg-[var(--dg-bg-active)]/20 dg-text-secondary border-[var(--dg-border)]'}>{env.enabled ? '启用' : '禁用'}</Badge>
</td>
<td className="px-3 py-2 text-right">
<div className="flex justify-end gap-1">
@@ -284,15 +319,13 @@ export default function DeployCredentials() {
))}
</tbody>
</table>
</div>
</div>
</ContentPanel>
)}
</TabsContent>
</>
)}
{/* Tools config tab */}
<TabsContent value="tools" className="mt-3">
<ToolConfigTab />
</TabsContent>
</Tabs>
{activeTab === 'tools' && <ToolConfigTab />}
<CredentialFormDialog
open={editorOpen} onOpenChange={setEditorOpen} type={editType} editId={editingId}
@@ -300,6 +333,6 @@ export default function DeployCredentials() {
/>
<EnvFormDialog open={envDialogOpen} onClose={() => setEnvDialogOpen(false)} onSaved={fetchAll} editData={editEnv} />
</div>
</PageShell>
);
}
+39 -34
View File
@@ -9,6 +9,9 @@ import { Label } from '@/components/ui/label';
import { toast } from 'sonner';
import { deployApi, userApi, pipelineApi } from '@/api';
import { useConfirm } from '@/components/ui/confirm-dialog';
import { PageShell } from '@/components/layout/PageShell';
import { PageEmpty } from '@/components/layout/PageEmpty';
import { StatStrip } from '@/components/ui/stat-strip';
import type { JsonData } from '@/types/common';
interface ItemRole {
@@ -159,36 +162,41 @@ export default function DeployRBAC() {
};
const roleBadgeClass = (role: string) =>
role === 'owner' ? 'bg-amber-500/20 text-amber-400 border-amber-500/30' :
role === 'editor' ? 'bg-blue-500/20 text-blue-400 border-blue-500/30' :
'bg-slate-500/20 text-slate-400 border-slate-500/30';
role === 'owner' ? 'bg-amber-500/20 dg-text-yellow border-amber-500/30' :
role === 'editor' ? 'bg-blue-500/20 dg-text-blue border-blue-500/30' :
'bg-[var(--dg-bg-active)]/20 dg-text-secondary border-[var(--dg-border)]';
const selectedUser = users.find(u => String(u.id) === selectedUserId);
if (loading) return <div className="flex items-center justify-center py-20"><Loader2 className="w-6 h-6 animate-spin" /></div>;
const activeRoles = roles.filter(r => r.status === 1).length;
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)]">
<Shield className="w-5 h-5 text-purple-500" />发布权限
</h1>
<p className="text-sm text-[var(--dg-text-secondary)] mt-1">
Jenkins-style Role Strategy — 按流水线 code 正则匹配分配角色
</p>
</div>
<Button onClick={() => setShowCreate(true)} disabled={showCreate}>
<PageShell
variant="list"
title="发布权限"
subtitle="Jenkins-style Role Strategy — 按流水线 code 正则匹配分配角色"
icon={<Shield className="h-5 w-5" />}
loading={loading}
metrics={(
<StatStrip
items={[
{ label: 'Item Role', value: roles.length, color: 'purple' },
{ label: '已启用', value: activeRoles, color: 'emerald' },
{ label: '流水线', value: pipelines.length, color: 'blue' },
]}
/>
)}
actions={(
<Button className="dg-btn-primary" onClick={() => setShowCreate(true)} disabled={showCreate}>
<Plus className="w-4 h-4 mr-1" />新建角色
</Button>
</div>
)}
>
{/* ====== Pipeline Preview ====== */}
<Card className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
<CardContent className="p-4">
<div className="flex items-center gap-2 mb-3">
<GitBranch className="w-4 h-4 text-blue-500" />
<GitBranch className="w-4 h-4 dg-text-blue" />
<span className="text-sm font-medium text-[var(--dg-text-primary)]">流水线命中预览</span>
<span className="text-xs text-[var(--dg-text-muted)]">— 选择一个角色查看哪些流水线会被匹配</span>
</div>
@@ -215,7 +223,7 @@ export default function DeployRBAC() {
})
}
{previewRoleId !== null && previewMatches.length === 0 && (
<span className="text-xs text-red-400">无匹配 — 请调整正则表达式</span>
<span className="text-xs dg-text-red">无匹配 — 请调整正则表达式</span>
)}
</div>
</CardContent>
@@ -266,10 +274,7 @@ export default function DeployRBAC() {
{/* Role list */}
{roles.length === 0 ? (
<div className="text-center py-12 text-[var(--dg-text-secondary)]">
<Shield className="w-10 h-10 mx-auto mb-2 opacity-20" />
<p className="text-sm">暂无 Item Role</p>
</div>
<PageEmpty icon={<Shield className="h-10 w-10" />} title="暂无 Item Role" description="创建角色后按正则匹配流水线并分配给用户" />
) : (
<div className="space-y-2">
{roles.map(role => (
@@ -278,21 +283,21 @@ export default function DeployRBAC() {
<div className="flex items-center justify-between">
<div className="flex items-center gap-3 flex-1 min-w-0">
<div className="w-9 h-9 rounded-lg bg-purple-500/20 flex items-center justify-center shrink-0">
<Regex className="w-4 h-4 text-purple-400" />
<Regex className="w-4 h-4 dg-text-purple" />
</div>
<div className="min-w-0">
<div className="flex items-center gap-1.5 flex-wrap">
<span className="text-sm font-semibold text-[var(--dg-text-primary)]">{role.name}</span>
<Badge className={`text-xs border ${roleBadgeClass(role.role)}`}>{role.role === 'owner' ? '拥有者' : role.role === 'editor' ? '编辑者' : '查看者'}</Badge>
{role.status === 0 && <Badge variant="secondary" className="text-xs text-red-400">已禁用</Badge>}
{role.status === 0 && <Badge variant="secondary" className="text-xs dg-text-red">已禁用</Badge>}
<Badge variant="secondary" className="text-xs cursor-pointer" onClick={() => setPreviewRoleId(previewRoleId === role.id ? null : role.id)}>
<GitBranch className="w-3 h-3 mr-0.5" />{matchCount[role.id]?.length || 0} 条
</Badge>
</div>
<div className="text-xs text-[var(--dg-text-secondary)] mt-0.5 truncate">
<code className="font-mono text-purple-400 bg-purple-500/10 px-1 rounded">{role.code}</code>
<code className="font-mono dg-text-purple bg-purple-500/10 px-1 rounded">{role.code}</code>
<span className="mx-1">→</span>
<code className="font-mono text-blue-400 bg-blue-500/10 px-1 rounded">{role.pattern}</code>
<code className="font-mono dg-text-blue bg-blue-500/10 px-1 rounded">{role.pattern}</code>
{role.description && <span className="ml-2 opacity-60">{role.description}</span>}
</div>
</div>
@@ -309,8 +314,8 @@ export default function DeployRBAC() {
<SelectItem value="viewer">查看者</SelectItem>
</SelectContent>
</Select>
<Button size="icon" variant="ghost" className="h-7 w-7 text-green-400" onClick={() => handleUpdate(role.id)}><Check className="w-3.5 h-3.5" /></Button>
<Button size="icon" variant="ghost" className="h-7 w-7 text-red-400" onClick={() => setEditing(null)}><X className="w-3.5 h-3.5" /></Button>
<Button size="icon" variant="ghost" className="h-7 w-7 dg-text-green" onClick={() => handleUpdate(role.id)}><Check className="w-3.5 h-3.5" /></Button>
<Button size="icon" variant="ghost" className="h-7 w-7 dg-text-red" onClick={() => setEditing(null)}><X className="w-3.5 h-3.5" /></Button>
</>
) : (
<>
@@ -331,7 +336,7 @@ export default function DeployRBAC() {
<Card className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
<CardContent className="p-4">
<div className="flex items-center gap-2 mb-3">
<UserPlus className="w-4 h-4 text-amber-400" />
<UserPlus className="w-4 h-4 dg-text-yellow" />
<span className="text-sm font-medium text-[var(--dg-text-primary)]">分配角色给用户</span>
</div>
<div className="flex gap-3 items-end mb-4">
@@ -344,7 +349,7 @@ export default function DeployRBAC() {
{users.map(u => (
<SelectItem key={u.id} value={String(u.id)}>
{u.real_name || u.username} ({u.username})
{assignments.some(a => a.user_id === u.id) && <span className="ml-2 text-xs text-purple-400">已分配</span>}
{assignments.some(a => a.user_id === u.id) && <span className="ml-2 text-xs dg-text-purple">已分配</span>}
</SelectItem>
))}
</SelectContent>
@@ -395,7 +400,7 @@ export default function DeployRBAC() {
</code>
)}
</div>
<Button variant="ghost" size="icon" className="h-6 w-6 text-red-400" onClick={() => handleRevoke(a.id)}>
<Button variant="ghost" size="icon" className="h-6 w-6 dg-text-red" onClick={() => handleRevoke(a.id)}>
<X className="w-3 h-3" />
</Button>
</div>
@@ -407,6 +412,6 @@ export default function DeployRBAC() {
)}
</CardContent>
</Card>
</div>
</PageShell>
);
}
@@ -175,7 +175,7 @@ export function FileBrowserDialog({
)}
{error && (
<div className="flex items-center justify-center h-full text-red-400 text-sm">
<div className="flex items-center justify-center h-full dg-text-red text-sm">
{error}
</div>
)}
@@ -218,7 +218,7 @@ export function FileBrowserDialog({
onClick={() => loadDir(dir.path)}
>
<td className="py-2 pl-4 pr-2 flex items-center gap-2">
<FolderOpen className="h-4 w-4 text-amber-400 shrink-0" />
<FolderOpen className="h-4 w-4 dg-text-yellow shrink-0" />
<span className="text-[var(--dg-text-primary)]">{dir.name}</span>
</td>
<td className="py-2 px-2 text-right text-[var(--dg-text-muted)]">-</td>
@@ -44,6 +44,8 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/u
import { toast } from 'sonner';
import { pipelineApi, deployApi } from '@/api';
import { useConfirm } from '@/components/ui/confirm-dialog';
import { PageShell } from '@/components/layout/PageShell';
import { PageEmpty } from '@/components/layout/PageEmpty';
import type { JsonData } from '@/types/common';
// ---- types ----
@@ -76,10 +78,10 @@ function TaskCard({ task, selected, onSelect, onDelete }: { task: Task; selected
<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)]'
selected ? 'border-[var(--dg-blue)] bg-[color-mix(in_srgb,var(--dg-blue)_12%,transparent)]' : 'border-[var(--dg-border)] hover:border-[var(--dg-border-hover)] bg-[var(--dg-bg-secondary)]'
}`}
>
<div className={`p-1.5 rounded ${selected ? 'bg-blue-100' : 'bg-slate-100 dark:bg-slate-800'}`}>
<div className={`p-1.5 rounded ${selected ? 'bg-[color-mix(in_srgb,var(--dg-blue)_15%,transparent)]' : 'bg-[var(--dg-bg-tertiary)]'}`}>
<Icon className={`w-4 h-4 ${selected ? 'text-blue-600' : 'text-[var(--dg-text-secondary)]'}`} />
</div>
<div className="flex-1 min-w-0">
@@ -113,7 +115,7 @@ function TaskEditor({ task, onUpdate }: { task: Task | null; onUpdate: (t: Task)
<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'" />
<Textarea value={task.script} onChange={e => onUpdate({ ...task, script: e.target.value })} className="font-mono text-xs min-h-[200px] bg-[var(--dg-bg-primary)] dg-text-green" placeholder="#!/bin/bash\nset -e\necho 'hello'" />
</div>
</div>
);
@@ -132,18 +134,18 @@ function LogDialog({ open, onClose, run, logs }: { open: boolean; onClose: () =>
<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">
<div className="bg-[var(--dg-bg-primary)] 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>
<div className="dg-text-muted 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="dg-text-muted 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 === 'error' ? 'dg-text-red' :
l.level === 'warn' ? 'dg-text-yellow' :
l.level === 'success' ? 'dg-text-green' :
'text-[var(--dg-text-secondary)]'
}>
{l.level === 'success' ? '✓' : l.level === 'error' ? '✗' : l.level === 'warn' ? '!' : '>'} {l.message}
</span>
@@ -323,27 +325,48 @@ export default function PipelineEditor() {
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>;
const editorTabs = [
{ id: 'flow', label: '流程配置' },
{ id: 'triggers', label: '触发设置' },
{ id: 'variables', label: '变量' },
{ id: 'runs', label: '发布记录', badge: runs.length || undefined },
{ id: 'members', label: '成员管理', badge: members.length || undefined },
];
if (!loading && !pipeline) {
return (
<PageShell variant="workspace" title="流水线编辑" icon={<GitBranch className="h-5 w-5" />}>
<PageEmpty title="流水线不存在" description="请检查链接或返回列表重试" />
</PageShell>
);
}
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>
<PageShell
variant="workspace"
title={pipeline?.name || '流水线编辑'}
subtitle={pipeline ? `code: ${pipeline.code} · ${pipeline.description || ''}` : undefined}
icon={<GitBranch className="h-5 w-5" />}
loading={loading}
tabs={editorTabs}
activeTab={activeTab}
onTabChange={setActiveTab}
actions={(
<>
{envBadge && (
<Badge style={{ backgroundColor: envBadge.color + '20', color: envBadge.color, borderColor: envBadge.color + '40' }} className="text-xs">
{envBadge.display_name}
</Badge>
)}
<Button variant="outline" className="dg-btn-secondary" onClick={handleSave} disabled={saving}>
<Save className="w-4 h-4 mr-1" />{saving ? '保存中...' : '保存'}
</Button>
<Button className="dg-btn-primary" onClick={handleTrigger}>
<Play className="w-4 h-4 mr-1" />运行
</Button>
</>
)}
>
{/* Environment Selector - quick bar */}
<Card className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
<CardContent className="p-3 flex items-center gap-3">
@@ -368,14 +391,6 @@ export default function PipelineEditor() {
</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">
@@ -433,18 +448,18 @@ export default function PipelineEditor() {
<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>
<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 dg-text-blue" /><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>
<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 dg-text-blue" /><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>
<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 dg-text-blue" /><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>
<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 dg-text-blue" /><span className="font-medium">手动触发</span><Badge variant="secondary" className="text-xs">默认启用</Badge></div></CardContent></Card>
</div>
</TabsContent>
@@ -487,10 +502,10 @@ export default function PipelineEditor() {
<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> :
{r.status === 'success' ? <Badge className="bg-emerald-500/20 dg-text-green border-emerald-500/30"><Check className="w-3 h-3 mr-0.5" />成功</Badge> :
r.status === 'failed' ? <Badge className="bg-red-500/20 dg-text-red border-red-500/30"><X className="w-3 h-3 mr-0.5" />失败</Badge> :
r.status === 'running' ? <Badge className="bg-blue-500/20 dg-text-blue 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 dg-text-yellow 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">
@@ -506,9 +521,9 @@ export default function PipelineEditor() {
<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>
<Button variant="ghost" size="sm" className="h-7 text-xs dg-text-blue 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>
<Button variant="ghost" size="sm" className="h-7 text-xs dg-text-red hover:dg-text-red" onClick={() => handleStop(r.id)}><StopCircle className="w-3.5 h-3.5 mr-1" />停止</Button>
)}
</div>
</td>
@@ -527,7 +542,7 @@ export default function PipelineEditor() {
<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" />
<UserPlus className="w-4 h-4 dg-text-blue" />
<span className="text-sm font-medium text-[var(--dg-text-primary)]">添加用户</span>
</div>
<div className="flex items-end gap-3 mt-3">
@@ -572,13 +587,13 @@ export default function PipelineEditor() {
{directUsers.map((m: JsonData) => (
<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 className="w-8 h-8 rounded-full bg-blue-500/20 flex items-center justify-center"><Users className="w-4 h-4 dg-text-blue" /></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 className="text-xs text-[var(--dg-text-secondary)]">{m.role === 'owner' ? <span className="dg-text-yellow">拥有者</span> : m.role === 'editor' ? <span className="dg-text-blue">编辑者</span> : <span className="dg-text-secondary">查看者</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>
<Button variant="ghost" size="sm" className="h-7 text-xs dg-text-red hover:dg-text-red" onClick={() => handleRemoveMember(m.id)}><Trash2 className="w-3.5 h-3.5 mr-1" />移除</Button>
</div>
))}
</div>
@@ -594,20 +609,20 @@ export default function PipelineEditor() {
</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>」页面创建组后,可在此授权
暂无组授权 — 在「<a href="/deploy/groups" className="dg-text-blue underline">用户组管理</a>」页面创建组后,可在此授权
</div>
) : (
<div className="divide-y divide-[var(--dg-border)]">
{groupAssignments.map((m: JsonData) => (
<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 className="w-8 h-8 rounded-full bg-purple-500/20 flex items-center justify-center"><Shield className="w-4 h-4 dg-text-purple" /></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 className="text-xs text-[var(--dg-text-secondary)]">{m.role === 'owner' ? <span className="dg-text-yellow">拥有者</span> : m.role === 'editor' ? <span className="dg-text-blue">编辑者</span> : <span className="dg-text-secondary">查看者</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>
<Button variant="ghost" size="sm" className="h-7 text-xs dg-text-red hover:dg-text-red" onClick={() => handleRemoveMember(m.id)}><Trash2 className="w-3.5 h-3.5 mr-1" />撤销授权</Button>
</div>
))}
</div>
@@ -623,6 +638,6 @@ export default function PipelineEditor() {
{/* Log Dialog */}
<LogDialog open={logOpen} onClose={() => setLogOpen(false)} run={logRun} logs={logs} />
</div>
</PageShell>
);
}
@@ -8,7 +8,6 @@ import {
GitBranch,
Clock,
CheckCircle,
Loader2,
Rocket,
Container,
ArrowRight,
@@ -26,6 +25,9 @@ import { useConfirm } from '@/components/ui/confirm-dialog';
import { toast } from 'sonner';
import { PipelineCard } from './pipeline-manager/PipelineCard';
import { PipelineCreateDialog } from './pipeline-manager/PipelineCreateDialog';
import { PageShell } from '@/components/layout/PageShell';
import { PageEmpty } from '@/components/layout/PageEmpty';
import { StatStrip } from '@/components/ui/stat-strip';
// 流水线列表页编排器 // Logan.Li
export function PipelineManager() {
@@ -155,21 +157,68 @@ export function PipelineManager() {
return matchSearch && matchEnv;
});
const runningCount = pipelines.filter(p => p.last_status === 'running').length;
const toolbar = (
<div className="flex items-center gap-4 flex-wrap">
<div className="relative max-w-md flex-1 min-w-[200px]">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 dg-text-secondary" />
<Input
placeholder={t('deploy.pipeline.searchPlaceholder')}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-10 dg-input"
/>
</div>
<div className="flex items-center gap-1 bg-[var(--dg-bg-secondary)] rounded-lg p-1 border border-[var(--dg-border)]">
{ENV_TYPES.map((et) => {
const active = envFilter === et.key;
const Icon = et.icon;
return (
<button
key={et.key}
onClick={() => setEnvFilter(et.key)}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium transition-all ${
active ? 'text-white shadow-sm' : 'text-[var(--dg-text-secondary)] hover:text-[var(--dg-text-primary)]'
}`}
style={active ? { backgroundColor: et.color } : {}}
>
<Icon className="h-3.5 w-3.5" />{et.label}
</button>
);
})}
</div>
</div>
);
return (
<div className="space-y-5">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-[var(--dg-text-primary)]">{t('deploy.pipeline.title')}</h1>
<p className="text-sm text-[var(--dg-text-secondary)] mt-1">{t('deploy.pipeline.subtitle')}</p>
</div>
<div className="flex items-center gap-2">
<Button variant="outline" className="border-[var(--dg-border)] text-[var(--dg-text-secondary)]" onClick={() => refetchPipelines()}>
<PageShell
variant="list"
title={t('deploy.pipeline.title')}
subtitle={t('deploy.pipeline.subtitle')}
icon={<Workflow className="h-5 w-5" />}
loading={loading}
metrics={(
<StatStrip
items={[
{ label: t('deploy.pipeline.title'), value: pipelines.length, color: 'blue' },
{ label: t('common.running'), value: runningCount, color: 'amber' },
{ label: t('deploy.env.all'), value: filtered.length, color: 'muted' },
]}
/>
)}
toolbar={toolbar}
actions={(
<>
<Button variant="outline" className="dg-btn-secondary" onClick={() => refetchPipelines()}>
<Clock className={`h-4 w-4 mr-2 ${loading ? 'animate-spin' : ''}`} />{t('common.refresh')}
</Button>
<Button onClick={() => setNewOpen(true)}><Plus className="h-4 w-4 mr-2" />{t('deploy.pipeline.newPipeline')}</Button>
</div>
</div>
<Button className="dg-btn-primary" onClick={() => setNewOpen(true)}>
<Plus className="h-4 w-4 mr-2" />{t('deploy.pipeline.newPipeline')}
</Button>
</>
)}
>
{/* 全流程可视化横幅 — 双路径 // Logan.Li */}
<div className="flex items-stretch gap-3 px-4 py-3 rounded-lg bg-[var(--dg-bg-secondary)] border border-[var(--dg-border)]">
<div className="flex-1 space-y-1.5">
@@ -177,12 +226,12 @@ export function PipelineManager() {
<GitBranch className="h-3 w-3 text-indigo-400" />
<span className="text-[10px] font-semibold text-indigo-400">{t('deploy.banner.build')}</span>
<ArrowRight className="h-2.5 w-2.5 text-[var(--dg-text-secondary)] opacity-30" />
<Rocket className="h-3 w-3 text-red-400" />
<span className="text-[10px] font-semibold text-red-400">{t('deploy.banner.deploy')}</span>
<Rocket className="h-3 w-3 dg-text-red" />
<span className="text-[10px] font-semibold dg-text-red">{t('deploy.banner.deploy')}</span>
<ArrowRight className="h-2.5 w-2.5 text-[var(--dg-text-secondary)] opacity-30" />
<div className="flex items-center gap-1 px-2 py-0.5 rounded bg-blue-400/10 border border-blue-400/20">
<Server className="h-3 w-3 text-blue-400" />
<span className="text-[10px] font-medium text-blue-400">{t('deploy.banner.sshHost')}</span>
<Server className="h-3 w-3 dg-text-blue" />
<span className="text-[10px] font-medium dg-text-blue">{t('deploy.banner.sshHost')}</span>
</div>
</div>
<p className="text-[9px] text-[var(--dg-text-secondary)] opacity-40 pl-1">{t('deploy.banner.sshDesc')}</p>
@@ -193,15 +242,15 @@ export function PipelineManager() {
<GitBranch className="h-3 w-3 text-indigo-400" />
<span className="text-[10px] font-semibold text-indigo-400">{t('deploy.banner.build')}</span>
<ArrowRight className="h-2.5 w-2.5 text-[var(--dg-text-secondary)] opacity-30" />
<Container className="h-3 w-3 text-cyan-400" />
<span className="text-[10px] font-semibold text-cyan-400">{t('deploy.banner.imageRegistry')}</span>
<Container className="h-3 w-3 dg-text-cyan" />
<span className="text-[10px] font-semibold dg-text-cyan">{t('deploy.banner.imageRegistry')}</span>
<ArrowRight className="h-2.5 w-2.5 text-[var(--dg-text-secondary)] opacity-30" />
<Rocket className="h-3 w-3 text-red-400" />
<span className="text-[10px] font-semibold text-red-400">{t('deploy.banner.deploy')}</span>
<Rocket className="h-3 w-3 dg-text-red" />
<span className="text-[10px] font-semibold dg-text-red">{t('deploy.banner.deploy')}</span>
<ArrowRight className="h-2.5 w-2.5 text-[var(--dg-text-secondary)] opacity-30" />
<div className="flex items-center gap-1 px-2 py-0.5 rounded bg-emerald-400/10 border border-emerald-400/20">
<Network className="h-3 w-3 text-emerald-400" />
<span className="text-[10px] font-medium text-emerald-400">{t('deploy.banner.kubernetes')}</span>
<Network className="h-3 w-3 dg-text-green" />
<span className="text-[10px] font-medium dg-text-green">{t('deploy.banner.kubernetes')}</span>
</div>
</div>
<p className="text-[9px] text-[var(--dg-text-secondary)] opacity-40 pl-1">{t('deploy.banner.k8sDesc')}</p>
@@ -209,48 +258,16 @@ export function PipelineManager() {
<span className="self-center ml-2 text-[10px] text-[var(--dg-text-secondary)] opacity-30 tracking-wide whitespace-nowrap">{t('deploy.banner.fullFlow')}</span>
</div>
<div className="flex items-center gap-4">
<div className="relative max-w-md flex-1">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-[var(--dg-text-secondary)]" />
<Input
placeholder={t('deploy.pipeline.searchPlaceholder')}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-10 bg-[var(--dg-bg-secondary)] border-[var(--dg-border)] text-[var(--dg-text-primary)]"
/>
</div>
<div className="flex items-center gap-1 bg-[var(--dg-bg-secondary)] rounded-lg p-1 border border-[var(--dg-border)]">
{ENV_TYPES.map((et) => {
const active = envFilter === et.key;
const Icon = et.icon;
return (
<button
key={et.key}
onClick={() => setEnvFilter(et.key)}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium transition-all ${
active ? 'text-white shadow-sm' : 'text-[var(--dg-text-secondary)] hover:text-[var(--dg-text-primary)]'
}`}
style={active ? { backgroundColor: et.color } : {}}
>
<Icon className="h-3.5 w-3.5" />{et.label}
</button>
);
})}
</div>
</div>
{loading ? (
<div className="flex items-center justify-center py-12">
<Loader2 className="h-6 w-6 text-[var(--dg-text-secondary)] animate-spin" />
</div>
) : filtered.length === 0 ? (
<div className="text-center py-16 text-[var(--dg-text-secondary)]">
<Workflow className="h-10 w-10 mx-auto mb-3 opacity-30" />
<p className="text-sm">{t('deploy.pipeline.noData')}</p>
<Button variant="outline" className="mt-4" onClick={() => setNewOpen(true)}>
<Plus className="h-4 w-4 mr-1" />{t('deploy.pipeline.createFirst')}
</Button>
</div>
{!loading && filtered.length === 0 ? (
<PageEmpty
icon={<Workflow className="h-10 w-10" />}
title={t('deploy.pipeline.noData')}
action={(
<Button variant="outline" onClick={() => setNewOpen(true)}>
<Plus className="h-4 w-4 mr-1" />{t('deploy.pipeline.createFirst')}
</Button>
)}
/>
) : (
<div className="space-y-3">
{filtered.map((pipeline) => {
@@ -275,6 +292,6 @@ export function PipelineManager() {
)}
<PipelineCreateDialog open={newOpen} onOpenChange={setNewOpen} onCreated={refetchPipelines} />
</div>
</PageShell>
);
}
@@ -12,6 +12,8 @@ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { toast } from 'sonner';
import { pipelineApi } from '@/api';
import { FileBrowserDialog } from './FileBrowserDialog';
import { PageShell } from '@/components/layout/PageShell';
import { StatStrip } from '@/components/ui/stat-strip';
import type { JsonData } from '@/types/common';
// ═══════════ 阶段定义 (与 PipelineManager 共用) ═══════════
@@ -55,11 +57,11 @@ interface StageStatus {
}
const STATUS_BADGES: Record<string, string> = {
success: 'bg-emerald-500/20 text-emerald-400 border-emerald-500/30',
failed: 'bg-red-500/20 text-red-400 border-red-500/30',
running: 'bg-blue-500/20 text-blue-400 border-blue-500/30',
pending: 'bg-slate-500/20 text-slate-400 border-slate-500/30',
cancelled: 'bg-amber-500/20 text-amber-400 border-amber-500/30',
success: 'bg-emerald-500/20 dg-text-green border-emerald-500/30',
failed: 'bg-red-500/20 dg-text-red border-red-500/30',
running: 'bg-blue-500/20 dg-text-blue border-blue-500/30',
pending: 'bg-[var(--dg-bg-active)]/20 dg-text-secondary border-[var(--dg-border)]',
cancelled: 'bg-amber-500/20 dg-text-yellow border-amber-500/30',
};
const STATUS_ICONS: JsonData = {
@@ -290,37 +292,35 @@ export default function PipelineRunDetail() {
// 获取当前选中阶段的摘要日志
const stageSummary = selectedStage ? getStageLogSummary(logs, selectedStage) : '';
const successCount = runs.filter(r => r.status === 'success').length;
const runningCount = runs.filter(r => r.status === 'running').length;
return (
<div className="space-y-4 h-full flex flex-col">
{/* ═══════════ 页面头部 ═══════════ */}
<div className="flex items-center justify-between">
<div>
<h2 className="text-lg font-semibold text-[var(--dg-text-primary)]">
<History className="w-5 h-5 inline mr-2" />
流水线详情
</h2>
<div className="flex items-center gap-2 mt-0.5 flex-wrap">
{pipeline?.name && (
<span className="text-sm font-medium text-[var(--dg-text-primary)]">
{pipeline.name}
</span>
)}
{pipeline?.environment && (
<Badge className="text-xs" style={{
backgroundColor: pipeline.environment.color + '20',
color: pipeline.environment.color,
}}>
{pipeline.environment.display_name}
</Badge>
)}
{selectedRun && (
<span className="text-sm text-[var(--dg-text-secondary)]">
发布 #{selectedRun.build_number}
</span>
)}
</div>
</div>
<div className="flex gap-2 items-center">
<PageShell
variant="workspace"
title={pipeline?.name || '流水线详情'}
subtitle={selectedRun ? `发布 #${selectedRun.build_number}` : (pipeline?.code ? `code: ${pipeline.code}` : '执行记录与日志')}
icon={<History className="h-5 w-5" />}
contentClassName="space-y-4 h-full flex flex-col"
metrics={(
<StatStrip
items={[
{ label: '执行记录', value: runs.length, color: 'blue' },
{ label: '成功', value: successCount, color: 'emerald' },
{ label: '执行中', value: runningCount, color: 'amber' },
]}
/>
)}
actions={(
<>
{pipeline?.environment && (
<Badge className="text-xs" style={{
backgroundColor: pipeline.environment.color + '20',
color: pipeline.environment.color,
}}>
{pipeline.environment.display_name}
</Badge>
)}
{selectedIds.size > 0 && (
<Button variant="destructive" size="sm" onClick={batchDelete} disabled={deleting}>
<Trash2 className="w-4 h-4 mr-1" />删除 ({selectedIds.size})
@@ -329,21 +329,21 @@ export default function PipelineRunDetail() {
<select
value={pageSize}
onChange={e => setPageSize(Number(e.target.value))}
className="h-8 px-2 text-xs rounded border border-[var(--dg-border)] bg-[var(--dg-bg-secondary)] text-[var(--dg-text-primary)]"
className="h-8 px-2 text-xs rounded border border-[var(--dg-border)] dg-input"
>
<option value={10}>10条/页</option>
<option value={20}>20条/页</option>
<option value={30}>30条/页</option>
</select>
<Button variant="outline" size="sm" onClick={fetchRuns}>
<Button variant="outline" size="sm" className="dg-btn-secondary" onClick={fetchRuns}>
<RefreshCw className="w-4 h-4 mr-1" />刷新
</Button>
<Button size="sm" onClick={triggerRun}>
<Button size="sm" className="dg-btn-primary" onClick={triggerRun}>
<Play className="w-4 h-4 mr-1" />立即执行
</Button>
</div>
</div>
</>
)}
>
{/* ═══════════ 阶段进度条 (交互式) ═══════════ */}
<div className="flex items-center gap-0 px-4 py-3 rounded-lg bg-[var(--dg-bg-secondary)] border border-[var(--dg-border)] overflow-x-auto">
{STAGE_DEFS.map((def, i) => {
@@ -473,7 +473,7 @@ export default function PipelineRunDetail() {
</td>
<td className="px-3 py-2.5 text-[var(--dg-text-secondary)] font-mono text-xs">
{run.status === 'running' ? (
<span className="flex items-center gap-1 text-blue-400">
<span className="flex items-center gap-1 dg-text-blue">
<Loader2 className="w-3 h-3 animate-spin" />执行中...
</span>
) : formatDuration(run.duration)}
@@ -516,7 +516,7 @@ export default function PipelineRunDetail() {
{pipeline?.name || `#${pipelineCode}`} #{selectedRun.build_number}
</CardTitle>
{selectedRun.status === 'running' && (
<Badge className="bg-blue-500/20 text-blue-400 border-blue-500/30 text-xs">
<Badge className="bg-blue-500/20 dg-text-blue border-blue-500/30 text-xs">
<Loader2 className="w-3 h-3 animate-spin mr-1" />实时
</Badge>
)}
@@ -533,7 +533,7 @@ export default function PipelineRunDetail() {
{selectedRun.commit_id && (
<>
<span className="opacity-40">|</span>
<code className="bg-amber-400/10 text-amber-400 px-1 py-0.5 rounded text-[10px]">
<code className="bg-amber-400/10 dg-text-yellow px-1 py-0.5 rounded text-[10px]">
{selectedRun.commit_id.slice(0, 8)}
</code>
</>
@@ -541,7 +541,7 @@ export default function PipelineRunDetail() {
{selectedRun.image_tag && (
<>
<span className="opacity-40">|</span>
<code className="bg-cyan-400/10 text-cyan-400 px-1 py-0.5 rounded text-[10px]">
<code className="bg-cyan-400/10 dg-text-cyan px-1 py-0.5 rounded text-[10px]">
📦 {selectedRun.image_tag}
</code>
</>
@@ -558,9 +558,9 @@ export default function PipelineRunDetail() {
</span>
{(() => {
const st = stageStats.get(selectedStage);
if (st === 'success' || st === 'completed') return <CheckCircle2 className="h-3.5 w-3.5 text-emerald-400" />;
if (st === 'failed' || st === 'error') return <XCircle className="h-3.5 w-3.5 text-red-400" />;
if (st === 'running' || st === 'in_progress') return <Loader2 className="h-3.5 w-3.5 text-blue-400 animate-spin" />;
if (st === 'success' || st === 'completed') return <CheckCircle2 className="h-3.5 w-3.5 dg-text-green" />;
if (st === 'failed' || st === 'error') return <XCircle className="h-3.5 w-3.5 dg-text-red" />;
if (st === 'running' || st === 'in_progress') return <Loader2 className="h-3.5 w-3.5 dg-text-blue animate-spin" />;
return null;
})()}
<span className="text-xs text-[var(--dg-text-muted)] truncate font-mono">
@@ -571,7 +571,7 @@ export default function PipelineRunDetail() {
)}
<div className="h-full overflow-y-auto bg-[#0d1117] font-mono text-xs leading-5 p-4" style={{ minHeight: 280 }}>
{filteredLogs.length === 0 && selectedRun.status !== 'running' ? (
<div className="text-center py-12 text-slate-500">
<div className="text-center py-12 dg-text-muted">
{selectedStage ? '该阶段暂无日志输出' : '暂无日志输出'}
</div>
) : (
@@ -601,25 +601,25 @@ export default function PipelineRunDetail() {
: isSuccess ? 'border-l-2 border-emerald-500 bg-emerald-500/5' : '';
const msgColor = isError ? 'text-red-200'
: isWarn ? 'text-amber-200'
: isSuccess ? 'text-emerald-200' : 'text-slate-300';
: isSuccess ? 'text-emerald-200' : 'text-[var(--dg-text-secondary)]';
const levelBadge = isError
? <span className="shrink-0 px-1.5 py-px rounded text-[10px] font-bold bg-red-600/40 text-red-200">ERR</span>
: isWarn
? <span className="shrink-0 px-1.5 py-px rounded text-[10px] font-bold bg-amber-600/40 text-amber-200">WARN</span>
: isSuccess
? <span className="shrink-0 px-1.5 py-px rounded text-[10px] font-bold bg-emerald-600/30 text-emerald-200"> OK </span>
: <span className="shrink-0 px-1.5 py-px rounded text-[10px] bg-slate-600/30 text-slate-400">INFO</span>;
: <span className="shrink-0 px-1.5 py-px rounded text-[10px] bg-[var(--dg-bg-active)]/30 dg-text-secondary">INFO</span>;
return (
<React.Fragment key={i}>
{showStage && (
<div className={`flex items-center gap-2 my-2 py-1 px-2 border-l-4 rounded-r ${stageStyles[entry.stage || ''] || 'border-slate-500 bg-slate-500/10'}`}>
<div className={`flex items-center gap-2 my-2 py-1 px-2 border-l-4 rounded-r ${stageStyles[entry.stage || ''] || 'border-[var(--dg-border)] bg-[var(--dg-bg-active)]/10'}`}>
<span className="text-xs">{stageIcons[entry.stage || ''] || '▸'}</span>
<span className="text-[11px] font-semibold tracking-wider text-slate-200">{entry.stage}</span>
<span className="flex-1 border-t border-slate-700/50"></span>
<span className="text-[11px] font-semibold tracking-wider text-[var(--dg-text-primary)]">{entry.stage}</span>
<span className="flex-1 border-t border-[var(--dg-border)]/50"></span>
</div>
)}
<div className={`flex items-start gap-2 px-2 py-0.5 -mx-1 rounded-sm ${accent} ${!isError && !isWarn && !isSuccess ? 'hover:bg-white/[0.03]' : ''}`}>
<span className="text-slate-600 shrink-0 w-14 text-right text-[10px] pt-px">{entry.time}</span>
<span className="dg-text-muted shrink-0 w-14 text-right text-[10px] pt-px">{entry.time}</span>
{levelBadge}
<span className={`break-all ${msgColor}`}>{entry.message}</span>
</div>
@@ -629,7 +629,7 @@ export default function PipelineRunDetail() {
})()}
{selectedRun.status === 'running' && (
<div className="flex items-center gap-2 mt-2 py-1.5 px-3 bg-blue-500/10 border-l-4 border-blue-500 rounded-r">
<Loader2 className="w-3 h-3 animate-spin text-blue-400 shrink-0" />
<Loader2 className="w-3 h-3 animate-spin dg-text-blue shrink-0" />
<span className="text-[11px] text-blue-300 font-medium">流水线执行中,实时输出...</span>
<span className="flex gap-1 ml-1">
<span className="w-1 h-1 rounded-full bg-blue-400 animate-bounce" style={{ animationDelay: '0ms' }}></span>
@@ -654,6 +654,6 @@ export default function PipelineRunDetail() {
runId={browserRun?.id ?? null}
buildNumber={browserRun?.buildNumber ?? 0}
/>
</div>
</PageShell>
);
}
@@ -73,7 +73,7 @@ export function ReleaseApproveDialog({ release, open, onOpenChange, onSuccess }:
<DialogFooter>
<Button
variant="outline"
className="border-red-600 text-red-400 hover:bg-red-600/20"
className="border-red-600 dg-text-red hover:bg-red-600/20"
disabled={submitting}
onClick={() => handleApprove(false)}
>
@@ -74,41 +74,41 @@ export function ReleaseCreateDialog({ open, onOpenChange, onSuccess }: ReleaseCr
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-2xl bg-slate-900 border-slate-800">
<DialogContent className="max-w-2xl dg-card">
<DialogHeader>
<DialogTitle className="text-slate-100">新建发布</DialogTitle>
<DialogDescription className="text-slate-500">创建新的应用版本发布申请</DialogDescription>
<DialogTitle className="text-[var(--dg-text-primary)]">新建发布</DialogTitle>
<DialogDescription className="dg-text-muted">创建新的应用版本发布申请</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div>
<Label className="text-slate-300">应用名称</Label>
<Label className="text-[var(--dg-text-secondary)]">应用名称</Label>
<Input
value={form.appName}
onChange={(e) => update('appName', e.target.value)}
placeholder="如:用户中心服务"
className="mt-1 bg-slate-800/50 border-slate-700 text-slate-200 placeholder:text-slate-500"
className="mt-1 dg-input"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<Label className="text-slate-300">版本号</Label>
<Label className="text-[var(--dg-text-secondary)]">版本号</Label>
<Input
value={form.version}
onChange={(e) => update('version', e.target.value)}
placeholder="v1.0.0"
className="mt-1 bg-slate-800/50 border-slate-700 text-slate-200 placeholder:text-slate-500"
className="mt-1 dg-input"
/>
</div>
<div>
<Label className="text-slate-300">目标环境</Label>
<Label className="text-[var(--dg-text-secondary)]">目标环境</Label>
<Select
value={form.environment}
onValueChange={(value) => update('environment', value)}
>
<SelectTrigger className="mt-1 bg-slate-800/50 border-slate-700 text-slate-200">
<SelectTrigger className="mt-1 dg-input">
<SelectValue />
</SelectTrigger>
<SelectContent className="bg-slate-800 border-slate-700">
<SelectContent className="dg-input">
<SelectItem value="dev">开发环境</SelectItem>
<SelectItem value="test">测试环境</SelectItem>
<SelectItem value="staging">预发布</SelectItem>
@@ -119,36 +119,36 @@ export function ReleaseCreateDialog({ open, onOpenChange, onSuccess }: ReleaseCr
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<Label className="text-slate-300">Commit ID</Label>
<Label className="text-[var(--dg-text-secondary)]">Commit ID</Label>
<Input
value={form.commitId}
onChange={(e) => update('commitId', e.target.value)}
placeholder="abc1234"
className="mt-1 bg-slate-800/50 border-slate-700 text-slate-200 placeholder:text-slate-500"
className="mt-1 dg-input"
/>
</div>
<div>
<Label className="text-slate-300">提交信息</Label>
<Label className="text-[var(--dg-text-secondary)]">提交信息</Label>
<Input
value={form.commitMsg}
onChange={(e) => update('commitMsg', e.target.value)}
placeholder="feat: 新增功能"
className="mt-1 bg-slate-800/50 border-slate-700 text-slate-200 placeholder:text-slate-500"
className="mt-1 dg-input"
/>
</div>
</div>
<div>
<Label className="text-slate-300">发布说明</Label>
<Label className="text-[var(--dg-text-secondary)]">发布说明</Label>
<Textarea
value={form.description}
onChange={(e) => update('description', e.target.value)}
placeholder="描述本次发布的内容..."
className="mt-1 bg-slate-800/50 border-slate-700 text-slate-200 placeholder:text-slate-500"
className="mt-1 dg-input"
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" className="border-slate-700 text-slate-300" onClick={() => onOpenChange(false)}>
<Button variant="outline" className="border-[var(--dg-border)] text-[var(--dg-text-secondary)]" onClick={() => onOpenChange(false)}>
取消
</Button>
<Button className="bg-blue-600 hover:bg-blue-700 text-white" onClick={handleSubmit} disabled={submitting}>
@@ -45,16 +45,16 @@ export function ReleaseDetailDialog({ release, open, onOpenChange, onSuccess }:
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-4xl bg-slate-900 border-slate-800">
<DialogContent className="max-w-4xl dg-card">
<DialogHeader>
<DialogTitle className="text-slate-100 flex items-center gap-2">
<Rocket className="h-5 w-5 text-blue-400" />
<DialogTitle className="text-[var(--dg-text-primary)] flex items-center gap-2">
<Rocket className="h-5 w-5 dg-text-blue" />
{release?.app_name}
<Badge variant="outline" className="border-slate-700 text-slate-400">
<Badge variant="outline" className="border-[var(--dg-border)] dg-text-secondary">
{release?.version}
</Badge>
</DialogTitle>
<DialogDescription className="text-slate-500">
<DialogDescription className="dg-text-muted">
环境: {release?.environment}
</DialogDescription>
</DialogHeader>
@@ -62,7 +62,7 @@ export function ReleaseDetailDialog({ release, open, onOpenChange, onSuccess }:
<div className="space-y-4">
{/* Pipeline Steps */}
<div className="space-y-2">
<h4 className="text-sm font-medium text-slate-300">发布进度</h4>
<h4 className="text-sm font-medium text-[var(--dg-text-secondary)]">发布进度</h4>
<div className="space-y-2">
{PIPELINE_STEPS.map((step, index) => (
<div
@@ -71,15 +71,15 @@ export function ReleaseDetailDialog({ release, open, onOpenChange, onSuccess }:
'flex items-center gap-3 p-3 rounded-lg',
step.status === 'success' && 'bg-emerald-500/5',
step.status === 'running' && 'bg-blue-500/5',
step.status === 'pending' && 'bg-slate-800/50'
step.status === 'pending' && 'bg-[var(--dg-bg-tertiary)]/50'
)}
>
<div
className={cn(
'w-6 h-6 rounded-full flex items-center justify-center',
step.status === 'success' && 'bg-emerald-500/20 text-emerald-400',
step.status === 'running' && 'bg-blue-500/20 text-blue-400',
step.status === 'pending' && 'bg-slate-700 text-slate-500'
step.status === 'success' && 'bg-emerald-500/20 dg-text-green',
step.status === 'running' && 'bg-blue-500/20 dg-text-blue',
step.status === 'pending' && 'bg-[var(--dg-bg-active)] dg-text-muted'
)}
>
{step.status === 'success' ? (
@@ -90,8 +90,8 @@ export function ReleaseDetailDialog({ release, open, onOpenChange, onSuccess }:
<span className="text-xs">{index + 1}</span>
)}
</div>
<span className="flex-1 text-sm text-slate-200">{step.name}</span>
<span className="text-sm text-slate-500">{step.duration}</span>
<span className="flex-1 text-sm text-[var(--dg-text-primary)]">{step.name}</span>
<span className="text-sm dg-text-muted">{step.duration}</span>
</div>
))}
</div>
@@ -99,23 +99,23 @@ export function ReleaseDetailDialog({ release, open, onOpenChange, onSuccess }:
{/* Build Logs */}
<div className="space-y-2">
<h4 className="text-sm font-medium text-slate-300">构建日志</h4>
<div className="bg-slate-950 rounded-lg p-4 font-mono text-xs text-slate-400 h-48 overflow-auto">
<h4 className="text-sm font-medium text-[var(--dg-text-secondary)]">构建日志</h4>
<div className="bg-[var(--dg-bg-primary)] rounded-lg p-4 font-mono text-xs dg-text-secondary h-48 overflow-auto">
<div className="space-y-1">
<p>[10:00:00] Starting pipeline for {release?.app_name}...</p>
<p>[10:00:15] Checkout code from branch {release?.branch}</p>
<p>[10:00:30] Installing dependencies...</p>
<p className="text-emerald-400">[10:01:15] Dependencies installed successfully</p>
<p className="dg-text-green">[10:01:15] Dependencies installed successfully</p>
<p>[10:01:15] Running code analysis...</p>
<p className="text-emerald-400">[10:01:45] Code analysis passed</p>
<p className="dg-text-green">[10:01:45] Code analysis passed</p>
<p>[10:01:45] Running unit tests...</p>
<p className="text-emerald-400">[10:04:00] All tests passed (156/156)</p>
<p className="dg-text-green">[10:04:00] All tests passed (156/156)</p>
<p>[10:04:00] Building Docker image...</p>
<p className="text-emerald-400">[10:07:30] Image built successfully</p>
<p className="dg-text-green">[10:07:30] Image built successfully</p>
<p>[10:07:30] Pushing image to registry...</p>
<p className="text-emerald-400">[10:08:50] Image pushed successfully</p>
<p className="dg-text-green">[10:08:50] Image pushed successfully</p>
<p>[10:08:50] Deploying to Kubernetes...</p>
<p className="text-blue-400">[10:10:00] Deployment in progress...</p>
<p className="dg-text-blue">[10:10:00] Deployment in progress...</p>
</div>
</div>
</div>
@@ -37,6 +37,10 @@ import { toast } from 'sonner';
import { getStatusBadge, getEnvironmentBadge } from './releaseManagerHelpers';
import { ReleaseApplyDialog } from './ReleaseApplyDialog';
import { ReleaseApproveDialog } from './ReleaseApproveDialog';
import { PageShell } from '@/components/layout/PageShell';
import { PageLoading } from '@/components/layout/PageLoading';
import { PageEmpty } from '@/components/layout/PageEmpty';
import { StatStrip } from '@/components/ui/stat-strip';
export function ReleaseManager() {
const _confirm = useConfirm();
@@ -106,40 +110,8 @@ export function ReleaseManager() {
}
};
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-[var(--dg-text-primary)]">发布管理</h1>
<p className="text-sm text-[var(--dg-text-secondary)] mt-1">管理应用版本发布与审批流程</p>
</div>
<Button onClick={() => setIsCreateDialogOpen(true)}>
<Plus className="h-4 w-4 mr-2" />
新建发布
</Button>
</div>
{/* Stats */}
<div className="grid grid-cols-5 gap-4">
{[
{ label: '待审批', val: releases.filter((r) => r.status === 'pending').length, color: 'text-amber-400' },
{ label: '发布中', val: releases.filter((r) => r.status === 'deploying').length, color: 'text-blue-400' },
{ label: '发布成功', val: releases.filter((r) => r.status === 'success').length, color: 'text-emerald-400' },
{ label: '发布失败', val: releases.filter((r) => r.status === 'failed').length, color: 'text-red-400' },
{ label: '已回滚', val: releases.filter((r) => r.status === 'rolled_back').length, color: 'text-slate-400' },
].map((s) => (
<Card key={s.label} className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
<CardContent className="p-4">
<p className="text-[var(--dg-text-secondary)] text-sm">{s.label}</p>
<p className={`text-2xl font-bold ${s.color}`}>{s.val}</p>
</CardContent>
</Card>
))}
</div>
{/* Filters */}
<div className="flex items-center gap-4">
const toolbar = (
<div className="flex items-center gap-4 flex-wrap">
<div className="relative flex-1 max-w-md">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-[var(--dg-text-secondary)]" />
<Input
@@ -176,17 +148,38 @@ export function ReleaseManager() {
<SelectItem value="rolled_back">已回滚</SelectItem>
</SelectContent>
</Select>
</div>
</div>
);
return (
<PageShell
variant="list"
title="发布管理"
subtitle="管理应用版本发布与审批流程"
icon={<Rocket className="h-5 w-5" />}
toolbar={toolbar}
metrics={(
<StatStrip
items={[
{ label: '待审批', value: releases.filter((r) => r.status === 'pending').length, color: 'amber' },
{ label: '发布中', value: releases.filter((r) => r.status === 'deploying').length, color: 'blue' },
{ label: '发布成功', value: releases.filter((r) => r.status === 'success').length, color: 'green' },
{ label: '发布失败', value: releases.filter((r) => r.status === 'failed').length, color: 'red' },
{ label: '已回滚', value: releases.filter((r) => r.status === 'rolled_back').length, color: 'muted' },
]}
/>
)}
actions={(
<Button onClick={() => setIsCreateDialogOpen(true)} className="dg-btn-primary">
<Plus className="h-4 w-4 mr-2" />
新建发布
</Button>
)}
>
{loading ? (
<div className="flex items-center justify-center py-12">
<Loader2 className="h-6 w-6 text-[var(--dg-text-secondary)] animate-spin" />
</div>
<PageLoading />
) : filteredReleases.length === 0 ? (
<div className="flex flex-col items-center justify-center py-16 text-[var(--dg-text-secondary)]">
<PackageOpen className="h-12 w-12 mb-3 opacity-50" />
<p className="text-sm">暂无发布记录</p>
</div>
<PageEmpty icon={<PackageOpen className="h-12 w-12" />} title="暂无发布记录" />
) : (
<div className="space-y-3">
{filteredReleases.map((release) => (
@@ -231,7 +224,7 @@ export function ReleaseManager() {
<Button
variant="outline"
size="sm"
className="border-emerald-600 text-emerald-400 hover:bg-emerald-600/20"
className="border-emerald-600 dg-text-green hover:bg-emerald-600/20"
onClick={() => {
setSelectedRelease(release);
setIsApproveDialogOpen(true);
@@ -245,7 +238,7 @@ export function ReleaseManager() {
<Button
variant="outline"
size="sm"
className="border-blue-600 text-blue-400 hover:bg-blue-600/20"
className="border-blue-600 dg-text-blue hover:bg-blue-600/20"
onClick={() => handleDeploy(release.id, release.app_name, release.version)}
>
<Play className="h-4 w-4 mr-1" />
@@ -256,7 +249,7 @@ export function ReleaseManager() {
<Button
variant="outline"
size="sm"
className="border-amber-600 text-amber-400 hover:bg-amber-600/20"
className="border-amber-600 dg-text-yellow hover:bg-amber-600/20"
onClick={() => handleRollback(release.id, release.app_name, release.version)}
>
<RotateCcw className="h-4 w-4 mr-1" />
@@ -363,6 +356,6 @@ export function ReleaseManager() {
</DialogFooter>
</DialogContent>
</Dialog>
</div>
</PageShell>
);
}
@@ -24,18 +24,18 @@ const categoryLabel: Record<string, string> = {
build: '构建', deploy: '部署', automation: '自动化', other: '其他',
};
const categoryColor: Record<string, string> = {
build: 'bg-amber-500/20 text-amber-400 border-amber-500/30',
deploy: 'bg-cyan-500/20 text-cyan-400 border-cyan-500/30',
automation: 'bg-purple-500/20 text-purple-400 border-purple-500/30',
other: 'bg-slate-500/20 text-slate-400 border-slate-500/30',
build: 'bg-amber-500/20 dg-text-yellow border-amber-500/30',
deploy: 'bg-cyan-500/20 dg-text-cyan border-cyan-500/30',
automation: 'bg-purple-500/20 dg-text-purple border-purple-500/30',
other: 'bg-[var(--dg-bg-active)]/20 dg-text-secondary border-[var(--dg-border)]',
};
const _methodLabel: Record<string, string> = {
preinstall: '预装', lazy: '懒加载', disabled: '禁用',
};
const _methodColor: Record<string, string> = {
preinstall: 'bg-emerald-500/20 text-emerald-400 border-emerald-500/30',
lazy: 'bg-violet-500/20 text-violet-400 border-violet-500/30',
disabled: 'bg-slate-500/20 text-slate-400 border-slate-500/30',
preinstall: 'bg-emerald-500/20 dg-text-green border-emerald-500/30',
lazy: 'bg-violet-500/20 dg-text-purple border-violet-500/30',
disabled: 'bg-[var(--dg-bg-active)]/20 dg-text-secondary border-[var(--dg-border)]',
};
// Tools that support version selection
@@ -177,10 +177,10 @@ export default function ToolConfigTab() {
<div className="text-[10px] text-[var(--dg-text-muted)] mt-0.5 max-w-[220px] leading-tight">{tool.description}</div>
)}
{tool.installed_version && tool.status === 'installed' && (
<div className="text-xs text-emerald-400 font-mono mt-0.5">v{tool.installed_version}</div>
<div className="text-xs dg-text-green font-mono mt-0.5">v{tool.installed_version}</div>
)}
{tool.error_msg && (
<div className="text-xs text-red-400 mt-0.5 max-w-[200px] truncate" title={tool.error_msg}>{tool.error_msg}</div>
<div className="text-xs dg-text-red mt-0.5 max-w-[200px] truncate" title={tool.error_msg}>{tool.error_msg}</div>
)}
</td>
<td className="px-3 py-2 hidden sm:table-cell">
@@ -205,19 +205,19 @@ export default function ToolConfigTab() {
</td>
<td className="px-3 py-2">
{tool.status === 'installed' ? (
<Badge className="bg-emerald-500/20 text-emerald-400 border-emerald-500/30 text-xs gap-1">
<Badge className="bg-emerald-500/20 dg-text-green border-emerald-500/30 text-xs gap-1">
<CheckCircle className="w-3 h-3" />已安装
</Badge>
) : tool.status === 'installing' ? (
<Badge className="bg-amber-500/20 text-amber-400 border-amber-500/30 text-xs gap-1">
<Badge className="bg-amber-500/20 dg-text-yellow border-amber-500/30 text-xs gap-1">
<Loader2 className="w-3 h-3 animate-spin" />安装中
</Badge>
) : tool.status === 'failed' ? (
<Badge className="bg-red-500/20 text-red-400 border-red-500/30 text-xs gap-1">
<Badge className="bg-red-500/20 dg-text-red border-red-500/30 text-xs gap-1">
<XCircle className="w-3 h-3" />失败
</Badge>
) : (
<Badge className="bg-slate-500/20 text-slate-400 border-slate-500/30 text-xs gap-1">
<Badge className="bg-[var(--dg-bg-active)]/20 dg-text-secondary border-[var(--dg-border)] text-xs gap-1">
<Clock className="w-3 h-3" />未安装
</Badge>
)}
@@ -1,6 +1,9 @@
import { useState, useEffect } from 'react';
import { Plus, Trash2, Pencil, RefreshCw, Loader2, Users, UserPlus, UserX, Shield } from 'lucide-react';
import { Plus, Trash2, Pencil, RefreshCw, Users, UserPlus, UserX, Shield } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { PageShell } from '@/components/layout/PageShell';
import { PageEmpty } from '@/components/layout/PageEmpty';
import { StatStrip } from '@/components/ui/stat-strip';
import { Input } from '@/components/ui/input';
import { Badge } from '@/components/ui/badge';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
@@ -78,35 +81,52 @@ export default function UserGroupsManager() {
const toggleExpand = (gid: number) => setExpanded(p => ({ ...p, [gid]: !p[gid] }));
const memberCount = groups.reduce((sum, g) => sum + (g.members || []).length, 0);
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
<h2 className="text-lg font-semibold text-[var(--dg-text-primary)]">用户组管理</h2>
<p className="text-sm text-[var(--dg-text-secondary)] mt-1">创建用户组,将组授权给流水线,组内成员自动获得流水线权限</p>
</div>
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" onClick={loadGroups}><RefreshCw className="w-4 h-4 mr-1" />刷新</Button>
<Button size="sm" onClick={() => { setEditId(null); setFormName(''); setFormDesc(''); setFormOpen(true); }}>
<PageShell
variant="list"
title="用户组管理"
subtitle="创建用户组,将组授权给流水线,组内成员自动获得流水线权限"
icon={<Users className="h-5 w-5" />}
loading={loading}
metrics={(
<StatStrip
items={[
{ label: '用户组', value: groups.length, color: 'blue' },
{ label: '成员总数', value: memberCount, color: 'purple' },
]}
/>
)}
actions={(
<>
<Button variant="outline" size="sm" className="dg-btn-secondary" onClick={loadGroups}>
<RefreshCw className="w-4 h-4 mr-1" />刷新
</Button>
<Button size="sm" className="dg-btn-primary" onClick={() => { setEditId(null); setFormName(''); setFormDesc(''); setFormOpen(true); }}>
<Plus className="w-4 h-4 mr-1" />新建用户组
</Button>
</div>
</div>
{loading ? (
<div className="flex justify-center py-12"><Loader2 className="w-6 h-6 animate-spin text-[var(--dg-text-secondary)]" /></div>
) : groups.length === 0 ? (
<div className="text-center py-12 text-[var(--dg-text-secondary)]">
<Users className="w-10 h-10 mx-auto mb-3 opacity-30" />
<p>暂无用户组,点击「新建用户组」创建</p>
</div>
</>
)}
>
{!loading && groups.length === 0 ? (
<PageEmpty
icon={<Users className="h-10 w-10" />}
title="暂无用户组"
description="点击「新建用户组」创建第一个用户组"
action={(
<Button size="sm" onClick={() => { setEditId(null); setFormName(''); setFormDesc(''); setFormOpen(true); }}>
<Plus className="w-4 h-4 mr-1" />新建用户组
</Button>
)}
/>
) : (
<div className="space-y-3">
{groups.map(g => (
<div key={g.id} className="border border-[var(--dg-border)] rounded-lg bg-[var(--dg-bg-secondary)]">
<div className="flex items-center justify-between p-4">
<div className="flex items-center gap-3" style={{ cursor: 'pointer' }} onClick={() => toggleExpand(g.id)}>
<Shield className="w-5 h-5 text-blue-400" />
<Shield className="w-5 h-5 dg-text-blue" />
<div>
<div className="font-medium text-[var(--dg-text-primary)]">{g.name}</div>
<div className="text-xs text-[var(--dg-text-secondary)]">{g.description || '暂无描述'} · {(g.members || []).length} 位成员</div>
@@ -139,7 +159,7 @@ export default function UserGroupsManager() {
{(g.members || []).map(m => (
<Badge key={m.id} variant="secondary" className="flex items-center gap-1 pl-2 pr-1 py-1.5">
<Users className="w-3 h-3" />{m.username}
<button className="ml-1 hover:text-red-400" onClick={() => handleRemoveMember(g.id, m.id, m.username)}>
<button className="ml-1 hover:dg-text-red" onClick={() => handleRemoveMember(g.id, m.id, m.username)}>
<UserX className="w-3 h-3" />
</button>
</Badge>
@@ -167,6 +187,6 @@ export default function UserGroupsManager() {
</div>
</DialogContent>
</Dialog>
</div>
</PageShell>
);
}
@@ -88,10 +88,10 @@ export function CredDetailDialog({ open, onClose, credId }: CredDetailDialogProp
</div>
<div><span className="text-[var(--dg-text-muted)]">状态:</span>
{detailRecord?.is_active
? <Badge className="bg-green-500/20 text-green-400 text-xs ml-1"><CheckCircle2 className="h-3 w-3 mr-0.5" />启用</Badge>
: <Badge className="bg-red-500/20 text-red-400 text-xs ml-1"><XCircle className="h-3 w-3 mr-0.5" />禁用</Badge>}
? <Badge className="bg-green-500/20 dg-text-green text-xs ml-1"><CheckCircle2 className="h-3 w-3 mr-0.5" />启用</Badge>
: <Badge className="bg-red-500/20 dg-text-red text-xs ml-1"><XCircle className="h-3 w-3 mr-0.5" />禁用</Badge>}
{detailRecord?.expires_at && new Date(detailRecord.expires_at as string) < new Date() && (
<Badge className="bg-yellow-500/20 text-yellow-400 text-xs ml-1"><AlertTriangle className="h-3 w-3 mr-0.5" />已过期</Badge>
<Badge className="bg-yellow-500/20 dg-text-yellow text-xs ml-1"><AlertTriangle className="h-3 w-3 mr-0.5" />已过期</Badge>
)}
</div>
<div className="col-span-2 cursor-pointer" onClick={() => copyValue((detailRecord?.env_prefix as string) || '')}>
@@ -118,7 +118,7 @@ export function CredDetailDialog({ open, onClose, credId }: CredDetailDialogProp
<Key className="h-4 w-4 text-yellow-500" />密钥数据
</div>
<div className="p-3 rounded-md bg-yellow-500/5 border border-yellow-500/20 mb-3">
<p className="text-xs text-yellow-400/80">以下内容包含敏感信息,请妥善保管,不要泄露给他人。</p>
<p className="text-xs dg-text-yellow/80">以下内容包含敏感信息,请妥善保管,不要泄露给他人。</p>
</div>
{st === 'ssh' && <>
@@ -161,7 +161,7 @@ export function CredFormDialog({ open, onClose, onSaved, editData }: CredFormDia
const show = showMap[fieldKey];
return (
<div className="space-y-1">
<Label className="text-[var(--dg-text-secondary)] text-xs">{label}{required && !isEdit && <span className="text-red-400"> *</span>}</Label>
<Label className="text-[var(--dg-text-secondary)] text-xs">{label}{required && !isEdit && <span className="dg-text-red"> *</span>}</Label>
<div className="relative">
{isLong ? (
<textarea value={value} onChange={e => onChange(e.target.value)}
@@ -202,7 +202,7 @@ export function CredFormDialog({ open, onClose, onSaved, editData }: CredFormDia
<div className="text-xs font-medium text-[var(--dg-text-secondary)] mb-2">基本信息</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1">
<Label className="text-[var(--dg-text-secondary)] text-xs">凭据名称 <span className="text-red-400">*</span></Label>
<Label className="text-[var(--dg-text-secondary)] text-xs">凭据名称 <span className="dg-text-red">*</span></Label>
<Input value={name} onChange={e => setName(e.target.value)} placeholder="如:生产环境 SSH"
className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)]" />
</div>
@@ -212,7 +212,7 @@ export function CredFormDialog({ open, onClose, onSaved, editData }: CredFormDia
className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)] font-mono text-sm" />
</div>
<div className="space-y-1">
<Label className="text-[var(--dg-text-secondary)] text-xs">类型{!isEdit && <span className="text-red-400"> *</span>}</Label>
<Label className="text-[var(--dg-text-secondary)] text-xs">类型{!isEdit && <span className="dg-text-red"> *</span>}</Label>
<Select value={secretType} onValueChange={(v) => setSecretType(v as SecretType)} disabled={isEdit}>
<SelectTrigger className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)]">
<SelectValue />
@@ -276,7 +276,7 @@ export function CredFormDialog({ open, onClose, onSaved, editData }: CredFormDia
{secretType === 'docker' && (
<div className="grid grid-cols-2 gap-3">
<div className="col-span-2 space-y-1">
<Label className="text-[var(--dg-text-secondary)] text-xs">Registry 地址 <span className="text-red-400">*</span></Label>
<Label className="text-[var(--dg-text-secondary)] text-xs">Registry 地址 <span className="dg-text-red">*</span></Label>
<Input value={dockerServer} onChange={e => setDockerServer(e.target.value)} placeholder="registry.example.com"
className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)] font-mono" />
</div>
@@ -304,7 +304,7 @@ export function CredFormDialog({ open, onClose, onSaved, editData }: CredFormDia
{secretType === 'aksk' && (
<div className="grid grid-cols-2 gap-3">
<div className="col-span-2 space-y-1">
<Label className="text-[var(--dg-text-secondary)] text-xs">Access Key ID <span className="text-red-400">*</span></Label>
<Label className="text-[var(--dg-text-secondary)] text-xs">Access Key ID <span className="dg-text-red">*</span></Label>
<Input value={akskAccessKey} onChange={e => setAkskAccessKey(e.target.value)} placeholder="AKIDxxxx..."
className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)] font-mono" />
</div>
@@ -334,7 +334,7 @@ export function CredFormDialog({ open, onClose, onSaved, editData }: CredFormDia
</Select>
</div>
<div className="space-y-1">
<Label className="text-[var(--dg-text-secondary)] text-xs">主机 <span className="text-red-400">*</span></Label>
<Label className="text-[var(--dg-text-secondary)] text-xs">主机 <span className="dg-text-red">*</span></Label>
<Input value={dbHost} onChange={e => setDbHost(e.target.value)} placeholder="10.0.0.1"
className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)] font-mono" />
</div>
@@ -373,7 +373,7 @@ export function CredFormDialog({ open, onClose, onSaved, editData }: CredFormDia
{secretType === 'generic' && (
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1">
<Label className="text-[var(--dg-text-secondary)] text-xs">Key <span className="text-red-400">*</span></Label>
<Label className="text-[var(--dg-text-secondary)] text-xs">Key <span className="dg-text-red">*</span></Label>
<Input value={genericKey} onChange={e => setGenericKey(e.target.value)} placeholder="MY_SECRET"
className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)] font-mono" />
</div>
@@ -6,21 +6,21 @@ import {
import type { SecretType } from './types';
export const TYPE_CONFIG: Record<SecretType, { label: string; icon: typeof Key; color: string; desc: string }> = {
ssh: { label: 'SSH 密钥', icon: Server, color: 'text-amber-400', desc: 'SSH 私钥/公钥/密码' },
docker: { label: 'Docker 凭证', icon: Container, color: 'text-cyan-400', desc: 'Docker Registry 登录凭据' },
tls: { label: 'TLS 证书', icon: Shield, color: 'text-green-400', desc: 'TLS/SSL 证书与私钥' },
aksk: { label: 'AKSK 密钥', icon: Cloud, color: 'text-purple-400', desc: '云服务 AccessKey/SecretKey' },
database: { label: '数据库凭证', icon: Database, color: 'text-blue-400', desc: '数据库连接信息' },
api: { label: 'API 密钥', icon: Globe, color: 'text-orange-400', desc: 'API Token/Key + 端点' },
generic: { label: '通用凭证', icon: FileText, color: 'text-gray-400', desc: '自定义 Key/Value 键值对' },
ssh: { label: 'SSH 密钥', icon: Server, color: 'dg-text-yellow', desc: 'SSH 私钥/公钥/密码' },
docker: { label: 'Docker 凭证', icon: Container, color: 'dg-text-cyan', desc: 'Docker Registry 登录凭据' },
tls: { label: 'TLS 证书', icon: Shield, color: 'dg-text-green', desc: 'TLS/SSL 证书与私钥' },
aksk: { label: 'AKSK 密钥', icon: Cloud, color: 'dg-text-purple', desc: '云服务 AccessKey/SecretKey' },
database: { label: '数据库凭证', icon: Database, color: 'dg-text-blue', desc: '数据库连接信息' },
api: { label: 'API 密钥', icon: Globe, color: 'dg-text-orange', desc: 'API Token/Key + 端点' },
generic: { label: '通用凭证', icon: FileText, color: 'dg-text-secondary', desc: '自定义 Key/Value 键值对' },
};
export const TYPE_BADGE_CLASS: Record<SecretType, string> = {
ssh: 'bg-amber-500/20 text-amber-400 border-amber-500/30',
docker: 'bg-cyan-500/20 text-cyan-400 border-cyan-500/30',
tls: 'bg-green-500/20 text-green-400 border-green-500/30',
aksk: 'bg-purple-500/20 text-purple-400 border-purple-500/30',
database: 'bg-blue-500/20 text-blue-400 border-blue-500/30',
api: 'bg-orange-500/20 text-orange-400 border-orange-500/30',
generic: 'bg-gray-500/20 text-gray-400 border-gray-500/30',
ssh: 'bg-amber-500/20 dg-text-yellow border-amber-500/30',
docker: 'bg-cyan-500/20 dg-text-cyan border-cyan-500/30',
tls: 'bg-green-500/20 dg-text-green border-green-500/30',
aksk: 'bg-purple-500/20 dg-text-purple border-purple-500/30',
database: 'bg-blue-500/20 dg-text-blue border-blue-500/30',
api: 'bg-orange-500/20 dg-text-orange border-orange-500/30',
generic: 'dg-badge-muted',
};
@@ -47,21 +47,21 @@ export function getStatusBadge( status: string) {
switch (status) {
case 'success':
return (
<Badge variant="outline" className="border-emerald-500/30 text-emerald-400 bg-emerald-500/10">
<Badge variant="outline" className="border-emerald-500/30 dg-text-green bg-emerald-500/10">
<CheckCircle className="h-3 w-3 mr-1" />
成功
</Badge>
);
case 'failed':
return (
<Badge variant="outline" className="border-red-500/30 text-red-400 bg-red-500/10">
<Badge variant="outline" className="border-red-500/30 dg-text-red bg-red-500/10">
<XCircle className="h-3 w-3 mr-1" />
失败
</Badge>
);
case 'running':
return (
<Badge variant="outline" className="border-blue-500/30 text-blue-400 bg-blue-500/10 animate-pulse">
<Badge variant="outline" className="border-blue-500/30 dg-text-blue bg-blue-500/10 animate-pulse">
<Loader2 className="h-3 w-3 mr-1 animate-spin" />
运行中
</Badge>
@@ -79,11 +79,11 @@ export function getStatusBadge( status: string) {
export function getStageStatusIcon(status: PipelineStage['status']) {
switch (status) {
case 'success':
return <CheckCircle className="h-4 w-4 text-emerald-400" />;
return <CheckCircle className="h-4 w-4 dg-text-green" />;
case 'failed':
return <XCircle className="h-4 w-4 text-red-400" />;
return <XCircle className="h-4 w-4 dg-text-red" />;
case 'running':
return <Loader2 className="h-4 w-4 text-blue-400 animate-spin" />;
return <Loader2 className="h-4 w-4 dg-text-blue animate-spin" />;
case 'skipped':
return <span className="text-[var(--dg-text-secondary)] text-xs">跳过</span>;
default:
@@ -98,7 +98,7 @@ export function PipelineCard({
<span className="text-[var(--dg-text-secondary)] opacity-50 text-[10px] mx-0.5">+</span>
)}
{pipeline.last_commit_id && (
<code className="text-amber-400 bg-amber-400/10 px-1 py-0.5 rounded text-[11px]">
<code className="dg-text-yellow bg-amber-400/10 px-1 py-0.5 rounded text-[11px]">
{String(pipeline.last_commit_id).slice(0, 8)}
</code>
)}
@@ -106,7 +106,7 @@ export function PipelineCard({
<ArrowRight className="h-3 w-3 text-[var(--dg-text-secondary)] opacity-40 mx-0.5" />
)}
{pipeline.last_image_tag && (
<span className="flex items-center gap-0.5 text-cyan-400">
<span className="flex items-center gap-0.5 dg-text-cyan">
<Package className="h-3 w-3" />
<code className="bg-cyan-400/10 px-1 py-0.5 rounded text-[11px]">{String(pipeline.last_image_tag)}</code>
</span>
@@ -158,7 +158,7 @@ export function PipelineCreateDialog({ open, onOpenChange, onCreated }: Pipeline
<div className="space-y-4">
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1">
<Label>名称 <span className="text-red-400">*</span></Label>
<Label>名称 <span className="dg-text-red">*</span></Label>
<Input value={newName} onChange={(e) => setNewName(e.target.value)} placeholder="例如: demo-jar 后端部署" className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)] h-9" />
</div>
<div className="space-y-1">
@@ -238,7 +238,7 @@ export function PipelineCreateDialog({ open, onOpenChange, onCreated }: Pipeline
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1">
<Label>Git URL {selectedStages.includes('checkout') && <span className="text-red-400">*</span>}</Label>
<Label>Git URL {selectedStages.includes('checkout') && <span className="dg-text-red">*</span>}</Label>
<Input value={newSource} onChange={(e) => setNewSource(e.target.value)} placeholder="https://gitee.com/user/repo.git" className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)] h-9 font-mono text-xs" />
</div>
<div className="space-y-1">
@@ -260,10 +260,10 @@ export function PipelineCreateDialog({ open, onOpenChange, onCreated }: Pipeline
))}
</SelectContent>
</Select>
{!newGitCredentialId && selectedStages.includes('checkout') && <p className="text-xs text-amber-400">建议关联凭证</p>}
{!newGitCredentialId && selectedStages.includes('checkout') && <p className="text-xs dg-text-yellow">建议关联凭证</p>}
</div>
<div className="space-y-1">
<Label>镜像仓库凭证 {selectedStages.includes('image') && <span className="text-red-400">*</span>}</Label>
<Label>镜像仓库凭证 {selectedStages.includes('image') && <span className="dg-text-red">*</span>}</Label>
<Select value={String(newRegistryCredentialId)} onValueChange={(v) => setNewRegistryCredentialId(v === '__none' ? '' : v)}>
<SelectTrigger className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)] h-9"><SelectValue placeholder="选择 Docker 凭证" /></SelectTrigger>
<SelectContent>
@@ -277,13 +277,13 @@ export function PipelineCreateDialog({ open, onOpenChange, onCreated }: Pipeline
))}
</SelectContent>
</Select>
{!newRegistryCredentialId && selectedStages.includes('image') && <p className="text-xs text-amber-400">需要关联 Docker 凭证</p>}
{!newRegistryCredentialId && selectedStages.includes('image') && <p className="text-xs dg-text-yellow">需要关联 Docker 凭证</p>}
</div>
</div>
{newRegistryCredentialId && newJobName && (
<div className="p-2.5 rounded-md bg-blue-500/5 border border-blue-500/20 text-xs text-[var(--dg-text-secondary)]">
<span className="text-blue-400">镜像命名:</span>
<span className="dg-text-blue">镜像命名:</span>
<code className="text-[var(--dg-text-primary)]">{'{registry}'}/{'{namespace}'}/{'{'}{'{JOB_BASE_NAME}'}{'}'}:{'{'}{'{tag}'}{'}'}</code>
<span className="ml-2 opacity-60">例:</span>
<code className="opacity-60">harbor.ast1001.com/test/{newJobName}:latest</code>
@@ -456,7 +456,7 @@ export function PipelineCreateDialog({ open, onOpenChange, onCreated }: Pipeline
placeholder="说明"
className="col-span-4 h-8 text-xs bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)]"
/>
<Button variant="ghost" size="icon" className="col-span-1 h-8 w-8 text-red-400" onClick={() => setNewEnvVars(newEnvVars.filter((_, idx) => idx !== i))}>
<Button variant="ghost" size="icon" className="col-span-1 h-8 w-8 dg-text-red" onClick={() => setNewEnvVars(newEnvVars.filter((_, idx) => idx !== i))}>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
@@ -72,7 +72,7 @@ function PipelineQueuePanel({ pipeline }: { pipeline: Record<string, unknown> })
<span className="font-bold text-[var(--dg-text-primary)]">{running}/{max}</span>
{queued > 0 && (
<span className="text-[var(--dg-text-secondary)] ml-2">
构建队列 <span className="font-bold text-amber-400">({queued})</span>
构建队列 <span className="font-bold dg-text-yellow">({queued})</span>
</span>
)}
</div>
@@ -82,12 +82,12 @@ function PipelineQueuePanel({ pipeline }: { pipeline: Record<string, unknown> })
{pendingRuns.map((run) => (
<div key={run.id as number} className="flex items-center justify-between bg-[var(--dg-bg-tertiary)] rounded px-3 py-1.5">
<div className="flex items-center gap-2 text-xs">
<Clock className="h-3 w-3 text-amber-400" />
<Clock className="h-3 w-3 dg-text-yellow" />
<span className="text-[var(--dg-text-secondary)] font-mono">#{String(run.build_number ?? run.id ?? '')}</span>
<span className="text-[var(--dg-text-secondary)]">{String(run.branch || '-')}</span>
<span className="text-[var(--dg-text-secondary)] opacity-50">{String(run.triggered_by || '-')}</span>
</div>
<Button variant="ghost" size="sm" className="h-6 text-xs text-red-400 hover:text-red-300" onClick={() => handleCancel(run.id as number)}>
<Button variant="ghost" size="sm" className="h-6 text-xs dg-text-red hover:dg-text-red" onClick={() => handleCancel(run.id as number)}>
<XCircle className="h-3 w-3 mr-1" />取消
</Button>
</div>
@@ -100,10 +100,10 @@ function PipelineQueuePanel({ pipeline }: { pipeline: Record<string, unknown> })
{queueIds.map((id) => (
<div key={id} className="flex items-center justify-between bg-[var(--dg-bg-tertiary)] rounded px-3 py-1.5">
<div className="flex items-center gap-2 text-xs">
<Clock className="h-3 w-3 text-amber-400" />
<Clock className="h-3 w-3 dg-text-yellow" />
<span className="text-[var(--dg-text-muted)] font-mono">Run #{id}</span>
</div>
<Button variant="ghost" size="sm" className="h-6 text-xs text-red-400 hover:text-red-300" onClick={() => handleCancel(id)}>
<Button variant="ghost" size="sm" className="h-6 text-xs dg-text-red hover:dg-text-red" onClick={() => handleCancel(id)}>
<XCircle className="h-3 w-3 mr-1" />取消
</Button>
</div>
@@ -160,19 +160,19 @@ export function PipelineRunPanel({ pipeline, pipelineRuns, isLoadingRuns, onStop
<td className="px-4 py-2 font-medium text-[var(--dg-text-primary)]">#{String(r.build_number ?? r.id ?? '')}</td>
<td className="px-4 py-2">
{r.status === 'success' ? (
<Badge className="bg-emerald-500/20 text-emerald-400 border-emerald-500/30 text-[11px]">
<Badge className="bg-emerald-500/20 dg-text-green border-emerald-500/30 text-[11px]">
<CheckCircle className="h-3 w-3 mr-0.5" />成功
</Badge>
) : r.status === 'failed' ? (
<Badge className="bg-red-500/20 text-red-400 border-red-500/30 text-[11px]">
<Badge className="bg-red-500/20 dg-text-red border-red-500/30 text-[11px]">
<XCircle className="h-3 w-3 mr-0.5" />失败
</Badge>
) : r.status === 'running' ? (
<Badge className="bg-blue-500/20 text-blue-400 border-blue-500/30 text-[11px]">
<Badge className="bg-blue-500/20 dg-text-blue border-blue-500/30 text-[11px]">
<Loader2 className="h-3 w-3 animate-spin mr-0.5" />执行中
</Badge>
) : r.status === 'cancelled' ? (
<Badge className="bg-yellow-500/20 text-yellow-400 border-yellow-500/30 text-[11px]">
<Badge className="bg-yellow-500/20 dg-text-yellow border-yellow-500/30 text-[11px]">
<StopCircle className="h-3 w-3 mr-0.5" />已停止
</Badge>
) : (
@@ -186,11 +186,11 @@ export function PipelineRunPanel({ pipeline, pipelineRuns, isLoadingRuns, onStop
</td>
<td className="px-4 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)}>
<Button variant="ghost" size="sm" className="h-7 text-xs dg-text-blue hover:text-blue-300" onClick={() => handleViewLog(r)}>
<FileText className="h-3.5 w-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={() => onStopRun(r.id as number | string)}>
<Button variant="ghost" size="sm" className="h-7 text-xs dg-text-red hover:dg-text-red" onClick={() => onStopRun(r.id as number | string)}>
<StopCircle className="h-3.5 w-3.5 mr-1" />停止
</Button>
)}
@@ -215,14 +215,14 @@ export function PipelineRunPanel({ pipeline, pipelineRuns, isLoadingRuns, onStop
<span>分支: {String(logRun?.branch || '-')}</span>
<span>耗时: {fmtDuration(logRun?.duration as number | undefined)}</span>
</div>
<div className="bg-slate-950 rounded-md p-3 max-h-[50vh] overflow-y-auto font-mono text-xs">
<div className="bg-[var(--dg-bg-primary)] 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>
<div className="dg-text-muted 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'}>
<span className="dg-text-muted shrink-0">[{l.time}]</span>
<span className={l.level === 'error' ? 'dg-text-red' : l.level === 'warn' ? 'dg-text-yellow' : l.level === 'success' ? 'dg-text-green' : 'text-[var(--dg-text-secondary)]'}>
{l.level === 'success' ? '✓' : l.level === 'error' ? '✗' : l.level === 'warn' ? '!' : '>'} {l.message}
</span>
</div>
@@ -14,42 +14,42 @@ export function getStatusBadge( status: string) {
switch (status) {
case 'success':
return (
<Badge variant="outline" className="border-emerald-500/30 text-emerald-400 bg-emerald-500/10">
<Badge variant="outline" className="border-emerald-500/30 dg-text-green bg-emerald-500/10">
<CheckCircle className="h-3 w-3 mr-1" />
成功
</Badge>
);
case 'failed':
return (
<Badge variant="outline" className="border-red-500/30 text-red-400 bg-red-500/10">
<Badge variant="outline" className="border-red-500/30 dg-text-red bg-red-500/10">
<XCircle className="h-3 w-3 mr-1" />
失败
</Badge>
);
case 'deploying':
return (
<Badge variant="outline" className="border-blue-500/30 text-blue-400 bg-blue-500/10 animate-pulse">
<Badge variant="outline" className="border-blue-500/30 dg-text-blue bg-blue-500/10 animate-pulse">
<Loader2 className="h-3 w-3 mr-1 animate-spin" />
发布中
</Badge>
);
case 'building':
return (
<Badge variant="outline" className="border-amber-500/30 text-amber-400 bg-amber-500/10">
<Badge variant="outline" className="border-amber-500/30 dg-text-yellow bg-amber-500/10">
<Loader2 className="h-3 w-3 mr-1 animate-spin" />
构建中
</Badge>
);
case 'rolled_back':
return (
<Badge variant="outline" className="border-slate-500/30 text-slate-400 bg-slate-500/10">
<Badge variant="outline" className="border-[var(--dg-border)] dg-text-secondary bg-[var(--dg-bg-active)]/10">
<RotateCcw className="h-3 w-3 mr-1" />
已回滚
</Badge>
);
case 'pending':
return (
<Badge variant="outline" className="border-slate-500/30 text-slate-400 bg-slate-500/10">
<Badge variant="outline" className="border-[var(--dg-border)] dg-text-secondary bg-[var(--dg-bg-active)]/10">
<Clock className="h-3 w-3 mr-1" />
待发布
</Badge>
@@ -62,10 +62,10 @@ export function getStatusBadge( status: string) {
// 环境徽章 // Logan.Li
export function getEnvBadge( env: string) {
const colors: Record<string, string> = {
dev: 'border-slate-500/30 text-slate-400 bg-slate-500/10',
test: 'border-blue-500/30 text-blue-400 bg-blue-500/10',
staging: 'border-amber-500/30 text-amber-400 bg-amber-500/10',
prod: 'border-emerald-500/30 text-emerald-400 bg-emerald-500/10',
dev: 'border-[var(--dg-border)] dg-text-secondary bg-[var(--dg-bg-active)]/10',
test: 'border-blue-500/30 dg-text-blue bg-blue-500/10',
staging: 'border-amber-500/30 dg-text-yellow bg-amber-500/10',
prod: 'border-emerald-500/30 dg-text-green bg-emerald-500/10',
};
const labels: Record<string, string> = {
dev: '开发环境',
@@ -13,22 +13,22 @@ import {
// 环境配置(值与后端 oneof=dev test staging prod 一致) // Logan.Li
export const ENVIRONMENTS: Record<string, { label: string; color: string }> = {
dev: { label: '开发环境', color: 'border-blue-500/30 text-blue-400 bg-blue-500/10' },
test: { label: '测试环境', color: 'border-cyan-500/30 text-cyan-400 bg-cyan-500/10' },
staging: { label: '预发环境', color: 'border-amber-500/30 text-amber-400 bg-amber-500/10' },
prod: { label: '生产环境', color: 'border-red-500/30 text-red-400 bg-red-500/10' },
dev: { label: '开发环境', color: 'border-blue-500/30 dg-text-blue bg-blue-500/10' },
test: { label: '测试环境', color: 'border-cyan-500/30 dg-text-cyan bg-cyan-500/10' },
staging: { label: '预发环境', color: 'border-amber-500/30 dg-text-yellow bg-amber-500/10' },
prod: { label: '生产环境', color: 'border-red-500/30 dg-text-red bg-red-500/10' },
};
// 发布状态徽章(审批流:待审批/已审批/已驳回/发布中/发布成功/发布失败/已回滚) // Logan.Li
export function getStatusBadge( status: string) {
const map: Record<string, { icon: any; label: string; cls: string }> = {
pending: { icon: Clock, label: '待审批', cls: 'border-amber-500/30 text-amber-400 bg-amber-500/10' },
approved: { icon: Check, label: '已审批', cls: 'border-purple-500/30 text-purple-400 bg-purple-500/10' },
rejected: { icon: XCircle, label: '已驳回', cls: 'border-slate-500/30 text-slate-400 bg-slate-500/10' },
deploying: { icon: Loader2, label: '发布中', cls: 'border-blue-500/30 text-blue-400 bg-blue-500/10 animate-pulse' },
success: { icon: CheckCircle, label: '发布成功', cls: 'border-emerald-500/30 text-emerald-400 bg-emerald-500/10' },
failed: { icon: XCircle, label: '发布失败', cls: 'border-red-500/30 text-red-400 bg-red-500/10' },
rolled_back: { icon: RotateCcw, label: '已回滚', cls: 'border-slate-500/30 text-slate-400 bg-slate-500/10' },
pending: { icon: Clock, label: '待审批', cls: 'border-amber-500/30 dg-text-yellow bg-amber-500/10' },
approved: { icon: Check, label: '已审批', cls: 'border-purple-500/30 dg-text-purple bg-purple-500/10' },
rejected: { icon: XCircle, label: '已驳回', cls: 'border-[var(--dg-border)] dg-text-secondary bg-[var(--dg-bg-active)]/10' },
deploying: { icon: Loader2, label: '发布中', cls: 'border-blue-500/30 dg-text-blue bg-blue-500/10 animate-pulse' },
success: { icon: CheckCircle, label: '发布成功', cls: 'border-emerald-500/30 dg-text-green bg-emerald-500/10' },
failed: { icon: XCircle, label: '发布失败', cls: 'border-red-500/30 dg-text-red bg-red-500/10' },
rolled_back: { icon: RotateCcw, label: '已回滚', cls: 'border-[var(--dg-border)] dg-text-secondary bg-[var(--dg-bg-active)]/10' },
};
const cfg = map[status];
if (!cfg) return null;
@@ -45,7 +45,7 @@ export function getStatusBadge( status: string) {
export function getEnvironmentBadge( env: string) {
const cfg = ENVIRONMENTS[env];
return (
<Badge variant="outline" className={cfg?.color || 'border-slate-500/30 text-slate-400'}>
<Badge variant="outline" className={cfg?.color || 'border-[var(--dg-border)] dg-text-secondary'}>
{cfg?.label || env}
</Badge>
);