增加团队管理页面

This commit is contained in:
dengqichen 2025-10-30 13:38:18 +08:00
parent 7fa9b08e3d
commit c24f825696
7 changed files with 730 additions and 436 deletions

View File

@ -60,6 +60,7 @@
"react-diff-viewer-continued": "^3.4.0",
"react-dom": "^18.2.0",
"react-hook-form": "^7.54.2",
"react-infinite-scroll-component": "^6.1.0",
"react-redux": "^9.0.4",
"react-router-dom": "^6.21.0",
"reactflow": "^11.11.4",
@ -74,6 +75,7 @@
"@types/node": "^20.17.10",
"@types/react": "^18.3.18",
"@types/react-dom": "^18.3.5",
"@types/react-infinite-scroll-component": "^5.0.0",
"@typescript-eslint/eslint-plugin": "^6.14.0",
"@typescript-eslint/parser": "^6.14.0",
"@vitejs/plugin-react": "^4.2.1",

View File

@ -158,6 +158,9 @@ importers:
react-hook-form:
specifier: ^7.54.2
version: 7.54.2(react@18.3.1)
react-infinite-scroll-component:
specifier: ^6.1.0
version: 6.1.0(react@18.3.1)
react-redux:
specifier: ^9.0.4
version: 9.2.0(@types/react@18.3.18)(react@18.3.1)(redux@5.0.1)
@ -195,6 +198,9 @@ importers:
'@types/react-dom':
specifier: ^18.3.5
version: 18.3.5(@types/react@18.3.18)
'@types/react-infinite-scroll-component':
specifier: ^5.0.0
version: 5.0.0(react@18.3.1)
'@typescript-eslint/eslint-plugin':
specifier: ^6.14.0
version: 6.21.0(@typescript-eslint/parser@6.21.0(eslint@8.57.1)(typescript@5.7.2))(eslint@8.57.1)(typescript@5.7.2)
@ -2114,6 +2120,10 @@ packages:
peerDependencies:
'@types/react': ^18.0.0
'@types/react-infinite-scroll-component@5.0.0':
resolution: {integrity: sha512-1DrmAOFfiy6nchA/KiXCAEzXxG4VHsEGJGlRyMZYy8A9WGfr0fAVe+MXep+VkjIPnNPf06qDbJGSNiPzt67wJg==}
deprecated: This is a stub types definition. react-infinite-scroll-component provides its own type definitions, so you do not need this installed.
'@types/react-window@1.8.8':
resolution: {integrity: sha512-8Ls660bHR1AUA2kuRvVG9D/4XpRC6wjAaPT9dil7Ckc76eP9TKWZwwmgfq8Q1LANX3QNDnoU4Zp48A3w+zK69Q==}
@ -3658,6 +3668,11 @@ packages:
react-native:
optional: true
react-infinite-scroll-component@6.1.0:
resolution: {integrity: sha512-SQu5nCqy8DxQWpnUVLx7V7b7LcA37aM7tvoWjTLZp1dk6EJibM5/4EJKzOnl07/BsM1Y40sKLuqjCwwH/xV0TQ==}
peerDependencies:
react: '>=16.0.0'
react-is@16.13.1:
resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==}
@ -4035,6 +4050,10 @@ packages:
thenify@3.3.1:
resolution: {integrity: sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==}
throttle-debounce@2.3.0:
resolution: {integrity: sha512-H7oLPV0P7+jgvrk+6mwwwBDmxTaxnu9HMXmloNLXwnNO0ZxZ31Orah2n8lU1eMPvsaowP2CX+USCgyovXfdOFQ==}
engines: {node: '>=8'}
throttle-debounce@5.0.2:
resolution: {integrity: sha512-B71/4oyj61iNH0KeCamLuE2rmKuTO5byTOSVwECM5FA7TiAiAW+UqTKZ9ERueC4qvgSttUhdmq1mXC3kJqGX7A==}
engines: {node: '>=12.22'}
@ -6366,6 +6385,12 @@ snapshots:
dependencies:
'@types/react': 18.3.18
'@types/react-infinite-scroll-component@5.0.0(react@18.3.1)':
dependencies:
react-infinite-scroll-component: 6.1.0(react@18.3.1)
transitivePeerDependencies:
- react
'@types/react-window@1.8.8':
dependencies:
'@types/react': 18.3.18
@ -8122,6 +8147,11 @@ snapshots:
optionalDependencies:
react-dom: 18.3.1(react@18.3.1)
react-infinite-scroll-component@6.1.0(react@18.3.1):
dependencies:
react: 18.3.1
throttle-debounce: 2.3.0
react-is@16.13.1: {}
react-is@18.3.1: {}
@ -8552,6 +8582,8 @@ snapshots:
dependencies:
any-promise: 1.3.0
throttle-debounce@2.3.0: {}
throttle-debounce@5.0.2: {}
tiny-invariant@1.3.3: {}

View File

