增加审批组件
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"
|
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>
|
||||||
|
|||||||
@ -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,12 +47,42 @@ 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 () => {
|
||||||
@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -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();
|
// 搜索和过滤状态
|
||||||
|
const [searchKeyword, setSearchKeyword] = useState('');
|
||||||
|
const [statusFilter, setStatusFilter] = useState<string>('all');
|
||||||
|
|
||||||
// 获取 Jenkins 实例列表
|
const { toast } = useToast();
|
||||||
const loadJenkinsList = async () => {
|
|
||||||
try {
|
// 获取 Jenkins 实例列表
|
||||||
const data = await getJenkinsInstances();
|
const loadJenkinsList = async () => {
|
||||||
setJenkinsList(data);
|
try {
|
||||||
// 如果没有选中的实例,默认选择第一个
|
const data = await getJenkinsInstances();
|
||||||
if (!currentJenkinsId && data.length > 0) {
|
setJenkinsList(data);
|
||||||
setCurrentJenkinsId(String(data[0].id));
|
if (!currentJenkinsId && data.length > 0) {
|
||||||
setCurrentJenkins(data[0]);
|
setCurrentJenkinsId(String(data[0].id));
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast({
|
toast({
|
||||||
variant: "destructive",
|
variant: 'destructive',
|
||||||
title: "获取 Jenkins 实例列表失败",
|
title: '获取 Jenkins 实例列表失败',
|
||||||
duration: 3000,
|
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 {
|
let jobs = instanceDetails.jenkinsJobList;
|
||||||
switch (type) {
|
|
||||||
case 'views':
|
// 按 View 过滤
|
||||||
await syncViews(currentJenkinsId);
|
if (currentView) {
|
||||||
break;
|
jobs = jobs.filter((job) => job.viewId === currentView);
|
||||||
case 'jobs':
|
}
|
||||||
await syncJobs(currentJenkinsId);
|
|
||||||
break;
|
// 按搜索关键词过滤
|
||||||
case 'builds':
|
if (searchKeyword) {
|
||||||
await syncBuilds(currentJenkinsId);
|
jobs = jobs.filter((job) =>
|
||||||
break;
|
job.jobName.toLowerCase().includes(searchKeyword.toLowerCase()) ||
|
||||||
}
|
job.description?.toLowerCase().includes(searchKeyword.toLowerCase())
|
||||||
await loadInstanceDetails(); // 重新加载实例详情
|
);
|
||||||
toast({
|
}
|
||||||
title: "同步成功",
|
|
||||||
duration: 3000,
|
// 按状态过滤
|
||||||
});
|
if (statusFilter !== 'all') {
|
||||||
} catch (error) {
|
jobs = jobs.filter((job) => job.lastBuildStatus === statusFilter);
|
||||||
toast({
|
}
|
||||||
variant: "destructive",
|
|
||||||
title: "同步失败",
|
return jobs;
|
||||||
duration: 3000,
|
}, [instanceDetails, currentView, searchKeyword, statusFilter]);
|
||||||
});
|
|
||||||
} 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'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
|
// 空状态:没有实例
|
||||||
|
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;
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user