增加团队管理页面

This commit is contained in:
dengqichen 2025-10-30 10:18:23 +08:00
parent d5c4bb0c1f
commit e087b6abd8
5 changed files with 569 additions and 404 deletions

View File

@ -42,6 +42,7 @@
"@react-form-builder/designer": "^7.4.0", "@react-form-builder/designer": "^7.4.0",
"@react-form-builder/designer-bundle": "^7.4.0", "@react-form-builder/designer-bundle": "^7.4.0",
"@reduxjs/toolkit": "^2.0.1", "@reduxjs/toolkit": "^2.0.1",
"@tanstack/react-virtual": "^3.13.12",
"@types/recharts": "^1.8.29", "@types/recharts": "^1.8.29",
"@types/uuid": "^10.0.0", "@types/uuid": "^10.0.0",
"@xyflow/react": "^12.8.6", "@xyflow/react": "^12.8.6",

View File

@ -104,6 +104,9 @@ importers:
'@reduxjs/toolkit': '@reduxjs/toolkit':
specifier: ^2.0.1 specifier: ^2.0.1
version: 2.5.0(react-redux@9.2.0(@types/react@18.3.18)(react@18.3.1)(redux@5.0.1))(react@18.3.1) version: 2.5.0(react-redux@9.2.0(@types/react@18.3.18)(react@18.3.1)(redux@5.0.1))(react@18.3.1)
'@tanstack/react-virtual':
specifier: ^3.13.12
version: 3.13.12(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@types/recharts': '@types/recharts':
specifier: ^1.8.29 specifier: ^1.8.29
version: 1.8.29 version: 1.8.29
@ -1950,6 +1953,15 @@ packages:
react: '>=16.8.0' react: '>=16.8.0'
react-dom: '>=16.8.0' react-dom: '>=16.8.0'
'@tanstack/react-virtual@3.13.12':
resolution: {integrity: sha512-Gd13QdxPSukP8ZrkbgS2RwoZseTTbQPLnQEn7HY/rqtM+8Zt95f7xKC7N0EsKs7aoz0WzZ+fditZux+F8EzYxA==}
peerDependencies:
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
'@tanstack/virtual-core@3.13.12':
resolution: {integrity: sha512-1YBOJfRHV4sXUmWsFSf5rQor4Ss82G8dQWLRbnk3GA4jeP8hQt1hxXh0tmflpC0dz3VgEv/1+qwPyLeWkQuPFA==}
'@types/babel__core@7.20.5': '@types/babel__core@7.20.5':
resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==}
@ -6167,6 +6179,14 @@ snapshots:
react: 18.3.1 react: 18.3.1
react-dom: 18.3.1(react@18.3.1) react-dom: 18.3.1(react@18.3.1)
'@tanstack/react-virtual@3.13.12(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
dependencies:
'@tanstack/virtual-core': 3.13.12
react: 18.3.1
react-dom: 18.3.1(react@18.3.1)
'@tanstack/virtual-core@3.13.12': {}
'@types/babel__core@7.20.5': '@types/babel__core@7.20.5':
dependencies: dependencies:
'@babel/parser': 7.26.3 '@babel/parser': 7.26.3

View File

