增加审批组件

This commit is contained in:
dengqichen 2025-10-24 21:20:13 +08:00
parent 2aada4632e
commit e61b75f9e1
3 changed files with 1046 additions and 477 deletions

View File

@ -3,10 +3,14 @@ import * as ProgressPrimitive from "@radix-ui/react-progress"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
export interface ProgressProps extends React.ComponentPropsWithoutRef<typeof ProgressPrimitive.Root> {
indicatorClassName?: string;
}
const Progress = React.forwardRef< const Progress = React.forwardRef<
React.ElementRef<typeof ProgressPrimitive.Root>, React.ElementRef<typeof ProgressPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof ProgressPrimitive.Root> ProgressProps
>(({ className, value, ...props }, ref) => ( >(({ className, value, indicatorClassName, ...props }, ref) => (
<ProgressPrimitive.Root <ProgressPrimitive.Root
ref={ref} ref={ref}
className={cn( className={cn(
@ -16,7 +20,10 @@ const Progress = React.forwardRef<
{...props} {...props}
> >
<ProgressPrimitive.Indicator <ProgressPrimitive.Indicator
className="h-full w-full flex-1 bg-primary transition-all" className={cn(
"h-full w-full flex-1 bg-primary transition-all",
indicatorClassName
)}
style={{ transform: `translateX(-${100 - (value || 0)}%)` }} style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
/> />
</ProgressPrimitive.Root> </ProgressPrimitive.Root>

View File

@ -1,13 +1,12 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect, useMemo } from 'react';
import { PageContainer } from '@ant-design/pro-components';
import { import {
Card, Card,
CardContent, CardContent,
CardDescription,
CardHeader, CardHeader,
CardTitle CardTitle
} from "@/components/ui/card"; } from "@/components/ui/card";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { import {
Table, Table,
TableBody, TableBody,
@ -25,16 +24,21 @@ import {
SelectValue, SelectValue,
} from "@/components/ui/select"; } from "@/components/ui/select";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { ScrollArea } from "@/components/ui/scroll-area"; import { useToast } from "@/components/ui/use-toast";
import { import {
GitBranch, GitBranch,
GitFork, GitFork,
RefreshCw, RefreshCw,
Link2, FolderGit2,
FolderGit2 Globe,
Lock,
Users,
Copy,
ExternalLink,
Search,
Loader2
} from "lucide-react"; } from "lucide-react";
import { message } from 'antd'; import type { GitInstance, GitInstanceInfo } from './types';
import type { GitInstance, GitInstanceInfo, RepositoryGroup, RepositoryProject } from './types';
import { import {
getGitInstances, getGitInstances,
getGitInstanceInfo, getGitInstanceInfo,
@ -43,13 +47,43 @@ import {
syncGitProjects, syncGitProjects,
syncGitBranches syncGitBranches
} from './service'; } from './service';
import dayjs from 'dayjs';
import relativeTime from 'dayjs/plugin/relativeTime';
import 'dayjs/locale/zh-cn';
dayjs.extend(relativeTime);
dayjs.locale('zh-cn');
// 常量定义
const VISIBILITY_CONFIG = {
public: { label: '公开', variant: 'default' as const, icon: Globe, color: 'text-green-600' },
private: { label: '私有', variant: 'destructive' as const, icon: Lock, color: 'text-red-600' },
internal: { label: '内部', variant: 'secondary' as const, icon: Users, color: 'text-yellow-600' },
};
const GitManager: React.FC = () => { const GitManager: React.FC = () => {
const { toast } = useToast();
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [instances, setInstances] = useState<GitInstance[]>([]); const [instances, setInstances] = useState<GitInstance[]>([]);
const [selectedInstance, setSelectedInstance] = useState<number>(); const [selectedInstance, setSelectedInstance] = useState<number>();
const [instanceInfo, setInstanceInfo] = useState<GitInstanceInfo>(); const [instanceInfo, setInstanceInfo] = useState<GitInstanceInfo>();
// 同步状态
const [syncing, setSyncing] = useState({
all: false,
groups: false,
projects: false,
branches: false,
});
// 搜索和过滤状态
const [groupSearch, setGroupSearch] = useState('');
const [groupVisibility, setGroupVisibility] = useState<string>('all');
const [projectSearch, setProjectSearch] = useState('');
const [projectVisibility, setProjectVisibility] = useState<string>('all');
const [projectGroup, setProjectGroup] = useState<string>('all');
const [activeTab, setActiveTab] = useState<string>('groups');
// 加载Git实例列表 // 加载Git实例列表
const loadInstances = async () => { const loadInstances = async () => {
try { try {
@ -61,7 +95,11 @@ const GitManager: React.FC = () => {
} }
} }
} catch (error) { } catch (error) {
message.error('加载Git实例失败'); toast({
variant: "destructive",
title: "加载失败",
description: "加载 Git 实例列表失败",
});
} }
}; };
@ -73,7 +111,11 @@ const GitManager: React.FC = () => {
const data = await getGitInstanceInfo(selectedInstance); const data = await getGitInstanceInfo(selectedInstance);
setInstanceInfo(data); setInstanceInfo(data);
} catch (error) { } catch (error) {
message.error('加载实例信息失败'); toast({
variant: "destructive",
title: "加载失败",
description: "加载实例信息失败",
});
} finally { } finally {
setLoading(false); setLoading(false);
} }
@ -82,51 +124,135 @@ const GitManager: React.FC = () => {
// 同步所有数据 // 同步所有数据
const handleSyncAll = async () => { const handleSyncAll = async () => {
if (!selectedInstance) return; if (!selectedInstance) return;
setSyncing(prev => ({ ...prev, all: true }));
try { try {
await syncAllGitData(selectedInstance); await syncAllGitData(selectedInstance);
message.success('同步任务已启动'); toast({
title: "同步成功",
description: "同步任务已启动",
});
loadInstanceInfo(); loadInstanceInfo();
} catch (error) { } catch (error) {
message.error('同步失败'); toast({
variant: "destructive",
title: "同步失败",
description: "同步任务启动失败",
});
} finally {
setSyncing(prev => ({ ...prev, all: false }));
} }
}; };
// 同步仓库组 // 同步仓库组
const handleSyncGroups = async () => { const handleSyncGroups = async () => {
if (!selectedInstance) return; if (!selectedInstance) return;
setSyncing(prev => ({ ...prev, groups: true }));
try { try {
await syncGitGroups(selectedInstance); await syncGitGroups(selectedInstance);
message.success('仓库组同步任务已启动'); toast({
title: "同步成功",
description: "仓库组同步任务已启动",
});
loadInstanceInfo(); loadInstanceInfo();
} catch (error) { } catch (error) {
message.error('同步失败'); toast({
variant: "destructive",
title: "同步失败",
description: "仓库组同步任务启动失败",
});
} finally {
setSyncing(prev => ({ ...prev, groups: false }));
} }
}; };
// 同步项目 // 同步项目
const handleSyncProjects = async () => { const handleSyncProjects = async () => {
if (!selectedInstance) return; if (!selectedInstance) return;
setSyncing(prev => ({ ...prev, projects: true }));
try { try {
await syncGitProjects(selectedInstance); await syncGitProjects(selectedInstance);
message.success('项目同步任务已启动'); toast({
title: "同步成功",
description: "项目同步任务已启动",
});
loadInstanceInfo(); loadInstanceInfo();
} catch (error) { } catch (error) {
message.error('同步失败'); toast({
variant: "destructive",
title: "同步失败",
description: "项目同步任务启动失败",
});
} finally {
setSyncing(prev => ({ ...prev, projects: false }));
} }
}; };
// 同步分支 // 同步分支
const handleSyncBranches = async () => { const handleSyncBranches = async () => {
if (!selectedInstance) return; if (!selectedInstance) return;
setSyncing(prev => ({ ...prev, branches: true }));
try { try {
await syncGitBranches(selectedInstance); await syncGitBranches(selectedInstance);
message.success('分支同步任务已启动'); toast({
title: "同步成功",
description: "分支同步任务已启动",
});
loadInstanceInfo(); loadInstanceInfo();
} catch (error) { } catch (error) {
message.error('同步失败'); toast({
variant: "destructive",
title: "同步失败",
description: "分支同步任务启动失败",
});
} finally {
setSyncing(prev => ({ ...prev, branches: false }));
} }
}; };
// 复制路径
const handleCopyPath = (path: string) => {
navigator.clipboard.writeText(path);
toast({
title: "复制成功",
description: `已复制路径: ${path}`,
});
};
// 格式化时间
const formatTime = (time?: string) => {
if (!time) return 'Never';
const diff = dayjs().diff(dayjs(time), 'day');
if (diff > 7) {
return dayjs(time).format('YYYY-MM-DD HH:mm');
}
return dayjs(time).fromNow();
};
// 过滤仓库组
const filteredGroups = useMemo(() => {
if (!instanceInfo?.repositoryGroupList) return [];
return instanceInfo.repositoryGroupList.filter(group => {
const matchSearch = !groupSearch ||
group.name.toLowerCase().includes(groupSearch.toLowerCase()) ||
group.path.toLowerCase().includes(groupSearch.toLowerCase());
const matchVisibility = groupVisibility === 'all' || group.visibility === groupVisibility;
return matchSearch && matchVisibility;
});
}, [instanceInfo?.repositoryGroupList, groupSearch, groupVisibility]);
// 过滤项目
const filteredProjects = useMemo(() => {
if (!instanceInfo?.repositoryProjectList) return [];
return instanceInfo.repositoryProjectList.filter(project => {
const matchSearch = !projectSearch ||
project.name.toLowerCase().includes(projectSearch.toLowerCase()) ||
project.path.toLowerCase().includes(projectSearch.toLowerCase());
const matchVisibility = projectVisibility === 'all' || project.visibility === projectVisibility;
const matchGroup = projectGroup === 'all' || (project.groupId || project.id).toString() === projectGroup;
return matchSearch && matchVisibility && matchGroup;
});
}, [instanceInfo?.repositoryProjectList, projectSearch, projectVisibility, projectGroup]);
useEffect(() => { useEffect(() => {
loadInstances(); loadInstances();
}, []); }, []);
@ -137,22 +263,41 @@ const GitManager: React.FC = () => {
} }
}, [selectedInstance]); }, [selectedInstance]);
// 渲染可见性 Badge
const renderVisibilityBadge = (visibility: string) => {
const config = VISIBILITY_CONFIG[visibility.toLowerCase() as keyof typeof VISIBILITY_CONFIG] || VISIBILITY_CONFIG.public;
const Icon = config.icon;
return (
<Badge variant={config.variant} className="inline-flex items-center gap-1">
<Icon className="h-3 w-3" />
{config.label}
</Badge>
);
};
// 点击仓库组,跳转到项目列表
const handleGroupClick = (groupId: number) => {
// 清空项目搜索和可见性过滤
setProjectSearch('');
setProjectVisibility('all');
// 设置仓库组过滤
setProjectGroup((groupId || '').toString());
// 切换到项目 Tab
setActiveTab('projects');
};
return ( return (
<PageContainer <div className="p-6 space-y-6">
header={{ {/* 页面标题栏 */}
title: 'Git 仓库管理', <div className="flex items-center justify-between">
extra: [ <h1 className="text-3xl font-bold">Git </h1>
<Button key="sync" onClick={handleSyncAll}> <div className="flex items-center gap-3">
<RefreshCw className="mr-2 h-4 w-4" />
</Button>,
<Select <Select
key="select"
value={selectedInstance?.toString()} value={selectedInstance?.toString()}
onValueChange={(value) => setSelectedInstance(Number(value))} onValueChange={(value) => setSelectedInstance(Number(value))}
> >
<SelectTrigger className="w-[200px]"> <SelectTrigger className="w-[200px]">
<SelectValue placeholder="选择Git实例" /> <SelectValue placeholder="选择 Git 实例" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{instances.map(instance => ( {instances.map(instance => (
@ -162,169 +307,368 @@ const GitManager: React.FC = () => {
))} ))}
</SelectContent> </SelectContent>
</Select> </Select>
], <Button onClick={handleSyncAll} disabled={syncing.all || selectedInstance === undefined}>
}} {syncing.all ? (
> <Loader2 className="mr-2 h-4 w-4 animate-spin" />
{selectedInstance && instances.find(i => i.id === selectedInstance) && ( ) : (
<Card className="mb-6"> <RefreshCw className="mr-2 h-4 w-4" />
<CardHeader className="pb-2"> )}
<CardTitle>{instances.find(i => i.id === selectedInstance)?.name}</CardTitle>
<CardDescription className="text-sm text-muted-foreground"> </Button>
{instances.find(i => i.id === selectedInstance)?.url} </div>
</CardDescription> </div>
</CardHeader>
</Card>
)}
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 mb-6"> {/* 统计卡片 */}
<Card className="col-span-1"> <div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2"> {/* 仓库组卡片 */}
<CardTitle className="text-xl font-bold"> <Card className="relative overflow-hidden min-h-[140px]">
<div className="flex items-center"> <div className="absolute right-0 top-0 h-full w-32 bg-gradient-to-br from-blue-500/20 to-blue-600/20 -z-10" />
<FolderGit2 className="mr-2 h-5 w-5" /> <CardHeader className="flex flex-row items-center justify-between pb-2">
{instanceInfo?.totalGroups || 0} <CardTitle className="text-sm font-medium text-muted-foreground">
</div>
<div className="text-sm font-normal text-muted-foreground mt-1"></div>
</CardTitle> </CardTitle>
<Button variant="ghost" size="sm" onClick={handleSyncGroups}> <div className="flex items-center gap-2 relative z-10">
<RefreshCw className="h-4 w-4" /> <FolderGit2 className="h-4 w-4 text-muted-foreground" />
</Button> <Button
variant="ghost"
size="icon"
className="h-6 w-6"
onClick={handleSyncGroups}
disabled={syncing.groups || selectedInstance === undefined}
>
<RefreshCw className={`h-3 w-3 ${syncing.groups ? 'animate-spin' : ''}`} />
</Button>
</div>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<p className="text-sm text-muted-foreground"> <div className="text-2xl font-bold">{instanceInfo?.totalGroups || 0}</div>
Last sync: {instanceInfo?.lastSyncGroupsTime ? new Date(instanceInfo.lastSyncGroupsTime).toLocaleString() : 'Never'} <p className="text-xs text-muted-foreground mt-1">
: {formatTime(instanceInfo?.lastSyncGroupsTime)}
</p> </p>
</CardContent> </CardContent>
</Card> </Card>
<Card className="col-span-1"> {/* 项目卡片 */}
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2"> <Card className="relative overflow-hidden min-h-[140px]">
<CardTitle className="text-xl font-bold"> <div className="absolute right-0 top-0 h-full w-32 bg-gradient-to-br from-purple-500/20 to-purple-600/20 -z-10" />
<div className="flex items-center"> <CardHeader className="flex flex-row items-center justify-between pb-2">
<GitFork className="mr-2 h-5 w-5" /> <CardTitle className="text-sm font-medium text-muted-foreground">
{instanceInfo?.totalProjects || 0}
</div>
<div className="text-sm font-normal text-muted-foreground mt-1"></div>
</CardTitle> </CardTitle>
<Button variant="ghost" size="sm" onClick={handleSyncProjects}> <div className="flex items-center gap-2 relative z-10">
<RefreshCw className="h-4 w-4" /> <GitFork className="h-4 w-4 text-muted-foreground" />
</Button> <Button
variant="ghost"
size="icon"
className="h-6 w-6"
onClick={handleSyncProjects}
disabled={syncing.projects || selectedInstance === undefined}
>
<RefreshCw className={`h-3 w-3 ${syncing.projects ? 'animate-spin' : ''}`} />
</Button>
</div>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<p className="text-sm text-muted-foreground"> <div className="text-2xl font-bold">{instanceInfo?.totalProjects || 0}</div>
Last sync: {instanceInfo?.lastSyncProjectsTime ? new Date(instanceInfo.lastSyncProjectsTime).toLocaleString() : 'Never'} <p className="text-xs text-muted-foreground mt-1">
: {formatTime(instanceInfo?.lastSyncProjectsTime)}
</p> </p>
</CardContent> </CardContent>
</Card> </Card>
<Card className="col-span-1"> {/* 分支卡片 */}
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2"> <Card className="relative overflow-hidden min-h-[140px]">
<CardTitle className="text-xl font-bold"> <div className="absolute right-0 top-0 h-full w-32 bg-gradient-to-br from-green-500/20 to-green-600/20 -z-10" />
<div className="flex items-center"> <CardHeader className="flex flex-row items-center justify-between pb-2">
<GitBranch className="mr-2 h-5 w-5" /> <CardTitle className="text-sm font-medium text-muted-foreground">
{instanceInfo?.totalBranches || 0}
</div>
<div className="text-sm font-normal text-muted-foreground mt-1"></div>
</CardTitle> </CardTitle>
<Button variant="ghost" size="sm" onClick={handleSyncBranches}> <div className="flex items-center gap-2 relative z-10">
<RefreshCw className="h-4 w-4" /> <GitBranch className="h-4 w-4 text-muted-foreground" />
</Button> <Button
variant="ghost"
size="icon"
className="h-6 w-6"
onClick={handleSyncBranches}
disabled={syncing.branches || selectedInstance === undefined}
>
<RefreshCw className={`h-3 w-3 ${syncing.branches ? 'animate-spin' : ''}`} />
</Button>
</div>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<p className="text-sm text-muted-foreground"> <div className="text-2xl font-bold">{instanceInfo?.totalBranches || 0}</div>
Last sync: {instanceInfo?.lastSyncBranchesTime ? new Date(instanceInfo.lastSyncBranchesTime).toLocaleString() : 'Never'} <p className="text-xs text-muted-foreground mt-1">
: {formatTime(instanceInfo?.lastSyncBranchesTime)}
</p> </p>
</CardContent> </CardContent>
</Card> </Card>
</div> </div>
<Tabs defaultValue="groups" className="space-y-4"> {/* Tabs 区域 */}
<Tabs value={activeTab} onValueChange={setActiveTab} className="space-y-4">
<TabsList> <TabsList>
<TabsTrigger value="groups"></TabsTrigger> <TabsTrigger value="groups"> ({filteredGroups.length})</TabsTrigger>
<TabsTrigger value="projects"></TabsTrigger> <TabsTrigger value="projects"> ({filteredProjects.length})</TabsTrigger>
</TabsList> </TabsList>
{/* 仓库组 Tab */}
<TabsContent value="groups"> <TabsContent value="groups">
<Card className="mb-4">
<CardContent className="pt-6">
<div className="flex items-center gap-4">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
placeholder="搜索仓库组名称或路径..."
value={groupSearch}
onChange={(e) => setGroupSearch(e.target.value)}
className="pl-10"
/>
</div>
<Select value={groupVisibility} onValueChange={setGroupVisibility}>
<SelectTrigger className="w-[150px]">
<SelectValue placeholder="可见性" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all"></SelectItem>
<SelectItem value="public"></SelectItem>
<SelectItem value="private"></SelectItem>
<SelectItem value="internal"></SelectItem>
</SelectContent>
</Select>
<Button
variant="outline"
onClick={() => {
setGroupSearch('');
setGroupVisibility('all');
}}
>
</Button>
</div>
</CardContent>
</Card>
<Card> <Card>
<CardContent className="p-4"> <CardContent className="p-0">
<Table> {loading ? (
<TableHeader> <div className="flex items-center justify-center h-64">
<TableRow> <Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
<TableHead className="w-[200px]"></TableHead> </div>
<TableHead className="w-[200px]"></TableHead> ) : filteredGroups.length === 0 ? (
<TableHead className="w-[100px]"></TableHead> <div className="flex flex-col items-center justify-center h-64 text-muted-foreground">
<TableHead></TableHead> <FolderGit2 className="h-16 w-16 mb-4 opacity-20" />
<TableHead className="w-[150px]"></TableHead> <p className="text-lg font-medium"></p>
</TableRow> <p className="text-sm"> Git </p>
</TableHeader> </div>
<TableBody> ) : (
{instanceInfo?.repositoryGroupList?.map((group) => ( <Table>
<TableRow key={group.id}> <TableHeader>
<TableCell className="font-medium">{group.name}</TableCell> <TableRow>
<TableCell>{group.path}</TableCell> <TableHead className="w-[200px]"></TableHead>
<TableCell> <TableHead className="w-[200px]"></TableHead>
<Badge variant={group.visibility === 'private' ? 'secondary' : 'default'}> <TableHead className="w-[100px]"></TableHead>
{group.visibility} <TableHead></TableHead>
</Badge> <TableHead className="w-[200px]"></TableHead>
</TableCell>
<TableCell>{group.description}</TableCell>
<TableCell>
<Button variant="ghost" size="sm" onClick={handleSyncProjects}>
<RefreshCw className="mr-2 h-4 w-4" />
</Button>
</TableCell>
</TableRow> </TableRow>
))} </TableHeader>
</TableBody> <TableBody>
</Table> {filteredGroups.map((group) => (
<TableRow key={group.id}>
<TableCell className="font-medium">
<Button
variant="link"
className="p-0 h-auto font-medium text-base hover:underline"
onClick={() => handleGroupClick(group.groupId || group.id)}
>
{group.name}
</Button>
</TableCell>
<TableCell>
<div className="flex items-center gap-2">
<code className="text-xs bg-muted px-2 py-1 rounded">
{group.path}
</code>
<Button
variant="ghost"
size="sm"
onClick={() => handleCopyPath(group.path)}
>
<Copy className="h-3 w-3" />
</Button>
</div>
</TableCell>
<TableCell>
{renderVisibilityBadge(group.visibility)}
</TableCell>
<TableCell className="max-w-xs truncate" title={group.description}>
{group.description || '-'}
</TableCell>
<TableCell>
<div className="flex items-center gap-2">
<Button variant="ghost" size="sm" onClick={handleSyncProjects}>
<RefreshCw className="mr-1 h-3 w-3" />
</Button>
{group.webUrl && (
<a
href={group.webUrl}
target="_blank"
rel="noopener noreferrer"
>
<Button variant="ghost" size="sm">
<ExternalLink className="h-3 w-3" />
</Button>
</a>
)}
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent> </CardContent>
</Card> </Card>
</TabsContent> </TabsContent>
{/* 项目 Tab */}
<TabsContent value="projects"> <TabsContent value="projects">
<Card className="mb-4">
<CardContent className="pt-6">
<div className="flex items-center gap-4">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
placeholder="搜索项目名称或路径..."
value={projectSearch}
onChange={(e) => setProjectSearch(e.target.value)}
className="pl-10"
/>
</div>
<Select value={projectVisibility} onValueChange={setProjectVisibility}>
<SelectTrigger className="w-[150px]">
<SelectValue placeholder="可见性" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all"></SelectItem>
<SelectItem value="public"></SelectItem>
<SelectItem value="private"></SelectItem>
<SelectItem value="internal"></SelectItem>
</SelectContent>
</Select>
<Select value={projectGroup} onValueChange={setProjectGroup}>
<SelectTrigger className="w-[180px]">
<SelectValue placeholder="仓库组" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all"></SelectItem>
{instanceInfo?.repositoryGroupList?.map((group) => (
<SelectItem key={group.id} value={(group.groupId || group.id).toString()}>
{group.name}
</SelectItem>
))}
</SelectContent>
</Select>
<Button
variant="outline"
onClick={() => {
setProjectSearch('');
setProjectVisibility('all');
setProjectGroup('all');
}}
>
</Button>
</div>
</CardContent>
</Card>
<Card> <Card>
<CardContent className="p-4"> <CardContent className="p-0">
<Table> {loading ? (
<TableHeader> <div className="flex items-center justify-center h-64">
<TableRow> <Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
<TableHead className="w-[200px]"></TableHead> </div>
<TableHead className="w-[200px]"></TableHead> ) : filteredProjects.length === 0 ? (
<TableHead className="w-[120px]"></TableHead> <div className="flex flex-col items-center justify-center h-64 text-muted-foreground">
<TableHead></TableHead> <GitFork className="h-16 w-16 mb-4 opacity-20" />
<TableHead className="w-[200px]"></TableHead> <p className="text-lg font-medium"></p>
</TableRow> <p className="text-sm"> Git </p>
</TableHeader> </div>
<TableBody> ) : (
{instanceInfo?.repositoryProjectList?.map((project) => ( <Table>
<TableRow key={project.id}> <TableHeader>
<TableCell className="font-medium">{project.name}</TableCell> <TableRow>
<TableCell>{project.path}</TableCell> <TableHead className="w-[200px]"></TableHead>
<TableCell>{project.isDefaultBranch}</TableCell> <TableHead className="w-[200px]"></TableHead>
<TableCell>{new Date(project.lastActivityAt).toLocaleString()}</TableCell> <TableHead className="w-[100px]"></TableHead>
<TableCell> <TableHead className="w-[120px]"></TableHead>
<div className="flex gap-2"> <TableHead></TableHead>
<Button variant="ghost" size="sm" onClick={handleSyncBranches}> <TableHead className="w-[220px]"></TableHead>
<RefreshCw className="mr-2 h-4 w-4" />
</Button>
<Button variant="ghost" size="sm" asChild>
<a href={project.webUrl} target="_blank" rel="noopener noreferrer">
<Link2 className="mr-2 h-4 w-4" />
</a>
</Button>
</div>
</TableCell>
</TableRow> </TableRow>
))} </TableHeader>
</TableBody> <TableBody>
</Table> {filteredProjects.map((project) => (
<TableRow key={project.id}>
<TableCell className="font-medium">{project.name}</TableCell>
<TableCell>
<div className="flex items-center gap-2">
<code className="text-xs bg-muted px-2 py-1 rounded">
{project.path}
</code>
<Button
variant="ghost"
size="sm"
onClick={() => handleCopyPath(project.path)}
>
<Copy className="h-3 w-3" />
</Button>
</div>
</TableCell>
<TableCell>
{renderVisibilityBadge(project.visibility)}
</TableCell>
<TableCell>
<Badge variant="outline" className="inline-flex items-center gap-1">
<GitBranch className="h-3 w-3" />
{project.isDefaultBranch}
</Badge>
</TableCell>
<TableCell className="text-sm text-muted-foreground">
{formatTime(project.lastActivityAt)}
</TableCell>
<TableCell>
<div className="flex items-center gap-2">
<Button variant="ghost" size="sm" onClick={handleSyncBranches}>
<RefreshCw className="mr-1 h-3 w-3" />
</Button>
{project.webUrl && (
<a
href={project.webUrl}
target="_blank"
rel="noopener noreferrer"
>
<Button variant="ghost" size="sm">
<ExternalLink className="mr-1 h-3 w-3" />
</Button>
</a>
)}
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent> </CardContent>
</Card> </Card>
</TabsContent> </TabsContent>
</Tabs> </Tabs>
</PageContainer> </div>
); );
}; };

View File

@ -1,342 +1,560 @@
import React, { useState, useEffect, useRef } from 'react'; import React, { useState, useEffect, useMemo } from 'react';
import { PageContainer } from '@/components/ui/page-container'; import { PageContainer } from '@/components/ui/page-container';
import { RefreshCw, ChevronLeft, ChevronRight } from 'lucide-react'; import { RefreshCw, Layers, Box, Activity, Search, CheckCircle, XCircle, AlertTriangle, Loader2, ExternalLink } from 'lucide-react';
import { import {
Card, Card,
CardContent, CardContent,
} from "@/components/ui/card"; CardHeader,
import { Button } from "@/components/ui/button"; CardTitle,
} from '@/components/ui/card';
import { import {
Select, Table,
SelectContent, TableBody,
SelectItem, TableCell,
SelectTrigger, TableHead,
SelectValue, TableHeader,
} from "@/components/ui/select"; TableRow,
import { Badge } from "@/components/ui/badge"; } from '@/components/ui/table';
import { useToast } from "@/components/ui/use-toast"; import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { import {
Tabs, Select,
TabsContent, SelectContent,
TabsList, SelectItem,
TabsTrigger, SelectTrigger,
} from "@/components/ui/tabs"; SelectValue,
} from '@/components/ui/select';
import { Badge } from '@/components/ui/badge';
import { useToast } from '@/components/ui/use-toast';
import { Progress } from '@/components/ui/progress';
import type { JenkinsInstance, JenkinsInstanceDTO } from './types'; import type { JenkinsInstance, JenkinsInstanceDTO } from './types';
import { getJenkinsInstances, getJenkinsInstance, syncViews, syncJobs, syncBuilds } from './service'; import { getJenkinsInstances, getJenkinsInstance, syncViews, syncJobs, syncBuilds } from './service';
const JenkinsManagerList: React.FC = () => { const JenkinsManagerList: React.FC = () => {
const [jenkinsList, setJenkinsList] = useState<JenkinsInstance[]>([]); const [jenkinsList, setJenkinsList] = useState<JenkinsInstance[]>([]);
const [currentJenkinsId, setCurrentJenkinsId] = useState<string>(); const [currentJenkinsId, setCurrentJenkinsId] = useState<string>();
const [currentJenkins, setCurrentJenkins] = useState<JenkinsInstance>(); const [instanceDetails, setInstanceDetails] = useState<JenkinsInstanceDTO>();
const [instanceDetails, setInstanceDetails] = useState<JenkinsInstanceDTO>(); const [loading, setLoading] = useState(false);
const [loading, setLoading] = useState(false); const [currentView, setCurrentView] = useState<number>();
const [currentView, setCurrentView] = useState<string>(); const [syncing, setSyncing] = useState<Record<string, boolean>>({
const [syncing, setSyncing] = useState<Record<string, boolean>>({ views: false,
views: false, jobs: false,
jobs: false, builds: false,
builds: false all: false,
}); });
const tabsRef = useRef<HTMLDivElement>(null);
const { toast } = useToast();
// 获取 Jenkins 实例列表 // 搜索和过滤状态
const loadJenkinsList = async () => { const [searchKeyword, setSearchKeyword] = useState('');
try { const [statusFilter, setStatusFilter] = useState<string>('all');
const data = await getJenkinsInstances();
setJenkinsList(data); const { toast } = useToast();
// 如果没有选中的实例,默认选择第一个
if (!currentJenkinsId && data.length > 0) { // 获取 Jenkins 实例列表
setCurrentJenkinsId(String(data[0].id)); const loadJenkinsList = async () => {
setCurrentJenkins(data[0]); try {
} const data = await getJenkinsInstances();
} catch (error) { setJenkinsList(data);
toast({ if (!currentJenkinsId && data.length > 0) {
variant: "destructive", setCurrentJenkinsId(String(data[0].id));
title: "获取 Jenkins 实例列表失败", }
duration: 3000, } catch (error) {
}); toast({
} variant: 'destructive',
title: '获取 Jenkins 实例列表失败',
duration: 3000,
});
}
};
// 获取 Jenkins 实例详情
const loadInstanceDetails = async () => {
if (!currentJenkinsId) return;
setLoading(true);
try {
const data = await getJenkinsInstance(currentJenkinsId);
setInstanceDetails(data);
// 设置默认视图
if (data.jenkinsViewList.length > 0 && !currentView) {
setCurrentView(data.jenkinsViewList[0].id);
}
} catch (error) {
toast({
variant: 'destructive',
title: '获取实例详情失败',
duration: 3000,
});
} finally {
setLoading(false);
}
};
// 切换 Jenkins 实例
const handleJenkinsChange = (id: string) => {
setCurrentJenkinsId(id);
setInstanceDetails(undefined);
setCurrentView(undefined);
setSearchKeyword('');
setStatusFilter('all');
};
// 单个同步
const handleSync = async (type: 'views' | 'jobs' | 'builds') => {
if (!currentJenkinsId) return;
setSyncing((prev) => ({ ...prev, [type]: true }));
try {
switch (type) {
case 'views':
await syncViews(currentJenkinsId);
break;
case 'jobs':
await syncJobs(currentJenkinsId);
break;
case 'builds':
await syncBuilds(currentJenkinsId);
break;
}
await loadInstanceDetails();
toast({
title: '同步成功',
duration: 2000,
});
} catch (error) {
toast({
variant: 'destructive',
title: '同步失败',
duration: 3000,
});
} finally {
setSyncing((prev) => ({ ...prev, [type]: false }));
}
};
// 全部同步
const handleSyncAll = async () => {
if (!currentJenkinsId) return;
setSyncing((prev) => ({ ...prev, all: true }));
try {
await Promise.all([
syncViews(currentJenkinsId),
syncJobs(currentJenkinsId),
syncBuilds(currentJenkinsId),
]);
await loadInstanceDetails();
toast({
title: '全部同步成功',
duration: 2000,
});
} catch (error) {
toast({
variant: 'destructive',
title: '同步失败',
description: '部分数据同步失败,请重试',
duration: 3000,
});
} finally {
setSyncing((prev) => ({ ...prev, all: false }));
}
};
useEffect(() => {
loadJenkinsList();
}, []);
useEffect(() => {
if (currentJenkinsId) {
loadInstanceDetails();
}
}, [currentJenkinsId]);
const formatTime = (time: string | null | undefined) => {
if (!time) return 'Never';
return time;
};
// 获取构建状态信息
const getBuildStatusInfo = (status: string) => {
const statusMap: Record<string, { variant: 'default' | 'destructive' | 'secondary' | 'outline', icon: React.ReactNode, label: string }> = {
SUCCESS: {
variant: 'default',
icon: <CheckCircle className="h-3 w-3" />,
label: '成功',
},
FAILURE: {
variant: 'destructive',
icon: <XCircle className="h-3 w-3" />,
label: '失败',
},
UNSTABLE: {
variant: 'secondary',
icon: <AlertTriangle className="h-3 w-3" />,
label: '不稳定',
},
ABORTED: {
variant: 'outline',
icon: <XCircle className="h-3 w-3" />,
label: '已中止',
},
RUNNING: {
variant: 'outline',
icon: <Loader2 className="h-3 w-3 animate-spin" />,
label: '构建中',
},
}; };
return statusMap[status] || {
// 获取 Jenkins 实例详情 variant: 'outline' as const,
const loadInstanceDetails = async () => { icon: <AlertTriangle className="h-3 w-3" />,
if (!currentJenkinsId) return; label: status || '未知',
setLoading(true);
try {
const data = await getJenkinsInstance(currentJenkinsId);
setInstanceDetails(data);
} catch (error) {
toast({
variant: "destructive",
title: "获取实例详情失败",
duration: 3000,
});
} finally {
setLoading(false);
}
}; };
};
// 切换 Jenkins 实例 // 获取健康度颜色
const handleJenkinsChange = (id: string) => { const getHealthColor = (score: number) => {
setCurrentJenkinsId(id); if (score >= 80) return 'bg-green-500';
const jenkins = jenkinsList.find(j => String(j.id) === id); if (score >= 60) return 'bg-green-400';
setCurrentJenkins(jenkins); if (score >= 40) return 'bg-yellow-400';
setInstanceDetails(undefined); if (score >= 20) return 'bg-orange-400';
}; return 'bg-red-500';
};
// 同步数据 // 过滤和搜索 Jobs
const handleSync = async (type: 'views' | 'jobs' | 'builds') => { const filteredJobs = useMemo(() => {
if (!currentJenkinsId) return; if (!instanceDetails) return [];
setSyncing(prev => ({ ...prev, [type]: true }));
try {
switch (type) {
case 'views':
await syncViews(currentJenkinsId);
break;
case 'jobs':
await syncJobs(currentJenkinsId);
break;
case 'builds':
await syncBuilds(currentJenkinsId);
break;
}
await loadInstanceDetails(); // 重新加载实例详情
toast({
title: "同步成功",
duration: 3000,
});
} catch (error) {
toast({
variant: "destructive",
title: "同步失败",
duration: 3000,
});
} finally {
setSyncing(prev => ({ ...prev, [type]: false }));
}
};
// 在获取实例详情后设置默认视图 let jobs = instanceDetails.jenkinsJobList;
useEffect(() => {
if (instanceDetails?.jenkinsViewList.length > 0) {
setCurrentView(String(instanceDetails.jenkinsViewList[0].id));
}
}, [instanceDetails]);
useEffect(() => { // 按 View 过滤
loadJenkinsList(); if (currentView) {
}, []); jobs = jobs.filter((job) => job.viewId === currentView);
}
useEffect(() => { // 按搜索关键词过滤
if (currentJenkinsId) { if (searchKeyword) {
loadInstanceDetails(); jobs = jobs.filter((job) =>
} job.jobName.toLowerCase().includes(searchKeyword.toLowerCase()) ||
}, [currentJenkinsId]); job.description?.toLowerCase().includes(searchKeyword.toLowerCase())
);
}
const formatTime = (time: string | null | undefined) => { // 按状态过滤
if (!time) return 'Never'; if (statusFilter !== 'all') {
return time; jobs = jobs.filter((job) => job.lastBuildStatus === statusFilter);
}; }
const handleScroll = (direction: 'left' | 'right') => { return jobs;
if (tabsRef.current) { }, [instanceDetails, currentView, searchKeyword, statusFilter]);
const scrollAmount = 200; // 每次滚动的距离
const newScrollLeft = direction === 'left'
? tabsRef.current.scrollLeft - scrollAmount
: tabsRef.current.scrollLeft + scrollAmount;
tabsRef.current.scrollTo({
left: newScrollLeft,
behavior: 'smooth'
});
}
};
// 空状态:没有实例
if (jenkinsList.length === 0) {
return ( return (
<PageContainer> <PageContainer>
<div className="flex items-center justify-between mb-6"> <div className="flex items-center justify-between mb-6">
<h2 className="text-3xl font-bold tracking-tight">Jenkins </h2> <h2 className="text-3xl font-bold tracking-tight">Jenkins </h2>
<Select </div>
value={currentJenkinsId} <Card>
onValueChange={handleJenkinsChange} <CardContent className="flex flex-col items-center justify-center py-16">
> <Activity className="h-16 w-16 text-muted-foreground mb-4" />
<SelectTrigger className="w-[300px]"> <h3 className="text-lg font-semibold mb-2"> Jenkins </h3>
<SelectValue placeholder="选择 Jenkins 实例"/> <p className="text-sm text-muted-foreground mb-4">
</SelectTrigger> Jenkins
<SelectContent> </p>
{jenkinsList.map(jenkins => ( <Button onClick={() => window.location.href = '/deploy/external'}>
<SelectItem key={jenkins.id} value={String(jenkins.id)}>
{jenkins.name} </Button>
</SelectItem> </CardContent>
))} </Card>
</SelectContent> </PageContainer>
</Select>
</div>
{currentJenkins && (
<Card className="mb-6">
<CardContent className="pt-6">
<div className="space-y-4">
<div>
<h3 className="text-lg font-semibold">{currentJenkins.name}</h3>
<p className="text-sm text-muted-foreground">{currentJenkins.url}</p>
</div>
<div className="grid grid-cols-3 gap-4">
<div className="space-y-2">
<div className="flex items-center justify-between">
<span className="text-sm font-medium">Views</span>
<span className="text-2xl font-bold">{instanceDetails?.totalViews || 0}</span>
</div>
<div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">Last sync: {formatTime(instanceDetails?.lastSyncViewsTime)}</span>
<Button
variant="ghost"
size="sm"
onClick={() => handleSync('views')}
disabled={syncing.views}
>
<RefreshCw className={`h-4 w-4 ${syncing.views ? 'animate-spin' : ''}`} />
<span className="ml-2"></span>
</Button>
</div>
</div>
<div className="space-y-2">
<div className="flex items-center justify-between">
<span className="text-sm font-medium">Jobs</span>
<span className="text-2xl font-bold">{instanceDetails?.totalJobs || 0}</span>
</div>
<div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">Last sync: {formatTime(instanceDetails?.lastSyncJobsTime)}</span>
<Button
variant="ghost"
size="sm"
onClick={() => handleSync('jobs')}
disabled={syncing.jobs}
>
<RefreshCw className={`h-4 w-4 ${syncing.jobs ? 'animate-spin' : ''}`} />
<span className="ml-2"></span>
</Button>
</div>
</div>
<div className="space-y-2">
<div className="flex items-center justify-between">
<span className="text-sm font-medium">Builds</span>
<span className="text-2xl font-bold">{instanceDetails?.totalBuilds || 0}</span>
</div>
<div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">Last sync: {formatTime(instanceDetails?.lastSyncBuildsTime)}</span>
<Button
variant="ghost"
size="sm"
onClick={() => handleSync('builds')}
disabled={syncing.builds}
>
<RefreshCw className={`h-4 w-4 ${syncing.builds ? 'animate-spin' : ''}`} />
<span className="ml-2"></span>
</Button>
</div>
</div>
</div>
</div>
</CardContent>
</Card>
)}
{loading ? (
<Card>
<CardContent className="flex items-center justify-center py-8">
<RefreshCw className="h-6 w-6 animate-spin" />
</CardContent>
</Card>
) : instanceDetails && (
<Tabs value={currentView} onValueChange={setCurrentView}>
<div className="relative mb-6">
<div className="absolute left-0 top-0 bottom-0 flex items-center">
<Button
variant="ghost"
size="icon"
className="h-8 w-8 rounded-full bg-background/80 backdrop-blur-sm"
onClick={() => handleScroll('left')}
>
<ChevronLeft className="h-4 w-4" />
</Button>
</div>
<div className="overflow-x-auto scrollbar-hide mx-10" ref={tabsRef}>
<TabsList className="w-max">
{instanceDetails.jenkinsViewList.map(view => (
<TabsTrigger
key={view.id}
value={String(view.id)}
className="min-w-[120px] max-w-[200px]"
title={`${view.viewName}${view.description ? ` - ${view.description}` : ''}`}
>
<div className="truncate">
<span className="font-medium">{view.viewName}</span>
{view.description && (
<span className="ml-1 text-xs text-muted-foreground">
({view.description})
</span>
)}
</div>
</TabsTrigger>
))}
</TabsList>
</div>
<div className="absolute right-0 top-0 bottom-0 flex items-center">
<Button
variant="ghost"
size="icon"
className="h-8 w-8 rounded-full bg-background/80 backdrop-blur-sm"
onClick={() => handleScroll('right')}
>
<ChevronRight className="h-4 w-4" />
</Button>
</div>
</div>
{instanceDetails.jenkinsViewList.map(view => (
<TabsContent key={view.id} value={String(view.id)}>
<Card>
<CardContent className="pt-6">
<div className="space-y-4">
{instanceDetails.jenkinsJobList
.filter(job => job.viewId === view.id)
.map(job => (
<div key={job.id} className="flex items-center justify-between text-sm py-2">
<div>
<a
href={job.jobUrl}
target="_blank"
rel="noopener noreferrer"
className="hover:underline"
>
{job.jobName}
</a>
{job.description && (
<p className="text-xs text-muted-foreground">{job.description}</p>
)}
</div>
<div className="flex items-center gap-2">
<Badge variant={job.lastBuildStatus === 'SUCCESS' ? 'outline' : 'destructive'}>
#{job.lastBuildNumber} - {job.lastBuildStatus}
</Badge>
<span className="text-muted-foreground">{formatTime(job.lastBuildTime)}</span>
</div>
</div>
))}
</div>
</CardContent>
</Card>
</TabsContent>
))}
</Tabs>
)}
</PageContainer>
); );
}
return (
<PageContainer>
{/* 页面标题 */}
<div className="flex items-center justify-between">
<h2 className="text-3xl font-bold tracking-tight">Jenkins </h2>
<div className="flex items-center gap-4">
<Select value={currentJenkinsId} onValueChange={handleJenkinsChange}>
<SelectTrigger className="w-[300px]">
<SelectValue placeholder="选择 Jenkins 实例" />
</SelectTrigger>
<SelectContent>
{jenkinsList.map((jenkins) => (
<SelectItem key={jenkins.id} value={String(jenkins.id)}>
{jenkins.name}
</SelectItem>
))}
</SelectContent>
</Select>
<Button
onClick={handleSyncAll}
disabled={syncing.all}
variant="outline"
>
{syncing.all ? (
<Loader2 className="h-4 w-4 animate-spin mr-2" />
) : (
<RefreshCw className="h-4 w-4 mr-2" />
)}
</Button>
</div>
</div>
{/* 统计卡片 */}
<div className="grid gap-4 md:grid-cols-3">
<Card className="min-h-[140px]">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Views</CardTitle>
<div className="flex items-center gap-2">
<Layers className="h-4 w-4 text-muted-foreground" />
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
onClick={() => handleSync('views')}
disabled={syncing.views}
>
<RefreshCw className={`h-3 w-3 ${syncing.views ? 'animate-spin' : ''}`} />
</Button>
</div>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{instanceDetails?.totalViews || 0}</div>
<p className="text-xs text-muted-foreground mt-1">
: {formatTime(instanceDetails?.lastSyncViewsTime)}
</p>
</CardContent>
</Card>
<Card className="min-h-[140px]">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Jobs</CardTitle>
<div className="flex items-center gap-2">
<Box className="h-4 w-4 text-muted-foreground" />
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
onClick={() => handleSync('jobs')}
disabled={syncing.jobs}
>
<RefreshCw className={`h-3 w-3 ${syncing.jobs ? 'animate-spin' : ''}`} />
</Button>
</div>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{instanceDetails?.totalJobs || 0}</div>
<p className="text-xs text-muted-foreground mt-1">
: {formatTime(instanceDetails?.lastSyncJobsTime)}
</p>
</CardContent>
</Card>
<Card className="min-h-[140px]">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Builds</CardTitle>
<div className="flex items-center gap-2">
<Activity className="h-4 w-4 text-muted-foreground" />
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
onClick={() => handleSync('builds')}
disabled={syncing.builds}
>
<RefreshCw className={`h-3 w-3 ${syncing.builds ? 'animate-spin' : ''}`} />
</Button>
</div>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{instanceDetails?.totalBuilds || 0}</div>
<p className="text-xs text-muted-foreground mt-1">
: {formatTime(instanceDetails?.lastSyncBuildsTime)}
</p>
</CardContent>
</Card>
</div>
{/* View 切换 */}
{instanceDetails && instanceDetails.jenkinsViewList.length > 0 && (
<Card>
<CardContent className="pt-6">
<div className="flex gap-2 overflow-x-auto pb-2">
{instanceDetails.jenkinsViewList.map((view) => (
<Button
key={view.id}
variant={currentView === view.id ? 'default' : 'outline'}
size="sm"
onClick={() => setCurrentView(view.id)}
className="whitespace-nowrap"
>
{view.viewName}
</Button>
))}
</div>
</CardContent>
</Card>
)}
{/* 搜索和过滤 */}
{instanceDetails && instanceDetails.jenkinsViewList.length > 0 && (
<Card>
<div className="p-6">
<div className="flex items-center gap-4">
<div className="relative flex-1 max-w-sm">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder="搜索 Job 名称或描述..."
value={searchKeyword}
onChange={(e) => setSearchKeyword(e.target.value)}
className="pl-9"
/>
</div>
<Select value={statusFilter} onValueChange={setStatusFilter}>
<SelectTrigger className="w-[180px]">
<SelectValue placeholder="构建状态" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all"></SelectItem>
<SelectItem value="SUCCESS"></SelectItem>
<SelectItem value="FAILURE"></SelectItem>
<SelectItem value="UNSTABLE"></SelectItem>
<SelectItem value="RUNNING"></SelectItem>
</SelectContent>
</Select>
<Button
variant="outline"
onClick={() => {
setSearchKeyword('');
setStatusFilter('all');
}}
>
</Button>
</div>
</div>
</Card>
)}
{/* Jobs 表格 */}
{loading ? (
<Card>
<CardContent className="flex items-center justify-center py-16">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</CardContent>
</Card>
) : instanceDetails && instanceDetails.jenkinsViewList.length > 0 ? (
<Card>
<CardHeader>
<CardTitle>
{instanceDetails.jenkinsViewList.find((v) => v.id === currentView)?.viewName || 'Jobs'}
</CardTitle>
</CardHeader>
<CardContent>
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-[250px]">Job </TableHead>
<TableHead className="w-[300px]"></TableHead>
<TableHead className="w-[100px]"></TableHead>
<TableHead className="w-[120px]"></TableHead>
<TableHead className="w-[120px]"></TableHead>
<TableHead className="w-[180px]"></TableHead>
<TableHead className="w-[100px]"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredJobs.length === 0 ? (
<TableRow>
<TableCell colSpan={7} className="h-24 text-center">
<div className="flex flex-col items-center justify-center text-muted-foreground">
<Box className="h-8 w-8 mb-2" />
<p></p>
</div>
</TableCell>
</TableRow>
) : (
filteredJobs.map((job) => {
const statusInfo = getBuildStatusInfo(job.lastBuildStatus);
return (
<TableRow key={job.id}>
<TableCell className="font-medium">
<div className="flex items-center gap-2">
<span className="truncate">{job.jobName}</span>
</div>
</TableCell>
<TableCell className="text-muted-foreground text-sm">
{job.description || '-'}
</TableCell>
<TableCell>
<span className="text-sm">#{job.lastBuildNumber}</span>
</TableCell>
<TableCell>
<Badge variant={statusInfo.variant} className="inline-flex items-center gap-1">
{statusInfo.icon}
{statusInfo.label}
</Badge>
</TableCell>
<TableCell>
<div className="space-y-1">
<Progress
value={job.healthReportScore}
className="h-2"
indicatorClassName={getHealthColor(job.healthReportScore)}
/>
<span className="text-xs text-muted-foreground">{job.healthReportScore}%</span>
</div>
</TableCell>
<TableCell className="text-sm text-muted-foreground">
{formatTime(job.lastBuildTime)}
</TableCell>
<TableCell>
<Button
variant="ghost"
size="sm"
onClick={(e) => {
e.preventDefault();
// 确保使用绝对 URL
const url = job.jobUrl.startsWith('http')
? job.jobUrl
: `${jenkinsList.find(j => j.id === Number(currentJenkinsId))?.url || ''}${job.jobUrl}`;
window.open(url, '_blank', 'noopener,noreferrer');
}}
>
<ExternalLink className="h-4 w-4 mr-1" />
</Button>
</TableCell>
</TableRow>
);
})
)}
</TableBody>
</Table>
</div>
</CardContent>
</Card>
) : (
<Card>
<CardContent className="flex flex-col items-center justify-center py-16">
<Layers className="h-16 w-16 text-muted-foreground mb-4" />
<h3 className="text-lg font-semibold mb-2"></h3>
<p className="text-sm text-muted-foreground mb-4">
Jenkins
</p>
<Button onClick={handleSyncAll} disabled={syncing.all}>
{syncing.all ? (
<Loader2 className="h-4 w-4 animate-spin mr-2" />
) : (
<RefreshCw className="h-4 w-4 mr-2" />
)}
</Button>
</CardContent>
</Card>
)}
</PageContainer>
);
}; };
export default JenkinsManagerList; export default JenkinsManagerList;