@ -0,0 +1,212 @@
import React, { useState, useCallback, useEffect, useRef } from 'react';
import InfiniteScroll from 'react-infinite-scroll-component';
import { Loader2 } from 'lucide-react';
import type { Page } from '@/types/base';
interface InfiniteScrollListProps<T> {
/**
*
* @param pageNum - 0
* @param size -
* @param extraParams -
* @returns
*/
loadData: (pageNum: number, size: number, extraParams?: any) => Promise<Page<T> | undefined>;
/**
*
*/
renderItem: (item: T, index: number) => React.ReactNode;
/**
* 20
*/
pageSize?: number;
/**
*
*/
extraParams?: any;
/**
* ID
*/
scrollableTarget: string;
/**
*
*/
className?: string;
/**
*
*/
listClassName?: string;
/**
*
*/
emptyText?: string;
/**
*
*/
emptyIcon?: React.ReactNode;
/**
*
*/
endMessage?: string;
/**
* loading
*/
showInitialLoading?: boolean;
/**
*
*/
resetTrigger?: any;
/**
*
*/
onDataChange?: (data: T[], total: number) => void;
}
export function InfiniteScrollList<T>({
loadData,
renderItem,
pageSize = 20,
extraParams,
scrollableTarget,
className = '',
listClassName = 'p-2 space-y-2',
emptyText = '暂无数据',
emptyIcon,
endMessage = '已加载全部数据',
showInitialLoading = true,
resetTrigger,
onDataChange,
}: InfiniteScrollListProps<T>) {
const [data, setData] = useState<T[]>([]);
const [page, setPage] = useState(0);
const [hasMore, setHasMore] = useState(true);
const [loading, setLoading] = useState(false);
const [initialLoading, setInitialLoading] = useState(showInitialLoading);
// 使用 ref 跟踪是否正在加载,避免重复请求
const loadingRef = useRef(false);
// 加载数据
const fetchData = useCallback(async (pageNum: number, reset: boolean = false) => {
if (loadingRef.current) return;
loadingRef.current = true;
setLoading(true);
try {
const response = await loadData(pageNum, pageSize, extraParams);
if (response) {
const newData = response.content || [];
if (reset) {
setData(newData);
} else {
setData(prev => [...prev, ...newData]);
}
setPage(pageNum);
setHasMore(!response.last);
// 触发数据变化回调
if (onDataChange) {
const totalData = reset ? newData : [...data, ...newData];
onDataChange(totalData, response.totalElements || 0);
}
}
} catch (error) {
console.error('Failed to load data:', error);
} finally {
setLoading(false);
setInitialLoading(false);
loadingRef.current = false;
}
}, [loadData, pageSize, extraParams, data, onDataChange]);
// 加载更多
const loadMore = useCallback(() => {
if (!loading && hasMore) {
fetchData(page + 1, false);
}
}, [loading, hasMore, page, fetchData]);
// 重置并重新加载
const reset = useCallback(() => {
setData([]);
setPage(0);
setHasMore(true);
setInitialLoading(showInitialLoading);
fetchData(0, true);
}, [fetchData, showInitialLoading]);
// 初始加载
useEffect(() => {
reset();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [resetTrigger]);
// 初始 loading 状态
if (initialLoading && data.length === 0) {
return (
<div className={`flex items-center justify-center h-full ${className}`}>
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
);
}
// 空状态
if (!loading && data.length === 0) {
return (
<div className={`flex flex-col items-center justify-center h-full text-muted-foreground ${className}`}>
{emptyIcon || <div className="h-12 w-12 mb-2 opacity-20" />}
<p className="text-sm">{emptyText}</p>
</div>
);
}
// 列表渲染
return (
<InfiniteScroll
dataLength={data.length}
next={loadMore}
hasMore={hasMore}
loader={
<div className="flex items-center justify-center py-4 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin mr-2" />
...
</div>
}
endMessage={
data.length > 0 ? (
<div className="flex items-center justify-center py-4 text-sm text-muted-foreground">
{endMessage}
</div>
) : null
}
scrollableTarget={scrollableTarget}
className={listClassName}
>
{data.map((item, index) => (
<React.Fragment key={index}>
{renderItem(item, index)}
</React.Fragment>
))}
</InfiniteScroll>
);
}
// 导出类型供外部使用
export type { InfiniteScrollListProps };

View File

@ -1,4 +1,5 @@
import React, { useState, useEffect, useMemo, useCallback } from 'react';
import React, { useState, useEffect, useMemo, useCallback, useRef } from 'react';
import { InfiniteScrollList } from '@/components/ui/infinite-scroll-list';
import {
Card,
CardHeader,
@ -87,10 +88,8 @@ const GitManager: React.FC = () => {
const [instances, setInstances] = useState<ExternalSystemResponse[]>([]);
const [selectedInstanceId, setSelectedInstanceId] = useState<number>();
// 数据状态
// 数据状态 - 项目和分支由 InfiniteScrollList 管理
const [groupTree, setGroupTree] = useState<RepositoryGroupResponse[]>([]);
const [projects, setProjects] = useState<RepositoryProjectResponse[]>([]);
const [branches, setBranches] = useState<RepositoryBranchResponse[]>([]);
// 选中状态
const [selectedGroup, setSelectedGroup] = useState<RepositoryGroupResponse>();
@ -113,15 +112,16 @@ 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('');
// 使用 ref 跟踪初始化状态,避免重复请求
const isInitializedRef = useRef(false);
const groupsLoadedRef = useRef(false);
const projectsLoadedRef = useRef(false);
// 仓库组搜索防抖
useEffect(() => {
@ -131,22 +131,6 @@ const GitManager: React.FC = () => {
return () => clearTimeout(timer);
}, [groupSearch]);
// 项目搜索防抖
useEffect(() => {
const timer = setTimeout(() => {
setDebouncedProjectSearch(projectSearch);
}, 300);
return () => clearTimeout(timer);
}, [projectSearch]);
// 分支搜索防抖
useEffect(() => {
const timer = setTimeout(() => {
setDebouncedBranchSearch(branchSearch);
}, 300);
return () => clearTimeout(timer);
}, [branchSearch]);
// 构建树形结构
const buildTree = useCallback((groups: RepositoryGroupResponse[]): RepositoryGroupResponse[] => {
// 使用 repoGroupId 作为 key因为 parentId 对应的是 Git 系统中的 repoGroupId
@ -209,48 +193,8 @@ const GitManager: React.FC = () => {
}
}, [selectedInstanceId, buildTree, toast]);
// 加载项目列表支持不传repoGroupId加载所有项目
const loadProjects = useCallback(async (repoGroupId?: number) => {
if (!selectedInstanceId) return;
setLoading((prev) => ({ ...prev, projects: true }));
try {
const data = await getRepositoryProjects({
externalSystemId: selectedInstanceId,
repoGroupId,
});
setProjects(data || []);
} catch (error) {
toast({
variant: 'destructive',
title: '加载失败',
description: '加载项目列表失败',
});
setProjects([]);
} finally {
setLoading((prev) => ({ ...prev, projects: false }));
}
}, [selectedInstanceId, toast]);
// 加载分支列表支持不传repoProjectId加载该组所有分支
const loadBranches = useCallback(async (repoProjectId?: number) => {
setLoading((prev) => ({ ...prev, branches: true }));
try {
const data = await getRepositoryBranches({
repoProjectId,
});
setBranches(data || []);
} catch (error) {
toast({
variant: 'destructive',
title: '加载失败',
description: '加载分支列表失败',
});
setBranches([]);
} finally {
setLoading((prev) => ({ ...prev, branches: false }));
}
}, [toast]);
// 加载项目列表支持不传repoGroupId加载所有项目- 现在不再需要,由 InfiniteScrollList 处理
// 加载分支列表支持不传repoProjectId加载该组所有分支- 现在不再需要,由 InfiniteScrollList 处理
// 同步所有数据(异步任务)
const handleSyncAll = useCallback(async () => {
@ -518,25 +462,9 @@ const GitManager: React.FC = () => {
);
};
// 过滤项目列表(使用防抖搜索值)
const filteredProjects = useMemo(() => {
if (!debouncedProjectSearch) return projects;
return projects.filter(
(project) =>
project.name.toLowerCase().includes(debouncedProjectSearch.toLowerCase()) ||
(project.path && project.path.toLowerCase().includes(debouncedProjectSearch.toLowerCase()))
);
}, [projects, debouncedProjectSearch]);
// 项目和分支列表现在由 InfiniteScrollList 组件内部管理,不再需要客户端过滤
// 过滤分支列表(使用防抖搜索值)
const filteredBranches = useMemo(() => {
if (!debouncedBranchSearch) return branches;
return branches.filter((branch) =>
branch.name.toLowerCase().includes(debouncedBranchSearch.toLowerCase())
);
}, [branches, debouncedBranchSearch]);
// 加载 Git 实例列表
// 加载 Git 实例列表(只在组件挂载时执行一次)
useEffect(() => {
const loadInstancesEffect = async () => {
try {
@ -556,38 +484,32 @@ const GitManager: React.FC = () => {
}
};
loadInstancesEffect();
}, [selectedInstanceId, toast]);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []); // 只在组件挂载时执行一次
// 监听实例变化
// 实例变化时 - 重置状态并加载仓库组
useEffect(() => {
if (selectedInstanceId) {
loadGroupTree();
setSelectedGroup(undefined);
setSelectedProject(undefined);
setProjects([]);
setBranches([]);
}
}, [selectedInstanceId, loadGroupTree]);
if (!selectedInstanceId) return;
// 重置所有状态和标志
isInitializedRef.current = false;
groupsLoadedRef.current = false;
projectsLoadedRef.current = false;
setSelectedGroup(undefined);
setSelectedProject(undefined);
// 项目和分支列表由 InfiniteScrollList 自动重置
// 加载仓库组列表
loadGroupTree();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedInstanceId]); // 只监听 selectedInstanceId避免重复请求
// 监听仓库组加载完成 或 仓库组选择变化,自动加载项目列表
// 仓库组选择变化 - 清除下级选择InfiniteScrollList 会自动重新加载)
useEffect(() => {
// 只有当仓库组不在加载中时才加载项目
if (selectedInstanceId && !loading.groups) {
setSelectedProject(undefined);
setBranches([]);
// 如果选择了组传递repoGroupId否则不传加载所有项目
loadProjects(selectedGroup?.repoGroupId || selectedGroup?.id);
}
}, [loading.groups, selectedInstanceId, selectedGroup, loadProjects]);
// 监听项目加载完成 或 项目选择变化,自动加载分支列表
useEffect(() => {
// 只有当项目不在加载中时才加载分支
if (selectedInstanceId && !loading.projects) {
// 如果选择了项目传递repoProjectId否则不传加载所有分支
loadBranches(selectedProject?.repoProjectId || selectedProject?.id);
}
}, [loading.projects, selectedInstanceId, selectedProject, loadBranches]);
setSelectedProject(undefined);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedGroup?.repoGroupId, selectedGroup?.id]); // 只监听选择的组ID
const filteredGroupTree = useMemo(() => filterGroupTree(groupTree), [groupTree, filterGroupTree]);
@ -648,9 +570,9 @@ const GitManager: React.FC = () => {
<div className="flex items-center justify-between">
<CardTitle className="text-base"></CardTitle>
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="icon"
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={loadGroupTree}
disabled={loading.groups || !selectedInstanceId}
@ -658,7 +580,7 @@ const GitManager: React.FC = () => {
<RefreshCw
className={`h-4 w-4 ${loading.groups ? 'animate-spin' : ''}`}
/>
</Button>
</Button>
<Button
variant="ghost"
size="icon"
@ -711,20 +633,9 @@ const GitManager: React.FC = () => {
<CardHeader className="border-b flex-shrink-0">
<div className="flex items-center justify-between">
<CardTitle className="text-base">
({filteredProjects.length}){selectedGroup && ` - ${selectedGroup.name}`}
{selectedGroup && ` - ${selectedGroup.name}`}
</CardTitle>
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="icon"
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"
@ -738,109 +649,96 @@ const GitManager: React.FC = () => {
</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>
</CardHeader>
<div className="flex-1 overflow-auto">
{loading.projects ? (
<div className="flex items-center justify-center h-full">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
) : 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">{selectedGroup ? '当前仓库组暂无项目' : '暂无项目'}</p>
</div>
) : (
<div
id="projectScrollableDiv"
className="flex-1 overflow-auto"
>
<InfiniteScrollList
loadData={(pageNum, size) =>
getRepositoryProjects({
externalSystemId: selectedInstanceId!,
repoGroupId: selectedGroup?.repoGroupId || selectedGroup?.id,
pageNum,
size,
})
}
renderItem={(project) => (
<div
key={project.id}
className={`p-3 border rounded-lg cursor-pointer hover:bg-accent transition-colors ${
selectedProject?.id === project.id
? 'bg-accent border-primary'
: ''
}`}
onClick={() => handleSelectProject(project)}
>
<div className="flex items-start justify-between gap-2">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<Code2 className="h-4 w-4 flex-shrink-0 text-muted-foreground" />
{renderVisibilityIcon(project.visibility)}
<span className="font-medium truncate">
{project.name}
</span>
</div>
<div className="p-2 space-y-2">
{filteredProjects.map((project) => (
<div
key={project.id}
className={`p-3 border rounded-lg cursor-pointer hover:bg-accent transition-colors ${
selectedProject?.id === project.id
? 'bg-accent border-primary'
: ''
}`}
onClick={() => handleSelectProject(project)}
>
<div className="flex items-start justify-between gap-2">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<Code2 className="h-4 w-4 flex-shrink-0 text-muted-foreground" />
{renderVisibilityIcon(project.visibility)}
<span className="font-medium truncate">
{project.name}
</span>
</div>
{project.description && (
<p className="text-xs text-muted-foreground line-clamp-2">
{project.description}
</p>
)}
<div className="flex items-center gap-3 mt-2 text-xs text-muted-foreground">
{project.isDefaultBranch && (
<span className="flex items-center gap-1">
{project.description && (
<p className="text-xs text-muted-foreground line-clamp-2">
{project.description}
</p>
)}
<div className="flex items-center gap-3 mt-2 text-xs text-muted-foreground">
{project.isDefaultBranch && (
<span className="flex items-center gap-1">
<GitBranch className="h-3 w-3" />
{project.isDefaultBranch}
</span>
)}
{project.branchCount !== undefined && project.branchCount > 0 && (
<span className="flex items-center gap-1">
</span>
)}
{project.branchCount !== undefined && project.branchCount > 0 && (
<span className="flex items-center gap-1">
<GitBranch className="h-3 w-3" />
{project.branchCount}
</span>
)}
{project.lastActivityAt && (
<span className="flex items-center gap-1">
<Calendar className="h-3 w-3" />
{formatTime(project.lastActivityAt)}
</span>
)}
</div>
</div>
{project.webUrl && (
<Tooltip>
<TooltipTrigger asChild>
<a
href={project.webUrl}
</span>
)}
{project.lastActivityAt && (
<span className="flex items-center gap-1">
<Calendar className="h-3 w-3" />
{formatTime(project.lastActivityAt)}
</span>
)}
</div>
</div>
{project.webUrl && (
<Tooltip>
<TooltipTrigger asChild>
<a
href={project.webUrl}
target="_blank"
rel="noopener noreferrer"
onClick={(e) => e.stopPropagation()}
className="flex-shrink-0"
onClick={(e) => e.stopPropagation()}
className="flex-shrink-0"
>
<Button variant="ghost" size="icon" className="h-6 w-6">
<Button variant="ghost" size="icon" className="h-6 w-6">
<ExternalLink className="h-3 w-3" />
</Button>
</a>
</TooltipTrigger>
<TooltipContent>
<p> GitLab </p>
</TooltipContent>
</Tooltip>
</TooltipTrigger>
<TooltipContent>
<p> GitLab </p>
</TooltipContent>
</Tooltip>
)}
</div>
</div>
))}
</div>
</div>
)}
</div>
scrollableTarget="projectScrollableDiv"
pageSize={20}
resetTrigger={selectedGroup?.id}
emptyText={selectedGroup ? '当前仓库组暂无项目' : '暂无项目'}
className="p-2 space-y-2"
/>
</div>
</Card>
{/* 右栏:分支列表 */}
@ -848,22 +746,11 @@ const GitManager: React.FC = () => {
<CardHeader className="border-b flex-shrink-0">
<div className="flex items-center justify-between">
<CardTitle className="text-base">
({filteredBranches.length}){selectedProject && ` - ${selectedProject.name}`}
{selectedProject && ` - ${selectedProject.name}`}
</CardTitle>
<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"
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={handleSyncBranches}
@ -872,101 +759,96 @@ const GitManager: React.FC = () => {
<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
placeholder="搜索分支..."
value={branchSearch}
onChange={(e) => setBranchSearch(e.target.value)}
className="pl-8 h-8"
/>
</Button>
</div>
</div>
</CardHeader>
<div className="flex-1 overflow-auto">
{loading.branches ? (
<div className="flex items-center justify-center h-full">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</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">{selectedProject ? '当前项目暂无分支' : '暂无分支'}</p>
</div>
) : (
<div className="divide-y">
{filteredBranches.map((branch) => (
<div key={branch.id} className="p-3 hover:bg-accent transition-colors">
<div className="flex items-start gap-2">
<GitBranch className="h-4 w-4 mt-0.5 flex-shrink-0 text-muted-foreground" />
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span className="font-medium font-mono text-sm truncate">
{branch.name}
</span>
{branch.isDefaultBranch && (
<Tooltip>
<TooltipTrigger asChild>
<Badge variant="default" className="text-xs cursor-help">
</Badge>
</TooltipTrigger>
<TooltipContent>
<p></p>
</TooltipContent>
</Tooltip>
<div
id="branchScrollableDiv"
className="flex-1 overflow-auto"
>
<InfiniteScrollList
loadData={(pageNum, size) =>
getRepositoryBranches({
externalSystemId: selectedInstanceId!,
repoProjectId: selectedProject?.repoProjectId || selectedProject?.id,
pageNum,
size,
})
}
renderItem={(branch) => (
<div key={branch.id} className="p-3 hover:bg-accent transition-colors border-b">
<div className="flex items-start gap-2">
<GitBranch className="h-4 w-4 mt-0.5 flex-shrink-0 text-muted-foreground" />
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span className="font-medium font-mono text-sm truncate">
{branch.name}
</span>
{branch.isDefaultBranch && (
<Tooltip>
<TooltipTrigger asChild>
<Badge variant="default" className="text-xs cursor-help">
</Badge>
</TooltipTrigger>
<TooltipContent>
<p></p>
</TooltipContent>
</Tooltip>
)}
</div>
{branch.commitMessage && (
<p className="text-xs text-muted-foreground mt-1 line-clamp-2">
<GitCommit className="h-3 w-3 inline mr-1" />
{branch.commitMessage}
</p>
)}
{(branch.commitAuthor || branch.commitDate) && (
<div className="flex items-center gap-3 mt-2 text-xs text-muted-foreground">
{branch.commitAuthor && (
<span className="flex items-center gap-1">
<User className="h-3 w-3" />
{branch.commitAuthor}
</span>
)}
{branch.commitDate && (
<span className="flex items-center gap-1">
<Calendar className="h-3 w-3" />
{formatTime(branch.commitDate)}
</span>
)}
</div>
{branch.commitMessage && (
<p className="text-xs text-muted-foreground mt-1 line-clamp-2">
<GitCommit className="h-3 w-3 inline mr-1" />
{branch.commitMessage}
</p>
)}
{(branch.commitAuthor || branch.commitDate) && (
<div className="flex items-center gap-3 mt-2 text-xs text-muted-foreground">
{branch.commitAuthor && (
<span className="flex items-center gap-1">
<User className="h-3 w-3" />
{branch.commitAuthor}
</span>
)}
{branch.commitDate && (
<span className="flex items-center gap-1">
<Calendar className="h-3 w-3" />
{formatTime(branch.commitDate)}
</span>
)}
</div>
)}
</div>
{branch.webUrl && (
<Tooltip>
<TooltipTrigger asChild>
<a
href={branch.webUrl}
)}
</div>
{branch.webUrl && (
<Tooltip>
<TooltipTrigger asChild>
<a
href={branch.webUrl}
target="_blank"
rel="noopener noreferrer"
className="flex-shrink-0"
className="flex-shrink-0"
>
<Button variant="ghost" size="icon" className="h-6 w-6">
<ExternalLink className="h-3 w-3" />
<Button variant="ghost" size="icon" className="h-6 w-6">
<ExternalLink className="h-3 w-3" />
</Button>
</a>
</TooltipTrigger>
<TooltipContent>
<p> GitLab </p>
</TooltipContent>
</Tooltip>
</TooltipTrigger>
<TooltipContent>
<p> GitLab </p>
</TooltipContent>
</Tooltip>
)}
</div>
</div>
))}
</div>
</div>
)}
scrollableTarget="branchScrollableDiv"
pageSize={20}
resetTrigger={selectedProject?.id}
emptyText={selectedProject ? '当前项目暂无分支' : '暂无分支'}
className=""
/>
</div>
</Card>
</div>

View File

@ -3,12 +3,11 @@ import type {
RepositoryGroupResponse,
RepositoryGroupQuery,
RepositoryProjectResponse,
RepositoryProjectQuery,
RepositoryBranchResponse,
RepositoryBranchQuery,
} from './types';
import { getExternalSystems } from '@/pages/Deploy/External/service';
import { SystemType } from '@/pages/Deploy/External/types';
import type { Page } from '@/types/base';
// API 基础路径
const GROUP_URL = '/api/v1/repository-group';
@ -45,17 +44,21 @@ export const syncRepositoryGroups = (externalSystemId: number) =>
// ==================== 仓库项目 ====================
/**
*
*
*/
export const getRepositoryProjects = (params?: RepositoryProjectQuery) =>
request.get<RepositoryProjectResponse[]>(`${PROJECT_URL}/list`, { params });
/**
* ID获取项目列表
*/
export const getProjectsByGroupId = (repoGroupId: number) =>
request.get<RepositoryProjectResponse[]>(`${PROJECT_URL}/list`, {
params: { repoGroupId },
export const getRepositoryProjects = (params: {
externalSystemId: number;
repoGroupId?: number;
pageNum?: number;
size?: number;
}) =>
request.get<Page<RepositoryProjectResponse>>(`${PROJECT_URL}/page`, {
params: {
pageNum: params.pageNum ?? 0,
size: params.size ?? 20,
externalSystemId: params.externalSystemId,
repoGroupId: params.repoGroupId,
},
});
/**
@ -71,26 +74,22 @@ export const syncRepositoryProjects = (externalSystemId: number, repoGroupId?: n
// ==================== 仓库分支 ====================
/**
*
*
*/
export const getRepositoryBranches = (params?: RepositoryBranchQuery) =>
request.get<RepositoryBranchResponse[]>(`${BRANCH_URL}/list`, {
export const getRepositoryBranches = (params: {
externalSystemId: number;
repoProjectId?: number;
pageNum?: number;
size?: number;
}) =>
request.get<Page<RepositoryBranchResponse>>(`${BRANCH_URL}/page`, {
params: {
...params,
pageNum: params.pageNum ?? 0,
size: params.size ?? 20,
sortField: 'lastUpdateTime',
sortOrder: 'desc'
}
});
/**
* ID获取分支列表
*/
export const getBranchesByProjectId = (repoProjectId: number) =>
request.get<RepositoryBranchResponse[]>(`${BRANCH_URL}/list`, {
params: {
repoProjectId,
sortField: 'lastUpdateTime',
sortOrder: 'desc'
sortOrder: 'desc',
externalSystemId: params.externalSystemId,
repoProjectId: params.repoProjectId,
},
});

View File

@ -1,4 +1,5 @@
import React, { useState, useEffect, useMemo, useCallback, useRef } from 'react';
import InfiniteScroll from 'react-infinite-scroll-component';
import {
Card,
CardHeader,
@ -54,7 +55,6 @@ 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';
@ -88,6 +88,12 @@ const JenkinsManager: React.FC = () => {
const [jobs, setJobs] = useState<JenkinsJobDTO[]>([]);
const [builds, setBuilds] = useState<JenkinsBuildDTO[]>([]);
// 分页状态
const [jobPage, setJobPage] = useState(0);
const [jobHasMore, setJobHasMore] = useState(true);
const [buildPage, setBuildPage] = useState(0);
const [buildHasMore, setBuildHasMore] = useState(true);
// 选中状态
const [selectedView, setSelectedView] = useState<JenkinsViewDTO>();
const [selectedJob, setSelectedJob] = useState<JenkinsJobDTO>();
@ -116,6 +122,11 @@ const JenkinsManager: React.FC = () => {
const [debouncedJobSearch, setDebouncedJobSearch] = useState('');
const [debouncedBuildSearch, setDebouncedBuildSearch] = useState('');
// 使用 ref 跟踪初始化状态,避免重复请求
const isInitializedRef = useRef(false);
const viewsLoadedRef = useRef(false);
const jobsLoadedRef = useRef(false);
// 视图搜索防抖
useEffect(() => {
const timer = setTimeout(() => {
@ -158,16 +169,27 @@ const JenkinsManager: React.FC = () => {
}
}, [selectedInstanceId, toast]);
// 加载任务列表
const loadJobs = useCallback(async (viewId?: number) => {
// 加载任务列表(首次加载或重置)
const loadJobs = useCallback(async (viewId?: number, reset: boolean = true) => {
if (!selectedInstanceId) return;
setLoading((prev) => ({ ...prev, jobs: true }));
try {
const data = await getJenkinsJobs({
const pageNum = reset ? 0 : jobPage;
const response = await getJenkinsJobs({
externalSystemId: selectedInstanceId,
viewId,
pageNum,
size: 20,
});
setJobs(data || []);
if (response) {
if (reset) {
setJobs(response.content || []);
setJobPage(0);
} else {
setJobs((prev) => [...prev, ...(response.content || [])]);
}
setJobHasMore(!response.last);
}
} catch (error) {
toast({
variant: 'destructive',
@ -177,30 +199,97 @@ const JenkinsManager: React.FC = () => {
} finally {
setLoading((prev) => ({ ...prev, jobs: false }));
}
}, [selectedInstanceId, toast]);
}, [selectedInstanceId, jobPage, toast]);
// 加载构建列表
const loadBuilds = useCallback(async (jobId?: number) => {
if (!selectedInstanceId) return;
setLoading((prev) => ({ ...prev, builds: true }));
// 加载更多任务
const loadMoreJobs = useCallback(async () => {
if (!selectedInstanceId || !jobHasMore || loading.jobs) return;
setLoading((prev) => ({ ...prev, jobs: true }));
try {
const data = await getJenkinsBuilds({
const nextPage = jobPage + 1;
const response = await getJenkinsJobs({
externalSystemId: selectedInstanceId,
jobId,
viewId: selectedView?.id,
pageNum: nextPage,
size: 20,
});
setBuilds(data || []);
if (response) {
setJobs((prev) => [...prev, ...(response.content || [])]);
setJobPage(nextPage);
setJobHasMore(!response.last);
}
} catch (error) {
toast({
variant: 'destructive',
title: '加载失败',
description: '获取更多任务失败',
});
} finally {
setLoading((prev) => ({ ...prev, jobs: false }));
}
}, [selectedInstanceId, selectedView, jobPage, jobHasMore, loading.jobs, toast]);
// 加载构建列表(首次加载或重置)
const loadBuilds = useCallback(async (jobId?: number, reset: boolean = true) => {
if (!selectedInstanceId) return;
setLoading((prev) => ({ ...prev, builds: true }));
try {
const pageNum = reset ? 0 : buildPage;
const response = await getJenkinsBuilds({
externalSystemId: selectedInstanceId,
jobId,
pageNum,
size: 20,
});
if (response) {
if (reset) {
setBuilds(response.content || []);
setBuildPage(0);
} else {
setBuilds((prev) => [...prev, ...(response.content || [])]);
}
setBuildHasMore(!response.last);
}
} catch (error) {
toast({
variant: 'destructive',
title: '加载失败',
description: '获取构建列表失败',
});
} finally {
setLoading((prev) => ({ ...prev, builds: false }));
}
}, [selectedInstanceId, toast]);
}, [selectedInstanceId, buildPage, toast]);
// 加载 Jenkins 实例列表
// 加载更多构建
const loadMoreBuilds = useCallback(async () => {
if (!selectedInstanceId || !buildHasMore || loading.builds) return;
setLoading((prev) => ({ ...prev, builds: true }));
try {
const nextPage = buildPage + 1;
const response = await getJenkinsBuilds({
externalSystemId: selectedInstanceId,
jobId: selectedJob?.id,
pageNum: nextPage,
size: 20,
});
if (response) {
setBuilds((prev) => [...prev, ...(response.content || [])]);
setBuildPage(nextPage);
setBuildHasMore(!response.last);
}
} catch (error) {
toast({
variant: 'destructive',
title: '加载失败',
description: '获取更多构建失败',
});
} finally {
setLoading((prev) => ({ ...prev, builds: false }));
}
}, [selectedInstanceId, selectedJob, buildPage, buildHasMore, loading.builds, toast]);
// 加载 Jenkins 实例列表(只在组件挂载时执行一次)
useEffect(() => {
const loadInstances = async () => {
try {
@ -218,7 +307,8 @@ const JenkinsManager: React.FC = () => {
}
};
loadInstances();
}, [selectedInstanceId, toast]);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []); // 只在组件挂载时执行一次
// 同步视图
const handleSyncViews = useCallback(async () => {
@ -298,36 +388,81 @@ const JenkinsManager: React.FC = () => {
}
}, [selectedInstanceId, selectedJob, toast, loadBuilds]);
// 监听实例变化
// 实例变化时 - 重置状态并加载视图
useEffect(() => {
if (selectedInstanceId) {
setSelectedView(undefined);
setSelectedJob(undefined);
setJobs([]);
setBuilds([]);
loadViews();
}
}, [selectedInstanceId, loadViews]);
if (!selectedInstanceId) return;
// 重置所有状态和标志
isInitializedRef.current = false;
viewsLoadedRef.current = false;
jobsLoadedRef.current = false;
setSelectedView(undefined);
setSelectedJob(undefined);
setJobs([]);
setBuilds([]);
setJobPage(0);
setJobHasMore(true);
setBuildPage(0);
setBuildHasMore(true);
// 加载视图列表
loadViews();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedInstanceId]); // 只监听 selectedInstanceId避免重复请求
// 监听视图加载完成 或 视图选择变化,自动加载任务列表
useEffect(() => {
// 只有当视图不在加载中时才加载任务
if (selectedInstanceId && !loading.views) {
setSelectedJob(undefined);
setBuilds([]);
// 如果选择了视图传递viewId否则不传加载所有任务
loadJobs(selectedView?.id);
// 视图加载完成后 - 自动加载任务(只执行一次)
useEffect(() => {
if (selectedInstanceId && views.length > 0 && !viewsLoadedRef.current) {
viewsLoadedRef.current = true;
loadJobs(undefined, true);
}
}, [loading.views, selectedInstanceId, selectedView, loadJobs]);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedInstanceId, views.length]); // 监听 views.length 变化
// 监听任务加载完成 或 任务选择变化,自动加载构建列表
useEffect(() => {
// 只有当任务不在加载中时才加载构建
if (selectedInstanceId && !loading.jobs) {
// 如果选择了任务传递jobId否则不传加载所有构建
loadBuilds(selectedJob?.id);
// 任务加载完成后 - 自动加载构建(只执行一次)
useEffect(() => {
if (selectedInstanceId && jobs.length > 0 && !jobsLoadedRef.current) {
jobsLoadedRef.current = true;
isInitializedRef.current = true; // 标记初始化完成
loadBuilds(undefined, true);
}
}, [loading.jobs, selectedInstanceId, selectedJob, loadBuilds]);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedInstanceId, jobs.length]); // 监听 jobs.length 变化
// 视图选择变化 - 重新加载任务
useEffect(() => {
// 只有初始化完成后才处理选择变化
if (!isInitializedRef.current) return;
// 立即清空旧数据,避免渲染大量过时数据导致卡顿
setSelectedJob(undefined);
setJobs([]); // 🔥 关键:立即清空任务数据
setBuilds([]);
setJobPage(0);
setJobHasMore(true);
setBuildPage(0);
setBuildHasMore(true);
// 重新加载任务
loadJobs(selectedView?.id, true);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedView?.id]); // 只监听 selectedView?.id
// 任务选择变化 - 重新加载构建
useEffect(() => {
// 只有初始化完成后才处理选择变化
if (!isInitializedRef.current) return;
// 立即清空旧数据,避免渲染大量过时数据导致卡顿
setBuilds([]); // 🔥 关键:立即清空构建数据
setBuildPage(0);
setBuildHasMore(true);
// 重新加载构建
loadBuilds(selectedJob?.id, true);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedJob?.id]); // 只监听 selectedJob?.id
// 过滤视图(使用防抖搜索值)
const filteredViews = useMemo(() => {
@ -378,14 +513,6 @@ const JenkinsManager: React.FC = () => {
return `${hours}小时${minutes % 60}分钟`;
}, []);
// 构建列表虚拟滚动 - 用于处理大量数据5000+ 条)
const buildListRef = useRef<HTMLDivElement>(null);
const buildVirtualizer = useVirtualizer({
count: filteredBuilds.length,
getScrollElement: () => buildListRef.current,
estimateSize: () => 120, // 预估每个构建卡片的高度
overscan: 5, // 预渲染额外的项目数量
});
return (
<TooltipProvider>
@ -566,8 +693,11 @@ const JenkinsManager: React.FC = () => {
/>
</div>
</CardHeader>
<div className="flex-1 overflow-auto">
{loading.jobs ? (
<div
id="jobScrollableDiv"
className="flex-1 overflow-auto"
>
{loading.jobs && filteredJobs.length === 0 ? (
<div className="flex items-center justify-center h-full">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
@ -575,9 +705,26 @@ const JenkinsManager: React.FC = () => {
<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">{selectedView ? '当前视图暂无任务' : '暂无任务'}</p>
</div>
</div>
) : (
<div className="p-2 space-y-2">
<InfiniteScroll
dataLength={filteredJobs.length}
next={loadMoreJobs}
hasMore={jobHasMore}
loader={
<div className="flex items-center justify-center py-4 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin mr-2" />
...
</div>
}
endMessage={
<div className="flex items-center justify-center py-4 text-sm text-muted-foreground">
</div>
}
scrollableTarget="jobScrollableDiv"
className="p-2 space-y-2"
>
{filteredJobs.map((job) => (
<div
key={job.id}
@ -607,7 +754,7 @@ const JenkinsManager: React.FC = () => {
>
<Button variant="ghost" size="icon" className="h-6 w-6">
<ExternalLink className="h-3 w-3" />
</Button>
</Button>
</a>
</TooltipTrigger>
<TooltipContent>
@ -615,7 +762,7 @@ const JenkinsManager: React.FC = () => {
</TooltipContent>
</Tooltip>
)}
</div>
</div>
{job.description && (
<p className="text-xs text-muted-foreground line-clamp-2 mb-2">
@ -653,10 +800,10 @@ const JenkinsManager: React.FC = () => {
</div>
)}
</div>
))}
</div>
))}
</InfiniteScroll>
)}
</div>
</div>
</Card>
{/* 右栏:构建列表 */}
@ -702,10 +849,10 @@ const JenkinsManager: React.FC = () => {
</div>
</CardHeader>
<div
ref={buildListRef}
id="buildScrollableDiv"
className="flex-1 overflow-auto"
>
{loading.builds ? (
{loading.builds && filteredBuilds.length === 0 ? (
<div className="flex items-center justify-center h-full">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
@ -715,36 +862,36 @@ const JenkinsManager: React.FC = () => {
<p className="text-sm">{selectedJob ? '当前任务暂无构建' : '暂无构建'}</p>
</div>
) : (
<div
style={{
height: `${buildVirtualizer.getTotalSize()}px`,
width: '100%',
position: 'relative',
}}
<InfiniteScroll
dataLength={filteredBuilds.length}
next={loadMoreBuilds}
hasMore={buildHasMore}
loader={
<div className="flex items-center justify-center py-4 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin mr-2" />
...
</div>
}
endMessage={
<div className="flex items-center justify-center py-4 text-sm text-muted-foreground">
</div>
}
scrollableTarget="buildScrollableDiv"
className="p-2 space-y-2"
>
{buildVirtualizer.getVirtualItems().map((virtualItem) => {
const build = filteredBuilds[virtualItem.index];
return (
{filteredBuilds.map((build) => (
<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)`,
}}
className="group p-3 hover:bg-accent transition-colors border rounded-lg"
>
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<div className="flex items-center gap-2">
<span className="font-mono font-semibold text-sm">
#{build.buildNumber}
</span>
{getBuildStatusBadge(build.buildStatus)}
</div>
</div>
{build.buildUrl && (
<Tooltip>
<TooltipTrigger asChild>
@ -779,10 +926,9 @@ const JenkinsManager: React.FC = () => {
: {formatDuration(build.duration)}
</div>
)}
</div>
);
})}
</div>
</div>
))}
</InfiniteScroll>
)}
</div>
</Card>

View File

@ -2,6 +2,7 @@ import request from '@/utils/request';
import type { JenkinsViewDTO, JenkinsJobDTO, JenkinsBuildDTO } from './types';
import { getExternalSystems } from '@/pages/Deploy/External/service';
import { SystemType } from '@/pages/Deploy/External/types';
import type { Page } from '@/types/base';
// 获取 Jenkins 实例列表
export const getJenkinsInstances = () =>
@ -26,9 +27,21 @@ export const syncJenkinsViews = (externalSystemId: number) =>
// ==================== Jenkins 任务 ====================
// 获取任务列表
export const getJenkinsJobs = (params: { externalSystemId: number; viewId?: number }) =>
request.get<JenkinsJobDTO[]>(`/api/v1/jenkins-job/list`, { params });
// 获取任务列表(分页)
export const getJenkinsJobs = (params: {
externalSystemId: number;
viewId?: number;
pageNum?: number;
size?: number;
}) =>
request.get<Page<JenkinsJobDTO>>(`/api/v1/jenkins-job/page`, {
params: {
pageNum: params.pageNum ?? 0,
size: params.size ?? 20,
externalSystemId: params.externalSystemId,
viewId: params.viewId
}
});
// 同步任务
export const syncJenkinsJobs = (params: { externalSystemId: number; viewId?: number }) =>
@ -36,13 +49,21 @@ export const syncJenkinsJobs = (params: { externalSystemId: number; viewId?: num
// ==================== Jenkins 构建 ====================
// 获取构建列表(按开始时间倒序排序)
export const getJenkinsBuilds = (params: { externalSystemId: number; jobId?: number }) =>
request.get<JenkinsBuildDTO[]>(`/api/v1/jenkins-build/list`, {
// 获取构建列表(分页,按开始时间倒序排序)
export const getJenkinsBuilds = (params: {
externalSystemId: number;
jobId?: number;
pageNum?: number;
size?: number;
}) =>
request.get<Page<JenkinsBuildDTO>>(`/api/v1/jenkins-build/page`, {
params: {
...params,
pageNum: params.pageNum ?? 0,
size: params.size ?? 20,
sortField: 'starttime',
sortOrder: 'desc'
sortOrder: 'desc',
externalSystemId: params.externalSystemId,
jobId: params.jobId
}
});