增加审批组件
This commit is contained in:
parent
2aada4632e
commit
e61b75f9e1
@ -3,10 +3,14 @@ import * as ProgressPrimitive from "@radix-ui/react-progress"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
export interface ProgressProps extends React.ComponentPropsWithoutRef<typeof ProgressPrimitive.Root> {
|
||||
indicatorClassName?: string;
|
||||
}
|
||||
|
||||
const Progress = React.forwardRef<
|
||||
React.ElementRef<typeof ProgressPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof ProgressPrimitive.Root>
|
||||
>(({ className, value, ...props }, ref) => (
|
||||
ProgressProps
|
||||
>(({ className, value, indicatorClassName, ...props }, ref) => (
|
||||
<ProgressPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(
|
||||
@ -16,7 +20,10 @@ const Progress = React.forwardRef<
|
||||
{...props}
|
||||
>
|
||||
<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)}%)` }}
|
||||
/>
|
||||
</ProgressPrimitive.Root>
|
||||
|
||||
@ -1,13 +1,12 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { PageContainer } from '@ant-design/pro-components';
|
||||
import React, { useState, useEffect, useMemo } from 'react';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle
|
||||
} from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
@ -25,16 +24,21 @@ import {
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { useToast } from "@/components/ui/use-toast";
|
||||
import {
|
||||
GitBranch,
|
||||
GitFork,
|
||||
RefreshCw,
|
||||
Link2,
|
||||
FolderGit2
|
||||
FolderGit2,
|
||||
Globe,
|
||||
Lock,
|
||||
Users,
|
||||
Copy,
|
||||
ExternalLink,
|
||||
Search,
|
||||
Loader2
|
||||
} from "lucide-react";
|
||||
import { message } from 'antd';
|
||||
import type { GitInstance, GitInstanceInfo, RepositoryGroup, RepositoryProject } from './types';
|
||||
import type { GitInstance, GitInstanceInfo } from './types';
|
||||
import {
|
||||
getGitInstances,
|
||||
getGitInstanceInfo,
|
||||
@ -43,12 +47,42 @@ import {
|
||||
syncGitProjects,
|
||||
syncGitBranches
|
||||
} 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 { toast } = useToast();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [instances, setInstances] = useState<GitInstance[]>([]);
|
||||
const [selectedInstance, setSelectedInstance] = useState<number>();
|
||||
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实例列表
|
||||
const loadInstances = async () => {
|
||||
@ -61,7 +95,11 @@ const GitManager: React.FC = () => {
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
message.error('加载Git实例失败');
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "加载失败",
|
||||
description: "加载 Git 实例列表失败",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@ -73,7 +111,11 @@ const GitManager: React.FC = () => {
|
||||
const data = await getGitInstanceInfo(selectedInstance);
|
||||
setInstanceInfo(data);
|
||||
} catch (error) {
|
||||
message.error('加载实例信息失败');
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "加载失败",
|
||||
description: "加载实例信息失败",
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@ -82,51 +124,135 @@ const GitManager: React.FC = () => {
|
||||
// 同步所有数据
|
||||
const handleSyncAll = async () => {
|
||||
if (!selectedInstance) return;
|
||||
setSyncing(prev => ({ ...prev, all: true }));
|
||||
try {
|
||||
await syncAllGitData(selectedInstance);
|
||||
message.success('同步任务已启动');
|
||||
toast({
|
||||
title: "同步成功",
|
||||
description: "同步任务已启动",
|
||||
});
|
||||
loadInstanceInfo();
|
||||
} catch (error) {
|
||||
message.error('同步失败');
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "同步失败",
|
||||
description: "同步任务启动失败",
|
||||
});
|
||||
} finally {
|
||||
setSyncing(prev => ({ ...prev, all: false }));
|
||||
}
|
||||
};
|
||||
|
||||
// 同步仓库组
|
||||
const handleSyncGroups = async () => {
|
||||
if (!selectedInstance) return;
|
||||
setSyncing(prev => ({ ...prev, groups: true }));
|
||||
try {
|
||||
await syncGitGroups(selectedInstance);
|
||||
message.success('仓库组同步任务已启动');
|
||||
toast({
|
||||
title: "同步成功",
|
||||
description: "仓库组同步任务已启动",
|
||||
});
|
||||
loadInstanceInfo();
|
||||
} catch (error) {
|
||||
message.error('同步失败');
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "同步失败",
|
||||
description: "仓库组同步任务启动失败",
|
||||
});
|
||||
} finally {
|
||||
setSyncing(prev => ({ ...prev, groups: false }));
|
||||
}
|
||||
};
|
||||
|
||||
// 同步项目
|
||||
const handleSyncProjects = async () => {
|
||||
if (!selectedInstance) return;
|
||||
setSyncing(prev => ({ ...prev, projects: true }));
|
||||
try {
|
||||
await syncGitProjects(selectedInstance);
|
||||
message.success('项目同步任务已启动');
|
||||
toast({
|
||||
title: "同步成功",
|
||||
description: "项目同步任务已启动",
|
||||
});
|
||||
loadInstanceInfo();
|
||||
} catch (error) {
|
||||
message.error('同步失败');
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "同步失败",
|
||||
description: "项目同步任务启动失败",
|
||||
});
|
||||
} finally {
|
||||
setSyncing(prev => ({ ...prev, projects: false }));
|
||||
}
|
||||
};
|
||||
|
||||
// 同步分支
|
||||
const handleSyncBranches = async () => {
|
||||
if (!selectedInstance) return;
|
||||
setSyncing(prev => ({ ...prev, branches: true }));
|
||||
try {
|
||||
await syncGitBranches(selectedInstance);
|
||||
message.success('分支同步任务已启动');
|
||||
toast({
|
||||
title: "同步成功",
|
||||
description: "分支同步任务已启动",
|
||||
});
|
||||
loadInstanceInfo();
|
||||
} 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(() => {
|
||||
loadInstances();
|
||||
}, []);
|
||||
@ -137,22 +263,41 @@ const GitManager: React.FC = () => {
|
||||
}
|
||||
}, [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 (
|
||||
<PageContainer
|
||||
header={{
|
||||
title: 'Git 仓库管理',
|
||||
extra: [
|
||||
<Button key="sync" onClick={handleSyncAll}>
|
||||
<RefreshCw className="mr-2 h-4 w-4" />
|
||||
同步所有数据
|
||||
</Button>,
|
||||
<div className="p-6 space-y-6">
|
||||
{/* 页面标题栏 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-3xl font-bold">Git 仓库管理</h1>
|
||||
<div className="flex items-center gap-3">
|
||||
<Select
|
||||
key="select"
|
||||
value={selectedInstance?.toString()}
|
||||
onValueChange={(value) => setSelectedInstance(Number(value))}
|
||||
>
|
||||
<SelectTrigger className="w-[200px]">
|
||||
<SelectValue placeholder="选择Git实例" />
|
||||
<SelectValue placeholder="选择 Git 实例" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{instances.map(instance => (
|
||||
@ -162,169 +307,368 @@ const GitManager: React.FC = () => {
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
],
|
||||
}}
|
||||
>
|
||||
{selectedInstance && instances.find(i => i.id === selectedInstance) && (
|
||||
<Card className="mb-6">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle>{instances.find(i => i.id === selectedInstance)?.name}</CardTitle>
|
||||
<CardDescription className="text-sm text-muted-foreground">
|
||||
{instances.find(i => i.id === selectedInstance)?.url}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
)}
|
||||
<Button onClick={handleSyncAll} disabled={syncing.all || selectedInstance === undefined}>
|
||||
{syncing.all ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<RefreshCw className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
同步所有数据
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 mb-6">
|
||||
<Card className="col-span-1">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-xl font-bold">
|
||||
<div className="flex items-center">
|
||||
<FolderGit2 className="mr-2 h-5 w-5" />
|
||||
{instanceInfo?.totalGroups || 0}
|
||||
</div>
|
||||
<div className="text-sm font-normal text-muted-foreground mt-1">仓库组</div>
|
||||
{/* 统计卡片 */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
{/* 仓库组卡片 */}
|
||||
<Card className="relative overflow-hidden min-h-[140px]">
|
||||
<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" />
|
||||
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">
|
||||
仓库组
|
||||
</CardTitle>
|
||||
<Button variant="ghost" size="sm" onClick={handleSyncGroups}>
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
</Button>
|
||||
<div className="flex items-center gap-2 relative z-10">
|
||||
<FolderGit2 className="h-4 w-4 text-muted-foreground" />
|
||||
<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>
|
||||
<CardContent>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Last sync: {instanceInfo?.lastSyncGroupsTime ? new Date(instanceInfo.lastSyncGroupsTime).toLocaleString() : 'Never'}
|
||||
<div className="text-2xl font-bold">{instanceInfo?.totalGroups || 0}</div>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
最后同步: {formatTime(instanceInfo?.lastSyncGroupsTime)}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="col-span-1">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-xl font-bold">
|
||||
<div className="flex items-center">
|
||||
<GitFork className="mr-2 h-5 w-5" />
|
||||
{instanceInfo?.totalProjects || 0}
|
||||
</div>
|
||||
<div className="text-sm font-normal text-muted-foreground mt-1">项目</div>
|
||||
{/* 项目卡片 */}
|
||||
<Card className="relative overflow-hidden min-h-[140px]">
|
||||
<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" />
|
||||
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">
|
||||
项目
|
||||
</CardTitle>
|
||||
<Button variant="ghost" size="sm" onClick={handleSyncProjects}>
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
</Button>
|
||||
<div className="flex items-center gap-2 relative z-10">
|
||||
<GitFork className="h-4 w-4 text-muted-foreground" />
|
||||
<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>
|
||||
<CardContent>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Last sync: {instanceInfo?.lastSyncProjectsTime ? new Date(instanceInfo.lastSyncProjectsTime).toLocaleString() : 'Never'}
|
||||
<div className="text-2xl font-bold">{instanceInfo?.totalProjects || 0}</div>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
最后同步: {formatTime(instanceInfo?.lastSyncProjectsTime)}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="col-span-1">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-xl font-bold">
|
||||
<div className="flex items-center">
|
||||
<GitBranch className="mr-2 h-5 w-5" />
|
||||
{instanceInfo?.totalBranches || 0}
|
||||
</div>
|
||||
<div className="text-sm font-normal text-muted-foreground mt-1">分支</div>
|
||||
{/* 分支卡片 */}
|
||||
<Card className="relative overflow-hidden min-h-[140px]">
|
||||
<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" />
|
||||
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">
|
||||
分支
|
||||
</CardTitle>
|
||||
<Button variant="ghost" size="sm" onClick={handleSyncBranches}>
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
</Button>
|
||||
<div className="flex items-center gap-2 relative z-10">
|
||||
<GitBranch className="h-4 w-4 text-muted-foreground" />
|
||||
<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>
|
||||
<CardContent>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Last sync: {instanceInfo?.lastSyncBranchesTime ? new Date(instanceInfo.lastSyncBranchesTime).toLocaleString() : 'Never'}
|
||||
<div className="text-2xl font-bold">{instanceInfo?.totalBranches || 0}</div>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
最后同步: {formatTime(instanceInfo?.lastSyncBranchesTime)}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Tabs defaultValue="groups" className="space-y-4">
|
||||
{/* Tabs 区域 */}
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab} className="space-y-4">
|
||||
<TabsList>
|
||||
<TabsTrigger value="groups">仓库组</TabsTrigger>
|
||||
<TabsTrigger value="projects">项目</TabsTrigger>
|
||||
<TabsTrigger value="groups">仓库组 ({filteredGroups.length})</TabsTrigger>
|
||||
<TabsTrigger value="projects">项目 ({filteredProjects.length})</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{/* 仓库组 Tab */}
|
||||
<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>
|
||||
<CardContent className="p-4">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-[200px]">名称</TableHead>
|
||||
<TableHead className="w-[200px]">路径</TableHead>
|
||||
<TableHead className="w-[100px]">可见性</TableHead>
|
||||
<TableHead>描述</TableHead>
|
||||
<TableHead className="w-[150px]">操作</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{instanceInfo?.repositoryGroupList?.map((group) => (
|
||||
<TableRow key={group.id}>
|
||||
<TableCell className="font-medium">{group.name}</TableCell>
|
||||
<TableCell>{group.path}</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={group.visibility === 'private' ? 'secondary' : 'default'}>
|
||||
{group.visibility}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>{group.description}</TableCell>
|
||||
<TableCell>
|
||||
<Button variant="ghost" size="sm" onClick={handleSyncProjects}>
|
||||
<RefreshCw className="mr-2 h-4 w-4" />
|
||||
同步项目
|
||||
</Button>
|
||||
</TableCell>
|
||||
<CardContent className="p-0">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : filteredGroups.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center h-64 text-muted-foreground">
|
||||
<FolderGit2 className="h-16 w-16 mb-4 opacity-20" />
|
||||
<p className="text-lg font-medium">暂无仓库组</p>
|
||||
<p className="text-sm">请同步 Git 实例数据</p>
|
||||
</div>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-[200px]">名称</TableHead>
|
||||
<TableHead className="w-[200px]">路径</TableHead>
|
||||
<TableHead className="w-[100px]">可见性</TableHead>
|
||||
<TableHead>描述</TableHead>
|
||||
<TableHead className="w-[200px]">操作</TableHead>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{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>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
{/* 项目 Tab */}
|
||||
<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>
|
||||
<CardContent className="p-4">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-[200px]">名称</TableHead>
|
||||
<TableHead className="w-[200px]">路径</TableHead>
|
||||
<TableHead className="w-[120px]">默认分支</TableHead>
|
||||
<TableHead>最后活动时间</TableHead>
|
||||
<TableHead className="w-[200px]">操作</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{instanceInfo?.repositoryProjectList?.map((project) => (
|
||||
<TableRow key={project.id}>
|
||||
<TableCell className="font-medium">{project.name}</TableCell>
|
||||
<TableCell>{project.path}</TableCell>
|
||||
<TableCell>{project.isDefaultBranch}</TableCell>
|
||||
<TableCell>{new Date(project.lastActivityAt).toLocaleString()}</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="ghost" size="sm" onClick={handleSyncBranches}>
|
||||
<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>
|
||||
<CardContent className="p-0">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : filteredProjects.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center h-64 text-muted-foreground">
|
||||
<GitFork className="h-16 w-16 mb-4 opacity-20" />
|
||||
<p className="text-lg font-medium">暂无项目</p>
|
||||
<p className="text-sm">请同步 Git 实例数据</p>
|
||||
</div>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-[200px]">名称</TableHead>
|
||||
<TableHead className="w-[200px]">路径</TableHead>
|
||||
<TableHead className="w-[100px]">可见性</TableHead>
|
||||
<TableHead className="w-[120px]">默认分支</TableHead>
|
||||
<TableHead>最后活动时间</TableHead>
|
||||
<TableHead className="w-[220px]">操作</TableHead>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{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>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</PageContainer>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@ -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 { RefreshCw, ChevronLeft, ChevronRight } from 'lucide-react';
|
||||
import { RefreshCw, Layers, Box, Activity, Search, CheckCircle, XCircle, AlertTriangle, Loader2, ExternalLink } from 'lucide-react';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
} from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { useToast } from "@/components/ui/use-toast";
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import {
|
||||
Tabs,
|
||||
TabsContent,
|
||||
TabsList,
|
||||
TabsTrigger,
|
||||
} from "@/components/ui/tabs";
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
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 { getJenkinsInstances, getJenkinsInstance, syncViews, syncJobs, syncBuilds } from './service';
|
||||
|
||||
const JenkinsManagerList: React.FC = () => {
|
||||
const [jenkinsList, setJenkinsList] = useState<JenkinsInstance[]>([]);
|
||||
const [currentJenkinsId, setCurrentJenkinsId] = useState<string>();
|
||||
const [currentJenkins, setCurrentJenkins] = useState<JenkinsInstance>();
|
||||
const [instanceDetails, setInstanceDetails] = useState<JenkinsInstanceDTO>();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [currentView, setCurrentView] = useState<string>();
|
||||
const [syncing, setSyncing] = useState<Record<string, boolean>>({
|
||||
views: false,
|
||||
jobs: false,
|
||||
builds: false
|
||||
});
|
||||
const tabsRef = useRef<HTMLDivElement>(null);
|
||||
const { toast } = useToast();
|
||||
const [jenkinsList, setJenkinsList] = useState<JenkinsInstance[]>([]);
|
||||
const [currentJenkinsId, setCurrentJenkinsId] = useState<string>();
|
||||
const [instanceDetails, setInstanceDetails] = useState<JenkinsInstanceDTO>();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [currentView, setCurrentView] = useState<number>();
|
||||
const [syncing, setSyncing] = useState<Record<string, boolean>>({
|
||||
views: false,
|
||||
jobs: false,
|
||||
builds: false,
|
||||
all: false,
|
||||
});
|
||||
|
||||
// 搜索和过滤状态
|
||||
const [searchKeyword, setSearchKeyword] = useState('');
|
||||
const [statusFilter, setStatusFilter] = useState<string>('all');
|
||||
|
||||
// 获取 Jenkins 实例列表
|
||||
const loadJenkinsList = async () => {
|
||||
try {
|
||||
const data = await getJenkinsInstances();
|
||||
setJenkinsList(data);
|
||||
// 如果没有选中的实例,默认选择第一个
|
||||
if (!currentJenkinsId && data.length > 0) {
|
||||
setCurrentJenkinsId(String(data[0].id));
|
||||
setCurrentJenkins(data[0]);
|
||||
}
|
||||
} catch (error) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "获取 Jenkins 实例列表失败",
|
||||
duration: 3000,
|
||||
});
|
||||
}
|
||||
const { toast } = useToast();
|
||||
|
||||
// 获取 Jenkins 实例列表
|
||||
const loadJenkinsList = async () => {
|
||||
try {
|
||||
const data = await getJenkinsInstances();
|
||||
setJenkinsList(data);
|
||||
if (!currentJenkinsId && data.length > 0) {
|
||||
setCurrentJenkinsId(String(data[0].id));
|
||||
}
|
||||
} 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: '构建中',
|
||||
},
|
||||
};
|
||||
|
||||
// 获取 Jenkins 实例详情
|
||||
const loadInstanceDetails = async () => {
|
||||
if (!currentJenkinsId) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await getJenkinsInstance(currentJenkinsId);
|
||||
setInstanceDetails(data);
|
||||
} catch (error) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "获取实例详情失败",
|
||||
duration: 3000,
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
return statusMap[status] || {
|
||||
variant: 'outline' as const,
|
||||
icon: <AlertTriangle className="h-3 w-3" />,
|
||||
label: status || '未知',
|
||||
};
|
||||
};
|
||||
|
||||
// 切换 Jenkins 实例
|
||||
const handleJenkinsChange = (id: string) => {
|
||||
setCurrentJenkinsId(id);
|
||||
const jenkins = jenkinsList.find(j => String(j.id) === id);
|
||||
setCurrentJenkins(jenkins);
|
||||
setInstanceDetails(undefined);
|
||||
};
|
||||
// 获取健康度颜色
|
||||
const getHealthColor = (score: number) => {
|
||||
if (score >= 80) return 'bg-green-500';
|
||||
if (score >= 60) return 'bg-green-400';
|
||||
if (score >= 40) return 'bg-yellow-400';
|
||||
if (score >= 20) return 'bg-orange-400';
|
||||
return 'bg-red-500';
|
||||
};
|
||||
|
||||
// 同步数据
|
||||
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: 3000,
|
||||
});
|
||||
} catch (error) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "同步失败",
|
||||
duration: 3000,
|
||||
});
|
||||
} finally {
|
||||
setSyncing(prev => ({ ...prev, [type]: false }));
|
||||
}
|
||||
};
|
||||
|
||||
// 在获取实例详情后设置默认视图
|
||||
useEffect(() => {
|
||||
if (instanceDetails?.jenkinsViewList.length > 0) {
|
||||
setCurrentView(String(instanceDetails.jenkinsViewList[0].id));
|
||||
}
|
||||
}, [instanceDetails]);
|
||||
|
||||
useEffect(() => {
|
||||
loadJenkinsList();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (currentJenkinsId) {
|
||||
loadInstanceDetails();
|
||||
}
|
||||
}, [currentJenkinsId]);
|
||||
|
||||
const formatTime = (time: string | null | undefined) => {
|
||||
if (!time) return 'Never';
|
||||
return time;
|
||||
};
|
||||
|
||||
const handleScroll = (direction: 'left' | 'right') => {
|
||||
if (tabsRef.current) {
|
||||
const scrollAmount = 200; // 每次滚动的距离
|
||||
const newScrollLeft = direction === 'left'
|
||||
? tabsRef.current.scrollLeft - scrollAmount
|
||||
: tabsRef.current.scrollLeft + scrollAmount;
|
||||
tabsRef.current.scrollTo({
|
||||
left: newScrollLeft,
|
||||
behavior: 'smooth'
|
||||
});
|
||||
}
|
||||
};
|
||||
// 过滤和搜索 Jobs
|
||||
const filteredJobs = useMemo(() => {
|
||||
if (!instanceDetails) return [];
|
||||
|
||||
let jobs = instanceDetails.jenkinsJobList;
|
||||
|
||||
// 按 View 过滤
|
||||
if (currentView) {
|
||||
jobs = jobs.filter((job) => job.viewId === currentView);
|
||||
}
|
||||
|
||||
// 按搜索关键词过滤
|
||||
if (searchKeyword) {
|
||||
jobs = jobs.filter((job) =>
|
||||
job.jobName.toLowerCase().includes(searchKeyword.toLowerCase()) ||
|
||||
job.description?.toLowerCase().includes(searchKeyword.toLowerCase())
|
||||
);
|
||||
}
|
||||
|
||||
// 按状态过滤
|
||||
if (statusFilter !== 'all') {
|
||||
jobs = jobs.filter((job) => job.lastBuildStatus === statusFilter);
|
||||
}
|
||||
|
||||
return jobs;
|
||||
}, [instanceDetails, currentView, searchKeyword, statusFilter]);
|
||||
|
||||
// 空状态:没有实例
|
||||
if (jenkinsList.length === 0) {
|
||||
return (
|
||||
<PageContainer>
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h2 className="text-3xl font-bold tracking-tight">Jenkins 三方管理</h2>
|
||||
<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>
|
||||
</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>
|
||||
<PageContainer>
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h2 className="text-3xl font-bold tracking-tight">Jenkins 三方管理</h2>
|
||||
</div>
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center justify-center py-16">
|
||||
<Activity className="h-16 w-16 text-muted-foreground mb-4" />
|
||||
<h3 className="text-lg font-semibold mb-2">暂无 Jenkins 实例</h3>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
请先在外部服务管理中添加 Jenkins 系统
|
||||
</p>
|
||||
<Button onClick={() => window.location.href = '/deploy/external'}>
|
||||
前往外部服务管理
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</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;
|
||||
|
||||
Loading…
Reference in New Issue
Block a user