增加团队管理页面

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-bundle": "^7.4.0",
"@reduxjs/toolkit": "^2.0.1",
"@tanstack/react-virtual": "^3.13.12",
"@types/recharts": "^1.8.29",
"@types/uuid": "^10.0.0",
"@xyflow/react": "^12.8.6",

View File

@ -104,6 +104,9 @@ importers:
'@reduxjs/toolkit':
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)
'@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':
specifier: ^1.8.29
version: 1.8.29
@ -1950,6 +1953,15 @@ packages:
react: '>=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':
resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==}
@ -6167,6 +6179,14 @@ snapshots:
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':
dependencies:
'@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 {
Card,
CardHeader,
@ -43,6 +43,7 @@ import {
Calendar,
User,
GitCommit,
Download,
} from 'lucide-react';
import type {
RepositoryGroupResponse,
@ -116,34 +117,39 @@ const GitManager: React.FC = () => {
// 搜索过滤
const [groupSearch, setGroupSearch] = useState('');
const [projectSearch, setProjectSearch] = useState('');
const [branchSearch, setBranchSearch] = useState('');
// 防抖搜索值 - 优化大量数据过滤性能
const [debouncedGroupSearch, setDebouncedGroupSearch] = useState('');
const [debouncedProjectSearch, setDebouncedProjectSearch] = useState('');
const [debouncedBranchSearch, setDebouncedBranchSearch] = useState('');
// 加载 Git 实例列表
const loadInstances = async () => {
try {
// 仓库组搜索防抖
useEffect(() => {
const timer = setTimeout(() => {
setDebouncedGroupSearch(groupSearch);
}, 300);
return () => clearTimeout(timer);
}, [groupSearch]);
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 实例列表失败',
});
}
};
// 项目搜索防抖
useEffect(() => {
const timer = setTimeout(() => {
setDebouncedProjectSearch(projectSearch);
}, 300);
return () => clearTimeout(timer);
}, [projectSearch]);
// 分支搜索防抖
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
const map = new Map<number, RepositoryGroupResponse>();
const roots: RepositoryGroupResponse[] = [];
@ -178,10 +184,10 @@ const GitManager: React.FC = () => {
});
return roots;
};
}, []);
// 加载仓库组列表并构建树
const loadGroupTree = async () => {
const loadGroupTree = useCallback(async () => {
if (!selectedInstanceId) return;
setLoading((prev) => ({ ...prev, groups: true }));
@ -202,10 +208,10 @@ const GitManager: React.FC = () => {
} finally {
setLoading((prev) => ({ ...prev, groups: false }));
}
};
}, [selectedInstanceId, buildTree, toast]);
// 加载项目列表
const loadProjects = async (repoGroupId?: number) => {
// 加载项目列表支持不传repoGroupId加载所有项目
const loadProjects = useCallback(async (repoGroupId?: number) => {
if (!selectedInstanceId) return;
setLoading((prev) => ({ ...prev, projects: true }));
@ -225,10 +231,10 @@ const GitManager: React.FC = () => {
} finally {
setLoading((prev) => ({ ...prev, projects: false }));
}
};
}, [selectedInstanceId, toast]);
// 加载分支列表
const loadBranches = async (repoProjectId: number) => {
// 加载分支列表支持不传repoProjectId加载该组所有分支
const loadBranches = useCallback(async (repoProjectId?: number) => {
setLoading((prev) => ({ ...prev, branches: true }));
try {
const data = await getRepositoryBranches({
@ -237,7 +243,6 @@ const GitManager: React.FC = () => {
setBranches(data || []);
} catch (error) {
toast({
variant: 'destructive',
title: '加载失败',
description: '加载分支列表失败',
@ -246,11 +251,10 @@ const GitManager: React.FC = () => {
} finally {
setLoading((prev) => ({ ...prev, branches: false }));
}
};
}, [toast]);
// 同步所有数据(异步任务)
const handleSyncAll = async () => {
const handleSyncAll = useCallback(async () => {
if (!selectedInstanceId) return;
setSyncing((prev) => ({ ...prev, all: true }));
@ -269,11 +273,10 @@ const GitManager: React.FC = () => {
} finally {
setSyncing((prev) => ({ ...prev, all: false }));
}
};
}, [selectedInstanceId, toast]);
// 同步仓库组(异步任务)
const handleSyncGroups = async () => {
const handleSyncGroups = useCallback(async () => {
if (!selectedInstanceId) return;
setSyncing((prev) => ({ ...prev, groups: true }));
@ -292,23 +295,27 @@ const GitManager: React.FC = () => {
} finally {
setSyncing((prev) => ({ ...prev, groups: false }));
}
};
}, [selectedInstanceId, toast]);
// 同步项目(异步任务)
const handleSyncProjects = async () => {
if (!selectedInstanceId || !selectedGroup) return;
// 同步项目(异步任务,支持未选择组时同步所有项目)
const handleSyncProjects = useCallback(async () => {
if (!selectedInstanceId) return;
setSyncing((prev) => ({ ...prev, projects: true }));
try {
// 传递 repoGroupId (Git系统中的ID) 和 externalSystemId
await syncRepositoryProjects(
selectedInstanceId,
selectedGroup.repoGroupId
selectedGroup?.repoGroupId
);
const description = selectedGroup
? `正在后台同步仓库组"${selectedGroup.name}"的项目,请稍后手动刷新查看结果`
: '正在后台同步所有项目,请稍后手动刷新查看结果';
toast({
title: '同步任务已启动',
description: `正在后台同步仓库组"${selectedGroup.name}"的项目,请稍后手动刷新查看结果`,
description,
});
} catch (error) {
toast({
@ -319,12 +326,11 @@ const GitManager: React.FC = () => {
} finally {
setSyncing((prev) => ({ ...prev, projects: false }));
}
};
}, [selectedInstanceId, selectedGroup, toast]);
// 同步分支(异步任务)
const handleSyncBranches = async () => {
if (!selectedInstanceId || !selectedGroup) return;
const handleSyncBranches = useCallback(async () => {
if (!selectedInstanceId) return;
setSyncing((prev) => ({ ...prev, branches: true }));
try {
@ -332,12 +338,15 @@ const GitManager: React.FC = () => {
await syncRepositoryBranches(
selectedInstanceId,
selectedProject?.repoProjectId,
selectedGroup.repoGroupId
selectedGroup?.repoGroupId
);
const description = selectedProject
? `正在后台同步项目"${selectedProject.name}"的分支,请稍后手动刷新查看结果`
: `正在后台同步组"${selectedGroup.name}"下所有项目的分支,请稍后手动刷新查看结果`;
let description = '正在后台同步所有分支,请稍后手动刷新查看结果';
if (selectedProject) {
description = `正在后台同步项目"${selectedProject.name}"的分支,请稍后手动刷新查看结果`;
} else if (selectedGroup) {
description = `正在后台同步组"${selectedGroup.name}"下所有项目的分支,请稍后手动刷新查看结果`;
}
toast({
title: '同步任务已启动',
@ -352,10 +361,10 @@ const GitManager: React.FC = () => {
} finally {
setSyncing((prev) => ({ ...prev, branches: false }));
}
};
}, [selectedInstanceId, selectedGroup, selectedProject, toast]);
// 切换仓库组展开/折叠
const toggleGroupExpand = (groupId: number) => {
const toggleGroupExpand = useCallback((groupId: number) => {
setExpandedGroupIds((prev) => {
const next = new Set(prev);
if (next.has(groupId)) {
@ -365,37 +374,44 @@ const GitManager: React.FC = () => {
}
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) => {
setSelectedGroup(group);
setSelectedProject(undefined);
setBranches([]);
loadProjects(group.repoGroupId || group.id);
};
// 选中项目
const handleSelectProject = (project: RepositoryProjectResponse) => {
setSelectedProject(project);
loadBranches(project.repoProjectId || project.id);
};
// 选中项目(支持反选取消)
const handleSelectProject = useCallback((project: RepositoryProjectResponse) => {
// 如果点击的是已选中的项目,则取消选择
setSelectedProject((prev) => {
const newProject = prev?.id === project.id ? undefined : project;
// 加载分支列表,如果取消选择则不传 repoProjectId加载所有分支
loadBranches(newProject?.repoProjectId || newProject?.id);
return newProject;
});
}, [loadBranches]);
// 格式化时间
const formatTime = (time?: string) => {
const formatTime = useCallback((time?: string) => {
if (!time) return '-';
const diff = dayjs().diff(dayjs(time), 'day');
if (diff > 7) {
return dayjs(time).format('YYYY-MM-DD HH:mm');
}
return dayjs(time).fromNow();
};
}, []);
// 渲染可见性图标(带 Tooltip
const renderVisibilityIcon = (visibility: string) => {
const renderVisibilityIcon = useCallback((visibility: string) => {
const config =
VISIBILITY_CONFIG[visibility.toLowerCase() as keyof typeof VISIBILITY_CONFIG] ||
VISIBILITY_CONFIG.public;
@ -412,17 +428,16 @@ const GitManager: React.FC = () => {
</TooltipContent>
</Tooltip>
);
};
}, []);
// 过滤仓库组树
const filterGroupTree = (groups: RepositoryGroupResponse[]): RepositoryGroupResponse[] => {
if (!groupSearch) return groups;
// 过滤仓库组树(使用防抖搜索值)
const filterGroupTree = useCallback((groups: RepositoryGroupResponse[]): RepositoryGroupResponse[] => {
if (!debouncedGroupSearch) return groups;
return groups.filter((group) => {
const matchesSearch =
group.name.toLowerCase().includes(groupSearch.toLowerCase()) ||
(group.path && group.path.toLowerCase().includes(groupSearch.toLowerCase()));
group.name.toLowerCase().includes(debouncedGroupSearch.toLowerCase()) ||
(group.path && group.path.toLowerCase().includes(debouncedGroupSearch.toLowerCase()));
const filteredChildren = group.children
? filterGroupTree(group.children)
@ -433,7 +448,7 @@ const GitManager: React.FC = () => {
...group,
children: group.children ? filterGroupTree(group.children) : [],
}));
};
}, [debouncedGroupSearch]);
// 渲染仓库组树节点
const renderGroupTreeNode = (
@ -480,7 +495,7 @@ const GitManager: React.FC = () => {
{group.projectCount !== undefined && group.projectCount > 0 && (
<Badge variant="secondary" className="text-xs px-1.5 py-0 h-5">
{group.projectCount}
</Badge>
</Badge>
)}
{group.webUrl && (
@ -516,33 +531,47 @@ const GitManager: React.FC = () => {
);
};
// 过滤项目列表
// 过滤项目列表(使用防抖搜索值)
const filteredProjects = useMemo(() => {
if (!projectSearch) return projects;
if (!debouncedProjectSearch) return projects;
return projects.filter(
(project) =>
project.name.toLowerCase().includes(projectSearch.toLowerCase()) ||
(project.path && project.path.toLowerCase().includes(projectSearch.toLowerCase()))
project.name.toLowerCase().includes(debouncedProjectSearch.toLowerCase()) ||
(project.path && project.path.toLowerCase().includes(debouncedProjectSearch.toLowerCase()))
);
}, [projects, projectSearch]);
}, [projects, debouncedProjectSearch]);
// 过滤分支列表
// 过滤分支列表(使用防抖搜索值)
const filteredBranches = useMemo(() => {
if (!branchSearch) return branches;
if (!debouncedBranchSearch) return branches;
return branches.filter((branch) =>
branch.name.toLowerCase().includes(branchSearch.toLowerCase())
branch.name.toLowerCase().includes(debouncedBranchSearch.toLowerCase())
);
}, [branches, branchSearch]);
}, [branches, debouncedBranchSearch]);
// 初始化加载
// 加载 Git 实例列表
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(() => {
if (selectedInstanceId) {
loadGroupTree();
setSelectedGroup(undefined);
@ -550,9 +579,9 @@ const GitManager: React.FC = () => {
setProjects([]);
setBranches([]);
}
}, [selectedInstanceId]);
}, [selectedInstanceId, loadGroupTree]);
const filteredGroupTree = useMemo(() => filterGroupTree(groupTree), [groupTree, groupSearch]);
const filteredGroupTree = useMemo(() => filterGroupTree(groupTree), [groupTree, filterGroupTree]);
return (
@ -610,19 +639,31 @@ const GitManager: React.FC = () => {
<CardHeader className="border-b flex-shrink-0">
<div className="flex items-center justify-between">
<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
variant="ghost"
size="icon"
className="h-7 w-7"
className="h-7 w-7"
onClick={handleSyncGroups}
disabled={syncing.groups || !selectedInstanceId}
disabled={syncing.groups || !selectedInstanceId}
>
<RefreshCw
className={`h-4 w-4 ${syncing.groups ? 'animate-spin' : ''}`}
/>
<Download
className={`h-4 w-4 ${syncing.groups ? 'animate-bounce' : ''}`}
/>
</Button>
</div>
</div>
<div className="relative mt-2">
<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"
/>
</div>
</div>
</CardHeader>
<ScrollArea className="flex-1">
@ -662,30 +703,40 @@ const GitManager: React.FC = () => {
<CardHeader className="border-b flex-shrink-0">
<div className="flex items-center justify-between">
<CardTitle className="text-base">
{selectedGroup && `(${filteredProjects.length})`}
({filteredProjects.length}){selectedGroup && ` - ${selectedGroup.name}`}
</CardTitle>
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={handleSyncProjects}
disabled={syncing.projects || !selectedInstanceId}
>
<RefreshCw
className={`h-4 w-4 ${syncing.projects ? 'animate-spin' : ''}`}
/>
className="h-7 w-7"
onClick={() => loadProjects(selectedGroup?.repoGroupId || selectedGroup?.id)}
disabled={loading.projects || !selectedInstanceId}
>
<RefreshCw
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>
</div>
</div>
<div className="relative mt-2">
<Search className="absolute left-2 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-8 h-8"
/>
</div>
@ -695,18 +746,11 @@ const GitManager: React.FC = () => {
{loading.projects ? (
<div className="flex items-center justify-center h-full">
<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>
) : filteredProjects.length === 0 ? (
) : filteredProjects.length === 0 ? (
<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>
<p className="text-sm">{selectedGroup ? '当前仓库组暂无项目' : '暂无项目'}</p>
</div>
) : (
@ -785,9 +829,9 @@ const GitManager: React.FC = () => {
)}
</div>
</div>
))}
))}
</div>
)}
)}
</div>
</Card>
@ -796,20 +840,33 @@ const GitManager: React.FC = () => {
<CardHeader className="border-b flex-shrink-0">
<div className="flex items-center justify-between">
<CardTitle className="text-base">
{selectedProject && `(${filteredBranches.length})`}
({filteredBranches.length}){selectedProject && ` - ${selectedProject.name}`}
</CardTitle>
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={handleSyncBranches}
disabled={syncing.branches || !selectedGroup}
>
<RefreshCw
className={`h-4 w-4 ${syncing.branches ? 'animate-spin' : ''}`}
/>
</Button>
</div>
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={() => loadBranches(selectedProject?.repoProjectId || selectedProject?.id)}
disabled={loading.branches || !selectedInstanceId}
>
<RefreshCw
className={`h-4 w-4 ${loading.branches ? 'animate-spin' : ''}`}
/>
</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">
<Search className="absolute left-2 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
@ -824,18 +881,13 @@ const GitManager: React.FC = () => {
{loading.branches ? (
<div className="flex items-center justify-center h-full">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</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>
</div>
) : filteredBranches.length === 0 ? (
<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>
) : (
<p className="text-sm">{selectedProject ? '当前项目暂无分支' : '暂无分支'}</p>
</div>
) : (
<div className="divide-y">
{filteredBranches.map((branch) => (
<div key={branch.id} className="p-3 hover:bg-accent transition-colors">
@ -894,7 +946,7 @@ const GitManager: React.FC = () => {
)}
</div>
)}
</div>
</div>
{branch.webUrl && (
<Tooltip>
<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 {
Card,
CardHeader,
CardTitle,
Card,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Badge } from '@/components/ui/badge';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { ScrollArea } from '@/components/ui/scroll-area';
import { useToast } from '@/components/ui/use-toast';
@ -37,6 +37,7 @@ import {
AlertTriangle,
Calendar,
Activity,
Download,
} from 'lucide-react';
import type {
JenkinsInstance,
@ -53,6 +54,7 @@ import {
syncJenkinsJobs,
syncJenkinsBuilds,
} from './service';
import { useVirtualizer } from '@tanstack/react-virtual';
import dayjs from 'dayjs';
import relativeTime from 'dayjs/plugin/relativeTime';
import 'dayjs/locale/zh-cn';
@ -92,16 +94,16 @@ const JenkinsManager: React.FC = () => {
// 加载状态
const [loading, setLoading] = useState({
views: false,
jobs: false,
builds: false,
views: false,
jobs: false,
builds: false,
});
// 同步状态
const [syncing, setSyncing] = useState({
views: false,
jobs: false,
builds: false,
views: false,
jobs: false,
builds: false,
});
// 搜索状态
@ -109,28 +111,37 @@ const JenkinsManager: React.FC = () => {
const [jobSearch, setJobSearch] = useState('');
const [buildSearch, setBuildSearch] = useState('');
// 加载 Jenkins 实例列表
// 防抖搜索值 - 优化大量数据过滤性能
const [debouncedViewSearch, setDebouncedViewSearch] = useState('');
const [debouncedJobSearch, setDebouncedJobSearch] = useState('');
const [debouncedBuildSearch, setDebouncedBuildSearch] = useState('');
// 视图搜索防抖
useEffect(() => {
const loadInstances = async () => {
try {
const data = await getJenkinsInstances();
setInstances(data);
if (data.length > 0 && !selectedInstanceId) {
setSelectedInstanceId(data[0].id);
}
} catch (error) {
toast({
variant: 'destructive',
title: '加载失败',
description: '获取 Jenkins 实例列表失败',
});
}
};
loadInstances();
}, []);
const timer = setTimeout(() => {
setDebouncedViewSearch(viewSearch);
}, 300);
return () => clearTimeout(timer);
}, [viewSearch]);
// 任务搜索防抖
useEffect(() => {
const timer = setTimeout(() => {
setDebouncedJobSearch(jobSearch);
}, 300);
return () => clearTimeout(timer);
}, [jobSearch]);
// 构建搜索防抖特别重要因为有5000+条数据)
useEffect(() => {
const timer = setTimeout(() => {
setDebouncedBuildSearch(buildSearch);
}, 300);
return () => clearTimeout(timer);
}, [buildSearch]);
// 加载视图列表
const loadViews = async () => {
const loadViews = useCallback(async () => {
if (!selectedInstanceId) return;
setLoading((prev) => ({ ...prev, views: true }));
try {
@ -145,10 +156,10 @@ const JenkinsManager: React.FC = () => {
} finally {
setLoading((prev) => ({ ...prev, views: false }));
}
};
}, [selectedInstanceId, toast]);
// 加载任务列表
const loadJobs = async (viewId?: number) => {
const loadJobs = useCallback(async (viewId?: number) => {
if (!selectedInstanceId) return;
setLoading((prev) => ({ ...prev, jobs: true }));
try {
@ -157,19 +168,19 @@ const JenkinsManager: React.FC = () => {
viewId,
});
setJobs(data || []);
} catch (error) {
toast({
variant: 'destructive',
} catch (error) {
toast({
variant: 'destructive',
title: '加载失败',
description: '获取任务列表失败',
});
} finally {
});
} finally {
setLoading((prev) => ({ ...prev, jobs: false }));
}
};
}, [selectedInstanceId, toast]);
// 加载构建列表
const loadBuilds = async (jobId?: number) => {
const loadBuilds = useCallback(async (jobId?: number) => {
if (!selectedInstanceId) return;
setLoading((prev) => ({ ...prev, builds: true }));
try {
@ -187,59 +198,52 @@ const JenkinsManager: React.FC = () => {
} finally {
setLoading((prev) => ({ ...prev, builds: false }));
}
};
}, [selectedInstanceId, toast]);
// 监听实例变化
// 加载 Jenkins 实例列表
useEffect(() => {
if (selectedInstanceId) {
setSelectedView(undefined);
setSelectedJob(undefined);
setJobs([]);
setBuilds([]);
loadViews();
}
}, [selectedInstanceId]);
// 监听视图选择变化
useEffect(() => {
if (selectedView) {
setSelectedJob(undefined);
setBuilds([]);
loadJobs(selectedView.id);
}
}, [selectedView]);
// 监听任务选择变化
useEffect(() => {
if (selectedJob) {
loadBuilds(selectedJob.id);
}
}, [selectedJob]);
const loadInstances = async () => {
try {
const data = await getJenkinsInstances();
setInstances(data);
if (data.length > 0 && !selectedInstanceId) {
setSelectedInstanceId(data[0].id);
}
} catch (error) {
toast({
variant: 'destructive',
title: '加载失败',
description: '获取 Jenkins 实例列表失败',
});
}
};
loadInstances();
}, [selectedInstanceId, toast]);
// 同步视图
const handleSyncViews = async () => {
const handleSyncViews = useCallback(async () => {
if (!selectedInstanceId) return;
setSyncing((prev) => ({ ...prev, views: true }));
try {
await syncJenkinsViews(selectedInstanceId);
toast({
title: '同步成功',
toast({
title: '同步成功',
description: '视图同步任务已启动',
});
});
setTimeout(() => loadViews(), 2000);
} catch (error) {
toast({
variant: 'destructive',
title: '同步失败',
} catch (error) {
toast({
variant: 'destructive',
title: '同步失败',
description: '视图同步失败',
});
} finally {
});
} finally {
setSyncing((prev) => ({ ...prev, views: false }));
}
};
}, [selectedInstanceId, toast, loadViews]);
// 同步任务
const handleSyncJobs = async () => {
const handleSyncJobs = useCallback(async () => {
if (!selectedInstanceId) return;
setSyncing((prev) => ({ ...prev, jobs: true }));
try {
@ -247,28 +251,27 @@ const JenkinsManager: React.FC = () => {
externalSystemId: selectedInstanceId,
viewId: selectedView?.id,
});
toast({
title: '同步成功',
description: '任务同步任务已启动',
});
toast({
title: '同步成功',
description: selectedView ? '视图任务同步任务已启动' : '所有任务同步任务已启动',
});
setTimeout(() => {
if (selectedView) {
loadJobs(selectedView.id);
}
// 无论是否选择视图,都重新加载任务列表
loadJobs(selectedView?.id);
}, 2000);
} catch (error) {
toast({
variant: 'destructive',
title: '同步失败',
} catch (error) {
toast({
variant: 'destructive',
title: '同步失败',
description: '任务同步失败',
});
} finally {
});
} finally {
setSyncing((prev) => ({ ...prev, jobs: false }));
}
};
}, [selectedInstanceId, selectedView, toast, loadJobs]);
// 同步构建
const handleSyncBuilds = async () => {
const handleSyncBuilds = useCallback(async () => {
if (!selectedInstanceId) return;
setSyncing((prev) => ({ ...prev, builds: true }));
try {
@ -276,64 +279,94 @@ const JenkinsManager: React.FC = () => {
externalSystemId: selectedInstanceId,
jobId: selectedJob?.id,
});
toast({
toast({
title: '同步成功',
description: '构建同步任务已启动',
});
description: selectedJob ? '任务构建同步任务已启动' : '所有构建同步任务已启动',
});
setTimeout(() => {
if (selectedJob) {
loadBuilds(selectedJob.id);
}
// 无论是否选择任务,都重新加载构建列表
loadBuilds(selectedJob?.id);
}, 2000);
} catch (error) {
toast({
variant: 'destructive',
title: '同步失败',
} catch (error) {
toast({
variant: 'destructive',
title: '同步失败',
description: '构建同步失败',
});
} finally {
});
} finally {
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(() => {
if (!debouncedViewSearch) return views;
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) =>
job.jobName.toLowerCase().includes(jobSearch.toLowerCase())
job.jobName.toLowerCase().includes(debouncedJobSearch.toLowerCase())
);
}, [jobs, jobSearch]);
}, [jobs, debouncedJobSearch]);
// 过滤构建
// 过滤构建(使用防抖搜索值 - 特别重要5000+条数据)
const filteredBuilds = useMemo(() => {
if (!buildSearch) return builds;
const searchLower = buildSearch.toLowerCase();
if (!debouncedBuildSearch) return builds;
const searchLower = debouncedBuildSearch.toLowerCase();
return builds.filter((build) =>
build.buildNumber.toString().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 Icon = config.icon;
return (
return (
<Badge variant={config.variant} className="inline-flex items-center gap-1">
<Icon className="h-3 w-3" />
{config.label}
</Badge>
);
};
}, []);
// 格式化时长
const formatDuration = (duration?: number) => {
const formatDuration = useCallback((duration?: number) => {
if (!duration) return '-';
const seconds = Math.floor(duration / 1000);
if (seconds < 60) return `${seconds}`;
@ -341,12 +374,21 @@ const JenkinsManager: React.FC = () => {
if (minutes < 60) return `${minutes}分钟`;
const hours = Math.floor(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>
<div className="flex flex-col h-full p-6 gap-4">
{/* 页面标题 */}
{/* 页面标题 */}
<div className="flex items-center justify-between flex-shrink-0">
<div>
<h1 className="text-3xl font-bold text-foreground">Jenkins </h1>
@ -360,16 +402,16 @@ const JenkinsManager: React.FC = () => {
>
<SelectTrigger className="w-[240px]">
<SelectValue placeholder="选择Jenkins实例" />
</SelectTrigger>
<SelectContent>
</SelectTrigger>
<SelectContent>
{instances.map((instance) => (
<SelectItem key={instance.id} value={instance.id.toString()}>
{instance.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* 三栏布局 */}
<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" />
({filteredViews.length})
</CardTitle>
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={handleSyncViews}
disabled={syncing.views || !selectedInstanceId}
>
<RefreshCw
className={`h-4 w-4 ${syncing.views ? 'animate-spin' : ''}`}
/>
</Button>
</div>
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={loadViews}
disabled={loading.views || !selectedInstanceId}
>
<RefreshCw
className={`h-4 w-4 ${loading.views ? 'animate-spin' : ''}`}
/>
</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">
<Search className="absolute left-2 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
@ -401,8 +456,8 @@ const JenkinsManager: React.FC = () => {
onChange={(e) => setViewSearch(e.target.value)}
className="pl-8 h-8"
/>
</div>
</CardHeader>
</div>
</CardHeader>
<ScrollArea className="flex-1">
<div className="p-2 space-y-1 min-h-[calc(100vh-280px)]">
{loading.views ? (
@ -423,7 +478,7 @@ const JenkinsManager: React.FC = () => {
? 'bg-accent border-primary'
: ''
}`}
onClick={() => setSelectedView(view)}
onClick={() => setSelectedView(selectedView?.id === view.id ? undefined : view)}
>
<div className="flex items-center gap-2 mb-2">
<span className="flex-1 truncate font-medium">
@ -465,27 +520,40 @@ const JenkinsManager: React.FC = () => {
)}
</div>
</ScrollArea>
</Card>
</Card>
{/* 中栏:任务列表 */}
<Card className="col-span-5 flex flex-col min-h-0">
<CardHeader className="border-b flex-shrink-0">
<div className="flex items-center justify-between">
<CardTitle className="text-base">
{selectedView && `(${filteredJobs.length})`}
({filteredJobs.length}){selectedView && ` - ${selectedView.viewName}`}
</CardTitle>
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={handleSyncJobs}
disabled={syncing.jobs || !selectedInstanceId}
>
<RefreshCw
className={`h-4 w-4 ${syncing.jobs ? 'animate-spin' : ''}`}
/>
</Button>
</div>
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={() => loadJobs(selectedView?.id)}
disabled={loading.jobs || !selectedInstanceId}
>
<RefreshCw
className={`h-4 w-4 ${loading.jobs ? 'animate-spin' : ''}`}
/>
</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">
<Search className="absolute left-2 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
@ -494,22 +562,17 @@ const JenkinsManager: React.FC = () => {
onChange={(e) => setJobSearch(e.target.value)}
className="pl-8 h-8"
/>
</div>
</CardHeader>
</div>
</CardHeader>
<div className="flex-1 overflow-auto">
{loading.jobs ? (
<div className="flex items-center justify-center h-full">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</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 ? (
<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>
<p className="text-sm">{selectedView ? '当前视图暂无任务' : '暂无任务'}</p>
</div>
) : (
<div className="p-2 space-y-2">
@ -521,7 +584,7 @@ const JenkinsManager: React.FC = () => {
? 'bg-accent border-primary'
: ''
}`}
onClick={() => setSelectedJob(job)}
onClick={() => setSelectedJob(selectedJob?.id === job.id ? undefined : job)}
>
<div className="flex items-center gap-2 mb-2">
<span className="flex-1 truncate font-medium">{job.jobName}</span>
@ -579,7 +642,7 @@ const JenkinsManager: React.FC = () => {
</TooltipContent>
</Tooltip>
)}
</div>
</div>
{job.lastBuildTime && (
<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>
</Card>
</Card>
{/* 右栏:构建列表 */}
<Card className="col-span-4 flex flex-col min-h-0">
<CardHeader className="border-b flex-shrink-0">
<div className="flex items-center justify-between">
<CardTitle className="text-base">
{selectedJob && `(${filteredBuilds.length})`}
({filteredBuilds.length}){selectedJob && ` - ${selectedJob.jobName}`}
</CardTitle>
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={handleSyncBuilds}
disabled={syncing.builds || !selectedInstanceId}
>
<RefreshCw
className={`h-4 w-4 ${syncing.builds ? 'animate-spin' : ''}`}
/>
</Button>
</div>
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={() => loadBuilds(selectedJob?.id)}
disabled={loading.builds || !selectedInstanceId}
>
<RefreshCw
className={`h-4 w-4 ${loading.builds ? 'animate-spin' : ''}`}
/>
</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">
<Search className="absolute left-2 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
<Input
placeholder="搜索构建..."
value={buildSearch}
onChange={(e) => setBuildSearch(e.target.value)}
className="pl-8 h-8"
/>
</div>
</CardHeader>
<div className="flex-1 overflow-auto">
/>
</div>
</CardHeader>
<div
ref={buildListRef}
className="flex-1 overflow-auto"
>
{loading.builds ? (
<div className="flex items-center justify-center h-full">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</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 ? (
<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>
<p className="text-sm">{selectedJob ? '当前任务暂无构建' : '暂无构建'}</p>
</div>
) : (
<div className="divide-y">
{filteredBuilds.map((build) => (
<div
key={build.id}
className="group p-3 hover:bg-accent transition-colors"
>
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<span className="font-mono font-semibold text-sm">
#{build.buildNumber}
</span>
{getBuildStatusBadge(build.buildStatus)}
</div>
{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
style={{
height: `${buildVirtualizer.getTotalSize()}px`,
width: '100%',
position: 'relative',
}}
>
{buildVirtualizer.getVirtualItems().map((virtualItem) => {
const build = filteredBuilds[virtualItem.index];
return (
<div
key={build.id}
data-index={virtualItem.index}
ref={buildVirtualizer.measureElement}
className="group p-3 hover:bg-accent transition-colors border-b"
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
transform: `translateY(${virtualItem.start}px)`,
}}
>
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<span className="font-mono font-semibold text-sm">
#{build.buildNumber}
</span>
{getBuildStatusBadge(build.buildStatus)}
</div>
{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>
{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>
</Card>
</div>
</Card>
</div>
</div>
</TooltipProvider>
);
);
};
export default JenkinsManager;

View File

@ -28,7 +28,7 @@ export const syncJenkinsViews = (externalSystemId: 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 }) =>
@ -38,7 +38,7 @@ export const syncJenkinsJobs = (params: { externalSystemId: number; viewId?: num
// 获取构建列表
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
export const syncJenkinsBuilds = (params: { externalSystemId: number; jobId?: number }) =>