增加团队管理页面
This commit is contained in:
parent
7fa9b08e3d
commit
c24f825696
@ -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",
|
||||
|
||||
@ -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: {}
|
||||
|
||||
212
frontend/src/components/ui/infinite-scroll-list.tsx
Normal file
212
frontend/src/components/ui/infinite-scroll-list.tsx
Normal 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 };
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user