@ -1,4 +1,4 @@
import React, { useState, useEffect, useMemo } from 'react'; import React, { useState, useEffect, useMemo, useCallback } from 'react';
import { import {
Card, Card,
CardHeader, CardHeader,
@ -43,6 +43,7 @@ import {
Calendar, Calendar,
User, User,
GitCommit, GitCommit,
Download,
} from 'lucide-react'; } from 'lucide-react';
import type { import type {
RepositoryGroupResponse, RepositoryGroupResponse,
@ -116,34 +117,39 @@ const GitManager: React.FC = () => {
// 搜索过滤 // 搜索过滤
const [groupSearch, setGroupSearch] = useState(''); const [groupSearch, setGroupSearch] = useState('');
const [projectSearch, setProjectSearch] = useState(''); const [projectSearch, setProjectSearch] = useState('');
const [branchSearch, setBranchSearch] = useState(''); const [branchSearch, setBranchSearch] = useState('');
// 防抖搜索值 - 优化大量数据过滤性能
const [debouncedGroupSearch, setDebouncedGroupSearch] = useState('');
const [debouncedProjectSearch, setDebouncedProjectSearch] = useState('');
const [debouncedBranchSearch, setDebouncedBranchSearch] = useState('');
// 加载 Git 实例列表 // 仓库组搜索防抖
const loadInstances = async () => { useEffect(() => {
try { const timer = setTimeout(() => {
setDebouncedGroupSearch(groupSearch);
}, 300);
return () => clearTimeout(timer);
}, [groupSearch]);
const data = await getGitInstances(); // 项目搜索防抖
if (data && Array.isArray(data)) { useEffect(() => {
setInstances(data); const timer = setTimeout(() => {
if (data.length > 0 && !selectedInstanceId) { setDebouncedProjectSearch(projectSearch);
setSelectedInstanceId(data[0].id); }, 300);
} return () => clearTimeout(timer);
} }, [projectSearch]);
} catch (error) {
toast({
variant: 'destructive',
title: '加载失败',
description: '加载 Git 实例列表失败',
});
}
};
// 分支搜索防抖
useEffect(() => {
const timer = setTimeout(() => {
setDebouncedBranchSearch(branchSearch);
}, 300);
return () => clearTimeout(timer);
}, [branchSearch]);
// 构建树形结构 // 构建树形结构
const buildTree = (groups: RepositoryGroupResponse[]): RepositoryGroupResponse[] => { const buildTree = useCallback((groups: RepositoryGroupResponse[]): RepositoryGroupResponse[] => {
// 使用 repoGroupId 作为 key因为 parentId 对应的是 Git 系统中的 repoGroupId // 使用 repoGroupId 作为 key因为 parentId 对应的是 Git 系统中的 repoGroupId
const map = new Map<number, RepositoryGroupResponse>(); const map = new Map<number, RepositoryGroupResponse>();
const roots: RepositoryGroupResponse[] = []; const roots: RepositoryGroupResponse[] = [];
@ -178,10 +184,10 @@ const GitManager: React.FC = () => {
}); });
return roots; return roots;
}; }, []);
// 加载仓库组列表并构建树 // 加载仓库组列表并构建树
const loadGroupTree = async () => { const loadGroupTree = useCallback(async () => {
if (!selectedInstanceId) return; if (!selectedInstanceId) return;
setLoading((prev) => ({ ...prev, groups: true })); setLoading((prev) => ({ ...prev, groups: true }));
@ -202,10 +208,10 @@ const GitManager: React.FC = () => {
} finally { } finally {
setLoading((prev) => ({ ...prev, groups: false })); setLoading((prev) => ({ ...prev, groups: false }));
} }
}; }, [selectedInstanceId, buildTree, toast]);
// 加载项目列表 // 加载项目列表支持不传repoGroupId加载所有项目
const loadProjects = async (repoGroupId?: number) => { const loadProjects = useCallback(async (repoGroupId?: number) => {
if (!selectedInstanceId) return; if (!selectedInstanceId) return;
setLoading((prev) => ({ ...prev, projects: true })); setLoading((prev) => ({ ...prev, projects: true }));
@ -225,10 +231,10 @@ const GitManager: React.FC = () => {
} finally { } finally {
setLoading((prev) => ({ ...prev, projects: false })); setLoading((prev) => ({ ...prev, projects: false }));
} }
}; }, [selectedInstanceId, toast]);
// 加载分支列表 // 加载分支列表支持不传repoProjectId加载该组所有分支
const loadBranches = async (repoProjectId: number) => { const loadBranches = useCallback(async (repoProjectId?: number) => {
setLoading((prev) => ({ ...prev, branches: true })); setLoading((prev) => ({ ...prev, branches: true }));
try { try {
const data = await getRepositoryBranches({ const data = await getRepositoryBranches({
@ -237,7 +243,6 @@ const GitManager: React.FC = () => {
setBranches(data || []); setBranches(data || []);
} catch (error) { } catch (error) {
toast({ toast({
variant: 'destructive', variant: 'destructive',
title: '加载失败', title: '加载失败',
description: '加载分支列表失败', description: '加载分支列表失败',
@ -246,11 +251,10 @@ const GitManager: React.FC = () => {
} finally { } finally {
setLoading((prev) => ({ ...prev, branches: false })); setLoading((prev) => ({ ...prev, branches: false }));
} }
}; }, [toast]);
// 同步所有数据(异步任务) // 同步所有数据(异步任务)
const handleSyncAll = async () => { const handleSyncAll = useCallback(async () => {
if (!selectedInstanceId) return; if (!selectedInstanceId) return;
setSyncing((prev) => ({ ...prev, all: true })); setSyncing((prev) => ({ ...prev, all: true }));
@ -269,11 +273,10 @@ const GitManager: React.FC = () => {
} finally { } finally {
setSyncing((prev) => ({ ...prev, all: false })); setSyncing((prev) => ({ ...prev, all: false }));
} }
}; }, [selectedInstanceId, toast]);
// 同步仓库组(异步任务) // 同步仓库组(异步任务)
const handleSyncGroups = async () => { const handleSyncGroups = useCallback(async () => {
if (!selectedInstanceId) return; if (!selectedInstanceId) return;
setSyncing((prev) => ({ ...prev, groups: true })); setSyncing((prev) => ({ ...prev, groups: true }));
@ -292,23 +295,27 @@ const GitManager: React.FC = () => {
} finally { } finally {
setSyncing((prev) => ({ ...prev, groups: false })); setSyncing((prev) => ({ ...prev, groups: false }));
} }
}; }, [selectedInstanceId, toast]);
// 同步项目(异步任务) // 同步项目(异步任务,支持未选择组时同步所有项目)
const handleSyncProjects = async () => { const handleSyncProjects = useCallback(async () => {
if (!selectedInstanceId) return;
if (!selectedInstanceId || !selectedGroup) return;
setSyncing((prev) => ({ ...prev, projects: true })); setSyncing((prev) => ({ ...prev, projects: true }));
try { try {
// 传递 repoGroupId (Git系统中的ID) 和 externalSystemId // 传递 repoGroupId (Git系统中的ID) 和 externalSystemId
await syncRepositoryProjects( await syncRepositoryProjects(
selectedInstanceId, selectedInstanceId,
selectedGroup.repoGroupId selectedGroup?.repoGroupId
); );
const description = selectedGroup
? `正在后台同步仓库组"${selectedGroup.name}"的项目,请稍后手动刷新查看结果`
: '正在后台同步所有项目,请稍后手动刷新查看结果';
toast({ toast({
title: '同步任务已启动', title: '同步任务已启动',
description: `正在后台同步仓库组"${selectedGroup.name}"的项目,请稍后手动刷新查看结果`, description,
}); });
} catch (error) { } catch (error) {
toast({ toast({
@ -319,12 +326,11 @@ const GitManager: React.FC = () => {
} finally { } finally {
setSyncing((prev) => ({ ...prev, projects: false })); setSyncing((prev) => ({ ...prev, projects: false }));
} }
}; }, [selectedInstanceId, selectedGroup, toast]);
// 同步分支(异步任务) // 同步分支(异步任务)
const handleSyncBranches = async () => { const handleSyncBranches = useCallback(async () => {
if (!selectedInstanceId) return;
if (!selectedInstanceId || !selectedGroup) return;
setSyncing((prev) => ({ ...prev, branches: true })); setSyncing((prev) => ({ ...prev, branches: true }));
try { try {
@ -332,12 +338,15 @@ const GitManager: React.FC = () => {
await syncRepositoryBranches( await syncRepositoryBranches(
selectedInstanceId, selectedInstanceId,
selectedProject?.repoProjectId, selectedProject?.repoProjectId,
selectedGroup.repoGroupId selectedGroup?.repoGroupId
); );
const description = selectedProject let description = '正在后台同步所有分支,请稍后手动刷新查看结果';
? `正在后台同步项目"${selectedProject.name}"的分支,请稍后手动刷新查看结果` if (selectedProject) {
: `正在后台同步组"${selectedGroup.name}"下所有项目的分支,请稍后手动刷新查看结果`; description = `正在后台同步项目"${selectedProject.name}"的分支,请稍后手动刷新查看结果`;
} else if (selectedGroup) {
description = `正在后台同步组"${selectedGroup.name}"下所有项目的分支,请稍后手动刷新查看结果`;
}
toast({ toast({
title: '同步任务已启动', title: '同步任务已启动',
@ -352,10 +361,10 @@ const GitManager: React.FC = () => {
} finally { } finally {
setSyncing((prev) => ({ ...prev, branches: false })); setSyncing((prev) => ({ ...prev, branches: false }));
} }
}; }, [selectedInstanceId, selectedGroup, selectedProject, toast]);
// 切换仓库组展开/折叠 // 切换仓库组展开/折叠
const toggleGroupExpand = (groupId: number) => { const toggleGroupExpand = useCallback((groupId: number) => {
setExpandedGroupIds((prev) => { setExpandedGroupIds((prev) => {
const next = new Set(prev); const next = new Set(prev);
if (next.has(groupId)) { if (next.has(groupId)) {
@ -365,37 +374,44 @@ const GitManager: React.FC = () => {
} }
return next; return next;
}); });
}; }, []);
// 选中仓库组(支持反选取消)
const handleSelectGroup = useCallback((group: RepositoryGroupResponse) => {
// 如果点击的是已选中的组,则取消选择
setSelectedGroup((prev) => {
const newGroup = prev?.id === group.id ? undefined : group;
setSelectedProject(undefined);
setBranches([]);
// 加载项目列表,如果取消选择则不传 repoGroupId加载所有项目
loadProjects(newGroup?.repoGroupId || newGroup?.id);
return newGroup;
});
}, [loadProjects]);
// 选中仓库组 // 选中项目(支持反选取消)
const handleSelectGroup = (group: RepositoryGroupResponse) => { const handleSelectProject = useCallback((project: RepositoryProjectResponse) => {
setSelectedGroup(group); // 如果点击的是已选中的项目,则取消选择
setSelectedProject(undefined); setSelectedProject((prev) => {
setBranches([]); const newProject = prev?.id === project.id ? undefined : project;
loadProjects(group.repoGroupId || group.id); // 加载分支列表,如果取消选择则不传 repoProjectId加载所有分支
}; loadBranches(newProject?.repoProjectId || newProject?.id);
return newProject;
// 选中项目 });
const handleSelectProject = (project: RepositoryProjectResponse) => { }, [loadBranches]);
setSelectedProject(project);
loadBranches(project.repoProjectId || project.id);
};
// 格式化时间 // 格式化时间
const formatTime = (time?: string) => { const formatTime = useCallback((time?: string) => {
if (!time) return '-'; if (!time) return '-';
const diff = dayjs().diff(dayjs(time), 'day'); const diff = dayjs().diff(dayjs(time), 'day');
if (diff > 7) { if (diff > 7) {
return dayjs(time).format('YYYY-MM-DD HH:mm'); return dayjs(time).format('YYYY-MM-DD HH:mm');
} }
return dayjs(time).fromNow(); return dayjs(time).fromNow();
}; }, []);
// 渲染可见性图标(带 Tooltip // 渲染可见性图标(带 Tooltip
const renderVisibilityIcon = (visibility: string) => { const renderVisibilityIcon = useCallback((visibility: string) => {
const config = const config =
VISIBILITY_CONFIG[visibility.toLowerCase() as keyof typeof VISIBILITY_CONFIG] || VISIBILITY_CONFIG[visibility.toLowerCase() as keyof typeof VISIBILITY_CONFIG] ||
VISIBILITY_CONFIG.public; VISIBILITY_CONFIG.public;
@ -412,17 +428,16 @@ const GitManager: React.FC = () => {
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
); );
}; }, []);
// 过滤仓库组树 // 过滤仓库组树(使用防抖搜索值)
const filterGroupTree = (groups: RepositoryGroupResponse[]): RepositoryGroupResponse[] => { const filterGroupTree = useCallback((groups: RepositoryGroupResponse[]): RepositoryGroupResponse[] => {
if (!groupSearch) return groups; if (!debouncedGroupSearch) return groups;
return groups.filter((group) => { return groups.filter((group) => {
const matchesSearch = const matchesSearch =
group.name.toLowerCase().includes(groupSearch.toLowerCase()) || group.name.toLowerCase().includes(debouncedGroupSearch.toLowerCase()) ||
(group.path && group.path.toLowerCase().includes(debouncedGroupSearch.toLowerCase()));
(group.path && group.path.toLowerCase().includes(groupSearch.toLowerCase()));
const filteredChildren = group.children const filteredChildren = group.children
? filterGroupTree(group.children) ? filterGroupTree(group.children)
@ -433,7 +448,7 @@ const GitManager: React.FC = () => {
...group, ...group,
children: group.children ? filterGroupTree(group.children) : [], children: group.children ? filterGroupTree(group.children) : [],
})); }));
}; }, [debouncedGroupSearch]);
// 渲染仓库组树节点 // 渲染仓库组树节点
const renderGroupTreeNode = ( const renderGroupTreeNode = (
@ -480,7 +495,7 @@ const GitManager: React.FC = () => {
{group.projectCount !== undefined && group.projectCount > 0 && ( {group.projectCount !== undefined && group.projectCount > 0 && (
<Badge variant="secondary" className="text-xs px-1.5 py-0 h-5"> <Badge variant="secondary" className="text-xs px-1.5 py-0 h-5">
{group.projectCount} {group.projectCount}
</Badge> </Badge>
)} )}
{group.webUrl && ( {group.webUrl && (
@ -516,33 +531,47 @@ const GitManager: React.FC = () => {
); );
}; };
// 过滤项目列表 // 过滤项目列表(使用防抖搜索值)
const filteredProjects = useMemo(() => { const filteredProjects = useMemo(() => {
if (!debouncedProjectSearch) return projects;
if (!projectSearch) return projects;
return projects.filter( return projects.filter(
(project) => (project) =>
project.name.toLowerCase().includes(projectSearch.toLowerCase()) || project.name.toLowerCase().includes(debouncedProjectSearch.toLowerCase()) ||
(project.path && project.path.toLowerCase().includes(debouncedProjectSearch.toLowerCase()))
(project.path && project.path.toLowerCase().includes(projectSearch.toLowerCase()))
); );
}, [projects, projectSearch]); }, [projects, debouncedProjectSearch]);
// 过滤分支列表 // 过滤分支列表(使用防抖搜索值)
const filteredBranches = useMemo(() => { const filteredBranches = useMemo(() => {
if (!branchSearch) return branches; if (!debouncedBranchSearch) return branches;
return branches.filter((branch) => return branches.filter((branch) =>
branch.name.toLowerCase().includes(branchSearch.toLowerCase()) branch.name.toLowerCase().includes(debouncedBranchSearch.toLowerCase())
); );
}, [branches, branchSearch]); }, [branches, debouncedBranchSearch]);
// 初始化加载 // 加载 Git 实例列表
useEffect(() => { useEffect(() => {
loadInstances(); const loadInstancesEffect = async () => {
}, []); try {
const data = await getGitInstances();
if (data && Array.isArray(data)) {
setInstances(data);
if (data.length > 0 && !selectedInstanceId) {
setSelectedInstanceId(data[0].id);
}
}
} catch (error) {
toast({
variant: 'destructive',
title: '加载失败',
description: '加载 Git 实例列表失败',
});
}
};
loadInstancesEffect();
}, [selectedInstanceId, toast]);
useEffect(() => { useEffect(() => {
if (selectedInstanceId) { if (selectedInstanceId) {
loadGroupTree(); loadGroupTree();
setSelectedGroup(undefined); setSelectedGroup(undefined);
@ -550,9 +579,9 @@ const GitManager: React.FC = () => {
setProjects([]); setProjects([]);
setBranches([]); setBranches([]);
} }
}, [selectedInstanceId]); }, [selectedInstanceId, loadGroupTree]);
const filteredGroupTree = useMemo(() => filterGroupTree(groupTree), [groupTree, groupSearch]); const filteredGroupTree = useMemo(() => filterGroupTree(groupTree), [groupTree, filterGroupTree]);
return ( return (
@ -610,19 +639,31 @@ const GitManager: React.FC = () => {
<CardHeader className="border-b flex-shrink-0"> <CardHeader className="border-b flex-shrink-0">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<CardTitle className="text-base"></CardTitle> <CardTitle className="text-base"></CardTitle>
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={loadGroupTree}
disabled={loading.groups || !selectedInstanceId}
>
<RefreshCw
className={`h-4 w-4 ${loading.groups ? 'animate-spin' : ''}`}
/>
</Button>
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
className="h-7 w-7"
className="h-7 w-7"
onClick={handleSyncGroups} onClick={handleSyncGroups}
disabled={syncing.groups || !selectedInstanceId} disabled={syncing.groups || !selectedInstanceId}
> >
<RefreshCw <Download
className={`h-4 w-4 ${syncing.groups ? 'animate-spin' : ''}`} className={`h-4 w-4 ${syncing.groups ? 'animate-bounce' : ''}`}
/> />
</Button> </Button>
</div> </div>
</div>
<div className="relative mt-2"> <div className="relative mt-2">
<Search className="absolute left-2 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" /> <Search className="absolute left-2 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
@ -634,7 +675,7 @@ const GitManager: React.FC = () => {
className="pl-8 h-8" className="pl-8 h-8"
/> />
</div> </div>
</CardHeader> </CardHeader>
<ScrollArea className="flex-1"> <ScrollArea className="flex-1">
@ -662,30 +703,40 @@ const GitManager: React.FC = () => {
<CardHeader className="border-b flex-shrink-0"> <CardHeader className="border-b flex-shrink-0">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<CardTitle className="text-base"> <CardTitle className="text-base">
{selectedGroup && `(${filteredProjects.length})`} ({filteredProjects.length}){selectedGroup && ` - ${selectedGroup.name}`}
</CardTitle> </CardTitle>
<div className="flex items-center gap-1">
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
className="h-7 w-7" className="h-7 w-7"
onClick={handleSyncProjects} onClick={() => loadProjects(selectedGroup?.repoGroupId || selectedGroup?.id)}
disabled={syncing.projects || !selectedInstanceId} disabled={loading.projects || !selectedInstanceId}
> >
<RefreshCw <RefreshCw
className={`h-4 w-4 ${syncing.projects ? 'animate-spin' : ''}`} className={`h-4 w-4 ${loading.projects ? 'animate-spin' : ''}`}
/> />
</Button>
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={handleSyncProjects}
disabled={syncing.projects || !selectedInstanceId}
>
<Download
className={`h-4 w-4 ${syncing.projects ? 'animate-bounce' : ''}`}
/>
</Button> </Button>
</div> </div>
</div>
<div className="relative mt-2"> <div className="relative mt-2">
<Search className="absolute left-2 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" /> <Search className="absolute left-2 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input <Input
placeholder="搜索项目..." placeholder="搜索项目..."
value={projectSearch} value={projectSearch}
onChange={(e) => setProjectSearch(e.target.value)} onChange={(e) => setProjectSearch(e.target.value)}
className="pl-8 h-8" className="pl-8 h-8"
/> />
</div> </div>
@ -695,18 +746,11 @@ const GitManager: React.FC = () => {
{loading.projects ? ( {loading.projects ? (
<div className="flex items-center justify-center h-full"> <div className="flex items-center justify-center h-full">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" /> <Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
) : !selectedGroup ? (
<div className="flex flex-col items-center justify-center h-full text-muted-foreground">
<Code2 className="h-12 w-12 mb-2 opacity-20" />
<p className="text-sm"></p>
</div> </div>
) : filteredProjects.length === 0 ? ( ) : filteredProjects.length === 0 ? (
<div className="flex flex-col items-center justify-center h-full text-muted-foreground"> <div className="flex flex-col items-center justify-center h-full text-muted-foreground">
<Code2 className="h-12 w-12 mb-2 opacity-20" /> <Code2 className="h-12 w-12 mb-2 opacity-20" />
<p className="text-sm"></p> <p className="text-sm">{selectedGroup ? '当前仓库组暂无项目' : '暂无项目'}</p>
</div> </div>
) : ( ) : (
@ -785,9 +829,9 @@ const GitManager: React.FC = () => {
)} )}
</div> </div>
</div> </div>
))} ))}
</div> </div>
)} )}
</div> </div>
</Card> </Card>
@ -796,20 +840,33 @@ const GitManager: React.FC = () => {
<CardHeader className="border-b flex-shrink-0"> <CardHeader className="border-b flex-shrink-0">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<CardTitle className="text-base"> <CardTitle className="text-base">
{selectedProject && `(${filteredBranches.length})`} ({filteredBranches.length}){selectedProject && ` - ${selectedProject.name}`}
</CardTitle> </CardTitle>
<Button <div className="flex items-center gap-1">
variant="ghost" <Button
size="icon" variant="ghost"
className="h-7 w-7" size="icon"
onClick={handleSyncBranches} className="h-7 w-7"
disabled={syncing.branches || !selectedGroup} onClick={() => loadBranches(selectedProject?.repoProjectId || selectedProject?.id)}
> disabled={loading.branches || !selectedInstanceId}
<RefreshCw >
className={`h-4 w-4 ${syncing.branches ? 'animate-spin' : ''}`} <RefreshCw
/> className={`h-4 w-4 ${loading.branches ? 'animate-spin' : ''}`}
</Button> />
</div> </Button>
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={handleSyncBranches}
disabled={syncing.branches || !selectedInstanceId}
>
<Download
className={`h-4 w-4 ${syncing.branches ? 'animate-bounce' : ''}`}
/>
</Button>
</div>
</div>
<div className="relative mt-2"> <div className="relative mt-2">
<Search className="absolute left-2 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" /> <Search className="absolute left-2 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input <Input
@ -824,18 +881,13 @@ const GitManager: React.FC = () => {
{loading.branches ? ( {loading.branches ? (
<div className="flex items-center justify-center h-full"> <div className="flex items-center justify-center h-full">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" /> <Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div> </div>
) : !selectedProject ? (
<div className="flex flex-col items-center justify-center h-full text-muted-foreground">
<GitBranch className="h-12 w-12 mb-2 opacity-20" />
<p className="text-sm"></p>
</div>
) : filteredBranches.length === 0 ? ( ) : filteredBranches.length === 0 ? (
<div className="flex flex-col items-center justify-center h-full text-muted-foreground"> <div className="flex flex-col items-center justify-center h-full text-muted-foreground">
<GitBranch className="h-12 w-12 mb-2 opacity-20" /> <GitBranch className="h-12 w-12 mb-2 opacity-20" />
<p className="text-sm"></p> <p className="text-sm">{selectedProject ? '当前项目暂无分支' : '暂无分支'}</p>
</div> </div>
) : ( ) : (
<div className="divide-y"> <div className="divide-y">
{filteredBranches.map((branch) => ( {filteredBranches.map((branch) => (
<div key={branch.id} className="p-3 hover:bg-accent transition-colors"> <div key={branch.id} className="p-3 hover:bg-accent transition-colors">
@ -894,7 +946,7 @@ const GitManager: React.FC = () => {
)} )}
</div> </div>
)} )}
</div> </div>
{branch.webUrl && ( {branch.webUrl && (
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>

View File

@ -1,18 +1,18 @@
import React, { useState, useEffect, useMemo } from 'react'; import React, { useState, useEffect, useMemo, useCallback, useRef } from 'react';
import { import {
Card, Card,
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 { Input } from '@/components/ui/input';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { import {
Select, Select,
SelectContent, SelectContent,
SelectItem, SelectItem,
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from '@/components/ui/select'; } from '@/components/ui/select';
import { ScrollArea } from '@/components/ui/scroll-area'; import { ScrollArea } from '@/components/ui/scroll-area';
import { useToast } from '@/components/ui/use-toast'; import { useToast } from '@/components/ui/use-toast';
@ -37,6 +37,7 @@ import {
AlertTriangle, AlertTriangle,
Calendar, Calendar,
Activity, Activity,
Download,
} from 'lucide-react'; } from 'lucide-react';
import type { import type {
JenkinsInstance, JenkinsInstance,
@ -53,6 +54,7 @@ import {
syncJenkinsJobs, syncJenkinsJobs,
syncJenkinsBuilds, syncJenkinsBuilds,
} from './service'; } from './service';
import { useVirtualizer } from '@tanstack/react-virtual';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import relativeTime from 'dayjs/plugin/relativeTime'; import relativeTime from 'dayjs/plugin/relativeTime';
import 'dayjs/locale/zh-cn'; import 'dayjs/locale/zh-cn';
@ -92,16 +94,16 @@ const JenkinsManager: React.FC = () => {
// 加载状态 // 加载状态
const [loading, setLoading] = useState({ const [loading, setLoading] = useState({
views: false, views: false,
jobs: false, jobs: false,
builds: false, builds: false,
}); });
// 同步状态 // 同步状态
const [syncing, setSyncing] = useState({ const [syncing, setSyncing] = useState({
views: false, views: false,
jobs: false, jobs: false,
builds: false, builds: false,
}); });
// 搜索状态 // 搜索状态
@ -109,28 +111,37 @@ const JenkinsManager: React.FC = () => {
const [jobSearch, setJobSearch] = useState(''); const [jobSearch, setJobSearch] = useState('');
const [buildSearch, setBuildSearch] = useState(''); const [buildSearch, setBuildSearch] = useState('');
// 加载 Jenkins 实例列表 // 防抖搜索值 - 优化大量数据过滤性能
const [debouncedViewSearch, setDebouncedViewSearch] = useState('');
const [debouncedJobSearch, setDebouncedJobSearch] = useState('');
const [debouncedBuildSearch, setDebouncedBuildSearch] = useState('');
// 视图搜索防抖
useEffect(() => { useEffect(() => {
const loadInstances = async () => { const timer = setTimeout(() => {
try { setDebouncedViewSearch(viewSearch);
const data = await getJenkinsInstances(); }, 300);
setInstances(data); return () => clearTimeout(timer);
if (data.length > 0 && !selectedInstanceId) { }, [viewSearch]);
setSelectedInstanceId(data[0].id);
} // 任务搜索防抖
} catch (error) { useEffect(() => {
toast({ const timer = setTimeout(() => {
variant: 'destructive', setDebouncedJobSearch(jobSearch);
title: '加载失败', }, 300);
description: '获取 Jenkins 实例列表失败', return () => clearTimeout(timer);
}); }, [jobSearch]);
}
}; // 构建搜索防抖特别重要因为有5000+条数据)
loadInstances(); useEffect(() => {
}, []); const timer = setTimeout(() => {
setDebouncedBuildSearch(buildSearch);
}, 300);
return () => clearTimeout(timer);
}, [buildSearch]);
// 加载视图列表 // 加载视图列表
const loadViews = async () => { const loadViews = useCallback(async () => {
if (!selectedInstanceId) return; if (!selectedInstanceId) return;
setLoading((prev) => ({ ...prev, views: true })); setLoading((prev) => ({ ...prev, views: true }));
try { try {
@ -145,10 +156,10 @@ const JenkinsManager: React.FC = () => {
} finally { } finally {
setLoading((prev) => ({ ...prev, views: false })); setLoading((prev) => ({ ...prev, views: false }));
} }
}; }, [selectedInstanceId, toast]);
// 加载任务列表 // 加载任务列表
const loadJobs = async (viewId?: number) => { const loadJobs = useCallback(async (viewId?: number) => {
if (!selectedInstanceId) return; if (!selectedInstanceId) return;
setLoading((prev) => ({ ...prev, jobs: true })); setLoading((prev) => ({ ...prev, jobs: true }));
try { try {
@ -157,19 +168,19 @@ const JenkinsManager: React.FC = () => {
viewId, viewId,
}); });
setJobs(data || []); setJobs(data || []);
} catch (error) { } catch (error) {
toast({ toast({
variant: 'destructive', variant: 'destructive',
title: '加载失败', title: '加载失败',
description: '获取任务列表失败', description: '获取任务列表失败',
}); });
} finally { } finally {
setLoading((prev) => ({ ...prev, jobs: false })); setLoading((prev) => ({ ...prev, jobs: false }));
} }
}; }, [selectedInstanceId, toast]);
// 加载构建列表 // 加载构建列表
const loadBuilds = async (jobId?: number) => { const loadBuilds = useCallback(async (jobId?: number) => {
if (!selectedInstanceId) return; if (!selectedInstanceId) return;
setLoading((prev) => ({ ...prev, builds: true })); setLoading((prev) => ({ ...prev, builds: true }));
try { try {
@ -187,59 +198,52 @@ const JenkinsManager: React.FC = () => {
} finally { } finally {
setLoading((prev) => ({ ...prev, builds: false })); setLoading((prev) => ({ ...prev, builds: false }));
} }
}; }, [selectedInstanceId, toast]);
// 监听实例变化 // 加载 Jenkins 实例列表
useEffect(() => { useEffect(() => {
if (selectedInstanceId) { const loadInstances = async () => {
setSelectedView(undefined); try {
setSelectedJob(undefined); const data = await getJenkinsInstances();
setJobs([]); setInstances(data);
setBuilds([]); if (data.length > 0 && !selectedInstanceId) {
loadViews(); setSelectedInstanceId(data[0].id);
} }
}, [selectedInstanceId]); } catch (error) {
toast({
// 监听视图选择变化 variant: 'destructive',
useEffect(() => { title: '加载失败',
if (selectedView) { description: '获取 Jenkins 实例列表失败',
setSelectedJob(undefined); });
setBuilds([]); }
loadJobs(selectedView.id); };
} loadInstances();
}, [selectedView]); }, [selectedInstanceId, toast]);
// 监听任务选择变化
useEffect(() => {
if (selectedJob) {
loadBuilds(selectedJob.id);
}
}, [selectedJob]);
// 同步视图 // 同步视图
const handleSyncViews = async () => { const handleSyncViews = useCallback(async () => {
if (!selectedInstanceId) return; if (!selectedInstanceId) return;
setSyncing((prev) => ({ ...prev, views: true })); setSyncing((prev) => ({ ...prev, views: true }));
try { try {
await syncJenkinsViews(selectedInstanceId); await syncJenkinsViews(selectedInstanceId);
toast({ toast({
title: '同步成功', title: '同步成功',
description: '视图同步任务已启动', description: '视图同步任务已启动',
}); });
setTimeout(() => loadViews(), 2000); setTimeout(() => loadViews(), 2000);
} catch (error) { } catch (error) {
toast({ toast({
variant: 'destructive', variant: 'destructive',
title: '同步失败', title: '同步失败',
description: '视图同步失败', description: '视图同步失败',
}); });
} finally { } finally {
setSyncing((prev) => ({ ...prev, views: false })); setSyncing((prev) => ({ ...prev, views: false }));
} }
}; }, [selectedInstanceId, toast, loadViews]);
// 同步任务 // 同步任务
const handleSyncJobs = async () => { const handleSyncJobs = useCallback(async () => {
if (!selectedInstanceId) return; if (!selectedInstanceId) return;
setSyncing((prev) => ({ ...prev, jobs: true })); setSyncing((prev) => ({ ...prev, jobs: true }));
try { try {
@ -247,28 +251,27 @@ const JenkinsManager: React.FC = () => {
externalSystemId: selectedInstanceId, externalSystemId: selectedInstanceId,
viewId: selectedView?.id, viewId: selectedView?.id,
}); });
toast({ toast({
title: '同步成功', title: '同步成功',
description: '任务同步任务已启动', description: selectedView ? '视图任务同步任务已启动' : '所有任务同步任务已启动',
}); });
setTimeout(() => { setTimeout(() => {
if (selectedView) { // 无论是否选择视图,都重新加载任务列表
loadJobs(selectedView.id); loadJobs(selectedView?.id);
}
}, 2000); }, 2000);
} catch (error) { } catch (error) {
toast({ toast({
variant: 'destructive', variant: 'destructive',
title: '同步失败', title: '同步失败',
description: '任务同步失败', description: '任务同步失败',
}); });
} finally { } finally {
setSyncing((prev) => ({ ...prev, jobs: false })); setSyncing((prev) => ({ ...prev, jobs: false }));
} }
}; }, [selectedInstanceId, selectedView, toast, loadJobs]);
// 同步构建 // 同步构建
const handleSyncBuilds = async () => { const handleSyncBuilds = useCallback(async () => {
if (!selectedInstanceId) return; if (!selectedInstanceId) return;
setSyncing((prev) => ({ ...prev, builds: true })); setSyncing((prev) => ({ ...prev, builds: true }));
try { try {
@ -276,64 +279,94 @@ const JenkinsManager: React.FC = () => {
externalSystemId: selectedInstanceId, externalSystemId: selectedInstanceId,
jobId: selectedJob?.id, jobId: selectedJob?.id,
}); });
toast({ toast({
title: '同步成功', title: '同步成功',
description: '构建同步任务已启动', description: selectedJob ? '任务构建同步任务已启动' : '所有构建同步任务已启动',
}); });
setTimeout(() => { setTimeout(() => {
if (selectedJob) { // 无论是否选择任务,都重新加载构建列表
loadBuilds(selectedJob.id); loadBuilds(selectedJob?.id);
}
}, 2000); }, 2000);
} catch (error) { } catch (error) {
toast({ toast({
variant: 'destructive', variant: 'destructive',
title: '同步失败', title: '同步失败',
description: '构建同步失败', description: '构建同步失败',
}); });
} finally { } finally {
setSyncing((prev) => ({ ...prev, builds: false })); setSyncing((prev) => ({ ...prev, builds: false }));
}
}, [selectedInstanceId, selectedJob, toast, loadBuilds]);
// 监听实例变化
useEffect(() => {
if (selectedInstanceId) {
setSelectedView(undefined);
setSelectedJob(undefined);
setJobs([]);
setBuilds([]);
loadViews();
} }
}; }, [selectedInstanceId, loadViews]);
// 过滤视图 // 监听视图选择变化
useEffect(() => {
if (selectedInstanceId) {
setSelectedJob(undefined);
setBuilds([]);
// 如果选择了视图传递viewId否则不传加载所有任务
loadJobs(selectedView?.id);
}
}, [selectedView, selectedInstanceId, loadJobs]);
// 监听任务选择变化
useEffect(() => {
if (selectedInstanceId) {
// 如果选择了任务传递jobId否则不传加载所有构建
loadBuilds(selectedJob?.id);
}
}, [selectedJob, selectedInstanceId, loadBuilds]);
// 过滤视图(使用防抖搜索值)
const filteredViews = useMemo(() => { const filteredViews = useMemo(() => {
if (!debouncedViewSearch) return views;
return views.filter((view) => return views.filter((view) =>
view.viewName.toLowerCase().includes(viewSearch.toLowerCase()) view.viewName.toLowerCase().includes(debouncedViewSearch.toLowerCase())
); );
}, [views, viewSearch]); }, [views, debouncedViewSearch]);
// 过滤任务 // 过滤任务(使用防抖搜索值)
const filteredJobs = useMemo(() => { const filteredJobs = useMemo(() => {
if (!debouncedJobSearch) return jobs;
return jobs.filter((job) => return jobs.filter((job) =>
job.jobName.toLowerCase().includes(jobSearch.toLowerCase()) job.jobName.toLowerCase().includes(debouncedJobSearch.toLowerCase())
); );
}, [jobs, jobSearch]); }, [jobs, debouncedJobSearch]);
// 过滤构建 // 过滤构建(使用防抖搜索值 - 特别重要5000+条数据)
const filteredBuilds = useMemo(() => { const filteredBuilds = useMemo(() => {
if (!buildSearch) return builds; if (!debouncedBuildSearch) return builds;
const searchLower = buildSearch.toLowerCase(); const searchLower = debouncedBuildSearch.toLowerCase();
return builds.filter((build) => return builds.filter((build) =>
build.buildNumber.toString().includes(searchLower) || build.buildNumber.toString().includes(searchLower) ||
build.buildStatus.toLowerCase().includes(searchLower) build.buildStatus.toLowerCase().includes(searchLower)
); );
}, [builds, buildSearch]); }, [builds, debouncedBuildSearch]);
// 获取构建状态徽章 // 获取构建状态徽章
const getBuildStatusBadge = (status: string) => { const getBuildStatusBadge = useCallback((status: string) => {
const config = BUILD_STATUS_CONFIG[status] || BUILD_STATUS_CONFIG.NOT_BUILT; const config = BUILD_STATUS_CONFIG[status] || BUILD_STATUS_CONFIG.NOT_BUILT;
const Icon = config.icon; const Icon = config.icon;
return ( return (
<Badge variant={config.variant} className="inline-flex items-center gap-1"> <Badge variant={config.variant} className="inline-flex items-center gap-1">
<Icon className="h-3 w-3" /> <Icon className="h-3 w-3" />
{config.label} {config.label}
</Badge> </Badge>
); );
}; }, []);
// 格式化时长 // 格式化时长
const formatDuration = (duration?: number) => { const formatDuration = useCallback((duration?: number) => {
if (!duration) return '-'; if (!duration) return '-';
const seconds = Math.floor(duration / 1000); const seconds = Math.floor(duration / 1000);
if (seconds < 60) return `${seconds}`; if (seconds < 60) return `${seconds}`;
@ -341,12 +374,21 @@ const JenkinsManager: React.FC = () => {
if (minutes < 60) return `${minutes}分钟`; if (minutes < 60) return `${minutes}分钟`;
const hours = Math.floor(minutes / 60); const hours = Math.floor(minutes / 60);
return `${hours}小时${minutes % 60}分钟`; return `${hours}小时${minutes % 60}分钟`;
}; }, []);
return ( // 构建列表虚拟滚动 - 用于处理大量数据5000+ 条)
const buildListRef = useRef<HTMLDivElement>(null);
const buildVirtualizer = useVirtualizer({
count: filteredBuilds.length,
getScrollElement: () => buildListRef.current,
estimateSize: () => 120, // 预估每个构建卡片的高度
overscan: 5, // 预渲染额外的项目数量
});
return (
<TooltipProvider> <TooltipProvider>
<div className="flex flex-col h-full p-6 gap-4"> <div className="flex flex-col h-full p-6 gap-4">
{/* 页面标题 */} {/* 页面标题 */}
<div className="flex items-center justify-between flex-shrink-0"> <div className="flex items-center justify-between flex-shrink-0">
<div> <div>
<h1 className="text-3xl font-bold text-foreground">Jenkins </h1> <h1 className="text-3xl font-bold text-foreground">Jenkins </h1>
@ -360,16 +402,16 @@ const JenkinsManager: React.FC = () => {
> >
<SelectTrigger className="w-[240px]"> <SelectTrigger className="w-[240px]">
<SelectValue placeholder="选择Jenkins实例" /> <SelectValue placeholder="选择Jenkins实例" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{instances.map((instance) => ( {instances.map((instance) => (
<SelectItem key={instance.id} value={instance.id.toString()}> <SelectItem key={instance.id} value={instance.id.toString()}>
{instance.name} {instance.name}
</SelectItem> </SelectItem>
))} ))}
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
{/* 三栏布局 */} {/* 三栏布局 */}
<div className="flex-1 grid grid-cols-12 gap-4 min-h-0 overflow-hidden"> <div className="flex-1 grid grid-cols-12 gap-4 min-h-0 overflow-hidden">
@ -381,18 +423,31 @@ const JenkinsManager: React.FC = () => {
<Layers className="h-4 w-4" /> <Layers className="h-4 w-4" />
({filteredViews.length}) ({filteredViews.length})
</CardTitle> </CardTitle>
<Button <div className="flex items-center gap-1">
variant="ghost" <Button
size="icon" variant="ghost"
className="h-7 w-7" size="icon"
onClick={handleSyncViews} className="h-7 w-7"
disabled={syncing.views || !selectedInstanceId} onClick={loadViews}
> disabled={loading.views || !selectedInstanceId}
<RefreshCw >
className={`h-4 w-4 ${syncing.views ? 'animate-spin' : ''}`} <RefreshCw
/> className={`h-4 w-4 ${loading.views ? 'animate-spin' : ''}`}
</Button> />
</div> </Button>
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={handleSyncViews}
disabled={syncing.views || !selectedInstanceId}
>
<Download
className={`h-4 w-4 ${syncing.views ? 'animate-bounce' : ''}`}
/>
</Button>
</div>
</div>
<div className="relative mt-2"> <div className="relative mt-2">
<Search className="absolute left-2 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" /> <Search className="absolute left-2 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input <Input
@ -401,8 +456,8 @@ const JenkinsManager: React.FC = () => {
onChange={(e) => setViewSearch(e.target.value)} onChange={(e) => setViewSearch(e.target.value)}
className="pl-8 h-8" className="pl-8 h-8"
/> />
</div> </div>
</CardHeader> </CardHeader>
<ScrollArea className="flex-1"> <ScrollArea className="flex-1">
<div className="p-2 space-y-1 min-h-[calc(100vh-280px)]"> <div className="p-2 space-y-1 min-h-[calc(100vh-280px)]">
{loading.views ? ( {loading.views ? (
@ -423,7 +478,7 @@ const JenkinsManager: React.FC = () => {
? 'bg-accent border-primary' ? 'bg-accent border-primary'
: '' : ''
}`} }`}
onClick={() => setSelectedView(view)} onClick={() => setSelectedView(selectedView?.id === view.id ? undefined : view)}
> >
<div className="flex items-center gap-2 mb-2"> <div className="flex items-center gap-2 mb-2">
<span className="flex-1 truncate font-medium"> <span className="flex-1 truncate font-medium">
@ -465,27 +520,40 @@ const JenkinsManager: React.FC = () => {
)} )}
</div> </div>
</ScrollArea> </ScrollArea>
</Card> </Card>
{/* 中栏:任务列表 */} {/* 中栏:任务列表 */}
<Card className="col-span-5 flex flex-col min-h-0"> <Card className="col-span-5 flex flex-col min-h-0">
<CardHeader className="border-b flex-shrink-0"> <CardHeader className="border-b flex-shrink-0">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<CardTitle className="text-base"> <CardTitle className="text-base">
{selectedView && `(${filteredJobs.length})`} ({filteredJobs.length}){selectedView && ` - ${selectedView.viewName}`}
</CardTitle> </CardTitle>
<Button <div className="flex items-center gap-1">
variant="ghost" <Button
size="icon" variant="ghost"
className="h-7 w-7" size="icon"
onClick={handleSyncJobs} className="h-7 w-7"
disabled={syncing.jobs || !selectedInstanceId} onClick={() => loadJobs(selectedView?.id)}
> disabled={loading.jobs || !selectedInstanceId}
<RefreshCw >
className={`h-4 w-4 ${syncing.jobs ? 'animate-spin' : ''}`} <RefreshCw
/> className={`h-4 w-4 ${loading.jobs ? 'animate-spin' : ''}`}
</Button> />
</div> </Button>
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={handleSyncJobs}
disabled={syncing.jobs || !selectedInstanceId}
>
<Download
className={`h-4 w-4 ${syncing.jobs ? 'animate-bounce' : ''}`}
/>
</Button>
</div>
</div>
<div className="relative mt-2"> <div className="relative mt-2">
<Search className="absolute left-2 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" /> <Search className="absolute left-2 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input <Input
@ -494,22 +562,17 @@ const JenkinsManager: React.FC = () => {
onChange={(e) => setJobSearch(e.target.value)} onChange={(e) => setJobSearch(e.target.value)}
className="pl-8 h-8" className="pl-8 h-8"
/> />
</div> </div>
</CardHeader> </CardHeader>
<div className="flex-1 overflow-auto"> <div className="flex-1 overflow-auto">
{loading.jobs ? ( {loading.jobs ? (
<div className="flex items-center justify-center h-full"> <div className="flex items-center justify-center h-full">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" /> <Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div> </div>
) : !selectedView ? (
<div className="flex flex-col items-center justify-center h-full text-muted-foreground">
<Box className="h-12 w-12 mb-2 opacity-20" />
<p className="text-sm"></p>
</div>
) : filteredJobs.length === 0 ? ( ) : filteredJobs.length === 0 ? (
<div className="flex flex-col items-center justify-center h-full text-muted-foreground"> <div className="flex flex-col items-center justify-center h-full text-muted-foreground">
<Box className="h-12 w-12 mb-2 opacity-20" /> <Box className="h-12 w-12 mb-2 opacity-20" />
<p className="text-sm"></p> <p className="text-sm">{selectedView ? '当前视图暂无任务' : '暂无任务'}</p>
</div> </div>
) : ( ) : (
<div className="p-2 space-y-2"> <div className="p-2 space-y-2">
@ -521,7 +584,7 @@ const JenkinsManager: React.FC = () => {
? 'bg-accent border-primary' ? 'bg-accent border-primary'
: '' : ''
}`} }`}
onClick={() => setSelectedJob(job)} onClick={() => setSelectedJob(selectedJob?.id === job.id ? undefined : job)}
> >
<div className="flex items-center gap-2 mb-2"> <div className="flex items-center gap-2 mb-2">
<span className="flex-1 truncate font-medium">{job.jobName}</span> <span className="flex-1 truncate font-medium">{job.jobName}</span>
@ -579,7 +642,7 @@ const JenkinsManager: React.FC = () => {
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
)} )}
</div> </div>
{job.lastBuildTime && ( {job.lastBuildTime && (
<div className="flex items-center gap-1 text-xs text-muted-foreground mt-2"> <div className="flex items-center gap-1 text-xs text-muted-foreground mt-2">
@ -588,114 +651,143 @@ const JenkinsManager: React.FC = () => {
</div> </div>
)} )}
</div> </div>
))} ))}
</div> </div>
)} )}
</div> </div>
</Card> </Card>
{/* 右栏:构建列表 */} {/* 右栏:构建列表 */}
<Card className="col-span-4 flex flex-col min-h-0"> <Card className="col-span-4 flex flex-col min-h-0">
<CardHeader className="border-b flex-shrink-0"> <CardHeader className="border-b flex-shrink-0">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<CardTitle className="text-base"> <CardTitle className="text-base">
{selectedJob && `(${filteredBuilds.length})`} ({filteredBuilds.length}){selectedJob && ` - ${selectedJob.jobName}`}
</CardTitle> </CardTitle>
<Button <div className="flex items-center gap-1">
variant="ghost" <Button
size="icon" variant="ghost"
className="h-7 w-7" size="icon"
onClick={handleSyncBuilds} className="h-7 w-7"
disabled={syncing.builds || !selectedInstanceId} onClick={() => loadBuilds(selectedJob?.id)}
> disabled={loading.builds || !selectedInstanceId}
<RefreshCw >
className={`h-4 w-4 ${syncing.builds ? 'animate-spin' : ''}`} <RefreshCw
/> className={`h-4 w-4 ${loading.builds ? 'animate-spin' : ''}`}
</Button> />
</div> </Button>
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={handleSyncBuilds}
disabled={syncing.builds || !selectedInstanceId}
>
<Download
className={`h-4 w-4 ${syncing.builds ? 'animate-bounce' : ''}`}
/>
</Button>
</div>
</div>
<div className="relative mt-2"> <div className="relative mt-2">
<Search className="absolute left-2 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" /> <Search className="absolute left-2 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input <Input
placeholder="搜索构建..." placeholder="搜索构建..."
value={buildSearch} value={buildSearch}
onChange={(e) => setBuildSearch(e.target.value)} onChange={(e) => setBuildSearch(e.target.value)}
className="pl-8 h-8" className="pl-8 h-8"
/> />
</div> </div>
</CardHeader> </CardHeader>
<div className="flex-1 overflow-auto"> <div
ref={buildListRef}
className="flex-1 overflow-auto"
>
{loading.builds ? ( {loading.builds ? (
<div className="flex items-center justify-center h-full"> <div className="flex items-center justify-center h-full">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" /> <Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div> </div>
) : !selectedJob ? (
<div className="flex flex-col items-center justify-center h-full text-muted-foreground">
<GitBranch className="h-12 w-12 mb-2 opacity-20" />
<p className="text-sm"></p>
</div>
) : filteredBuilds.length === 0 ? ( ) : filteredBuilds.length === 0 ? (
<div className="flex flex-col items-center justify-center h-full text-muted-foreground"> <div className="flex flex-col items-center justify-center h-full text-muted-foreground">
<GitBranch className="h-12 w-12 mb-2 opacity-20" /> <GitBranch className="h-12 w-12 mb-2 opacity-20" />
<p className="text-sm"></p> <p className="text-sm">{selectedJob ? '当前任务暂无构建' : '暂无构建'}</p>
</div> </div>
) : ( ) : (
<div className="divide-y"> <div
{filteredBuilds.map((build) => ( style={{
<div height: `${buildVirtualizer.getTotalSize()}px`,
key={build.id} width: '100%',
className="group p-3 hover:bg-accent transition-colors" position: 'relative',
> }}
<div className="flex items-center justify-between mb-2"> >
<div className="flex items-center gap-2"> {buildVirtualizer.getVirtualItems().map((virtualItem) => {
<span className="font-mono font-semibold text-sm"> const build = filteredBuilds[virtualItem.index];
#{build.buildNumber} return (
</span> <div
{getBuildStatusBadge(build.buildStatus)} key={build.id}
</div> data-index={virtualItem.index}
{build.buildUrl && ( ref={buildVirtualizer.measureElement}
<Tooltip> className="group p-3 hover:bg-accent transition-colors border-b"
<TooltipTrigger asChild> style={{
<a position: 'absolute',
href={build.buildUrl} top: 0,
target="_blank" left: 0,
rel="noopener noreferrer" width: '100%',
className="flex-shrink-0 opacity-0 group-hover:opacity-100 transition-opacity" transform: `translateY(${virtualItem.start}px)`,
> }}
<Button variant="ghost" size="icon" className="h-6 w-6"> >
<ExternalLink className="h-3 w-3" /> <div className="flex items-center justify-between mb-2">
</Button> <div className="flex items-center gap-2">
</a> <span className="font-mono font-semibold text-sm">
</TooltipTrigger> #{build.buildNumber}
<TooltipContent> </span>
<p> Jenkins </p> {getBuildStatusBadge(build.buildStatus)}
</TooltipContent> </div>
</Tooltip> {build.buildUrl && (
<Tooltip>
<TooltipTrigger asChild>
<a
href={build.buildUrl}
target="_blank"
rel="noopener noreferrer"
className="flex-shrink-0 opacity-0 group-hover:opacity-100 transition-opacity"
>
<Button variant="ghost" size="icon" className="h-6 w-6">
<ExternalLink className="h-3 w-3" />
</Button>
</a>
</TooltipTrigger>
<TooltipContent>
<p> Jenkins </p>
</TooltipContent>
</Tooltip>
)}
</div>
{build.starttime && (
<div className="flex items-center gap-1 text-xs text-muted-foreground mb-1">
<Calendar className="h-3 w-3" />
{dayjs(build.starttime).format('YYYY-MM-DD HH:mm:ss')}
</div>
)}
{build.duration !== undefined && (
<div className="flex items-center gap-1 text-xs text-muted-foreground mb-1">
<Clock className="h-3 w-3" />
: {formatDuration(build.duration)}
</div>
)} )}
</div> </div>
);
{build.starttime && ( })}
<div className="flex items-center gap-1 text-xs text-muted-foreground mb-1">
<Calendar className="h-3 w-3" />
{dayjs(build.starttime).format('YYYY-MM-DD HH:mm:ss')}
</div>
)}
{build.duration !== undefined && (
<div className="flex items-center gap-1 text-xs text-muted-foreground mb-1">
<Clock className="h-3 w-3" />
: {formatDuration(build.duration)}
</div>
)}
</div>
))}
</div> </div>
)} )}
</div> </div>
</Card> </Card>
</div> </div>
</div> </div>
</TooltipProvider> </TooltipProvider>
); );
}; };
export default JenkinsManager; export default JenkinsManager;

View File

@ -28,7 +28,7 @@ export const syncJenkinsViews = (externalSystemId: number) =>
// 获取任务列表 // 获取任务列表
export const getJenkinsJobs = (params: { externalSystemId: number; viewId?: number }) => export const getJenkinsJobs = (params: { externalSystemId: number; viewId?: number }) =>
request.get<JenkinsJobDTO[]>(`/api/v1/jenkins-job`, { params }); request.get<JenkinsJobDTO[]>(`/api/v1/jenkins-job/list`, { params });
// 同步任务 // 同步任务
export const syncJenkinsJobs = (params: { externalSystemId: number; viewId?: number }) => export const syncJenkinsJobs = (params: { externalSystemId: number; viewId?: number }) =>
@ -38,7 +38,7 @@ export const syncJenkinsJobs = (params: { externalSystemId: number; viewId?: num
// 获取构建列表 // 获取构建列表
export const getJenkinsBuilds = (params: { externalSystemId: number; jobId?: number }) => export const getJenkinsBuilds = (params: { externalSystemId: number; jobId?: number }) =>
request.get<JenkinsBuildDTO[]>(`/api/v1/jenkins-build`, { params }); request.get<JenkinsBuildDTO[]>(`/api/v1/jenkins-build/list`, { params });
// 同步构建根据jobId // 同步构建根据jobId
export const syncJenkinsBuilds = (params: { externalSystemId: number; jobId?: number }) => export const syncJenkinsBuilds = (params: { externalSystemId: number; jobId?: number }) =>