feat: 全站 PageShell 设计系统与专业 UI 统一
- 新增 PageShell/PageTabs/ContentPanel/PageLoading/PageEmpty 页面骨架 - index.css 设计令牌文档化,布局令牌与纯色主按钮(去除渐变粉彩) - 全站路由页接入 PageShell,云账单三 Tab 为标杆 - 主题体系:跟随系统 + 主流/开发者分组,新增 Dracula/Tokyo Night,One Dark 暖灰差异化 - 登录页/侧栏/顶栏去渐变光球,企业克制风 - 修复 AIAlertAnalyzer JSX 与构建依赖导入问题
This commit is contained in:
1 parent
1acb0c49af
commit
e363ded445
213 files changed
+7148
-6818
No files matched your search
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
Reference in new issue
Block a user