重构前端逻辑

This commit is contained in:
dengqichen 2025-11-07 11:22:26 +08:00
parent d9807a1816
commit 011e13dbfa
12 changed files with 909 additions and 1020 deletions

View File

@ -0,0 +1,227 @@
import React, { useState } from 'react';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import { Loader2 } from 'lucide-react';
import { cn } from '@/lib/utils';
export interface ConfirmDialogProps<T = any> {
/** 对话框开关状态 */
open: boolean;
/** 对话框状态变化回调 */
onOpenChange: (open: boolean) => void;
/** 对话框标题 */
title: string | React.ReactNode;
/** 对话框描述文字(可选) */
description?: string | React.ReactNode;
/** 自定义内容区域(优先级高于 description */
children?: React.ReactNode;
/** 传递给回调的数据项(可选) */
item?: T;
/** 确认操作的异步函数 */
onConfirm: (item?: T) => Promise<void>;
/** 成功后的回调(可选) */
onSuccess?: (item?: T) => void;
/** 失败后的回调(可选) */
onError?: (error: any, item?: T) => void;
/** 取消操作的回调(可选) */
onCancel?: () => void;
/** 确认前的验证函数(返回 false 阻止提交) */
beforeConfirm?: (item?: T) => boolean | Promise<boolean>;
/** 确认按钮文字 */
confirmText?: string;
/** 取消按钮文字 */
cancelText?: string;
/** 按钮样式变体 */
variant?: 'default' | 'destructive' | 'success';
/** 成功后是否自动关闭(默认 true */
autoCloseOnSuccess?: boolean;
/** 失败后是否自动关闭(默认 true配合 request.ts 拦截器) */
autoCloseOnError?: boolean;
/** loading 时是否禁用取消按钮(默认 true */
disableCancelWhenLoading?: boolean;
/** 自定义最大宽度 */
maxWidth?: 'sm' | 'md' | 'lg' | 'xl';
/** 自定义类名 */
className?: string;
}
/**
*
*
* shadcn/ui AlertDialog
*
* @example
* ```tsx
* // 简单删除确认
* <ConfirmDialog
* open={open}
* onOpenChange={setOpen}
* title="确认删除"
* description="此操作不可撤销"
* onConfirm={async () => await deleteAPI()}
* onSuccess={() => toast({ title: '删除成功' })}
* variant="destructive"
* />
*
* // 复杂内容 + 自定义
* <ConfirmDialog
* open={open}
* onOpenChange={setOpen}
* title="确认删除"
* item={deletingItem}
* onConfirm={async (item) => await deleteTeam(item.id)}
* variant="destructive"
* >
* <div className="space-y-3">
* <Alert variant="destructive">
* <AlertDescription></AlertDescription>
* </Alert>
* </div>
* </ConfirmDialog>
* ```
*/
export function ConfirmDialog<T = any>({
open,
onOpenChange,
title,
description,
children,
item,
onConfirm,
onSuccess,
onError,
onCancel,
beforeConfirm,
confirmText = '确定',
cancelText = '取消',
variant = 'default',
autoCloseOnSuccess = true,
autoCloseOnError = true,
disableCancelWhenLoading = true,
maxWidth = 'md',
className,
}: ConfirmDialogProps<T>) {
const [loading, setLoading] = useState(false);
/**
* Radix UI AlertDialog body.style.pointerEvents
* body
*/
React.useEffect(() => {
if (open === false) {
// 延迟执行,确保在 AlertDialog 关闭动画之后
const timer = setTimeout(() => {
// 强制移除 body 的 pointer-events 限制
if (document.body.style.pointerEvents === 'none') {
document.body.style.pointerEvents = '';
}
}, 350); // 稍微比 AlertDialog 动画时间200ms长一点
return () => clearTimeout(timer);
}
}, [open]);
const handleConfirm = async () => {
// 前置验证
if (beforeConfirm) {
try {
const canProceed = await beforeConfirm(item);
if (!canProceed) {
return; // 验证失败,不继续执行
}
} catch (error) {
console.error('前置验证失败:', error);
return;
}
}
setLoading(true);
try {
await onConfirm(item);
// 成功后自动关闭
if (autoCloseOnSuccess) {
onOpenChange(false);
}
// 执行成功回调
onSuccess?.(item);
} catch (error) {
// 失败后自动关闭(配合 request.ts 拦截器显示错误)
if (autoCloseOnError) {
onOpenChange(false);
}
// 执行失败回调
onError?.(error, item);
} finally {
setLoading(false);
}
};
const handleCancel = () => {
onCancel?.();
};
// 按钮样式映射
const variantStyles = {
default: '',
destructive: 'bg-destructive text-destructive-foreground hover:bg-destructive/90',
success: 'bg-green-600 hover:bg-green-700 text-white',
};
// 最大宽度映射
const maxWidthStyles = {
sm: 'max-w-sm',
md: 'max-w-md',
lg: 'max-w-lg',
xl: 'max-w-xl',
};
return (
<AlertDialog open={open} onOpenChange={onOpenChange}>
<AlertDialogContent className={cn(maxWidthStyles[maxWidth], className)}>
<AlertDialogHeader>
<AlertDialogTitle>{title}</AlertDialogTitle>
{!children && description && (
<AlertDialogDescription>{description}</AlertDialogDescription>
)}
</AlertDialogHeader>
{/* 自定义内容区域 */}
{children && <div className="py-4">{children}</div>}
<AlertDialogFooter>
<AlertDialogCancel
disabled={disableCancelWhenLoading && loading}
onClick={handleCancel}
>
{cancelText}
</AlertDialogCancel>
<AlertDialogAction
onClick={handleConfirm}
disabled={loading}
className={cn(variantStyles[variant])}
>
{loading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{confirmText}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
}

View File

@ -6,7 +6,7 @@ import type { Page } from '@/types/base';
interface InfiniteScrollListProps<T> {
/**
*
* @param pageNum - 0
* @param pageNum - 1
* @param size -
* @param extraParams -
* @returns
@ -15,8 +15,11 @@ interface InfiniteScrollListProps<T> {
/**
*
* @param item -
* @param index -
* @param isSelected -
*/
renderItem: (item: T, index: number) => React.ReactNode;
renderItem: (item: T, index: number, isSelected: boolean) => React.ReactNode;
/**
* 20
@ -53,16 +56,6 @@ interface InfiniteScrollListProps<T> {
*/
emptyIcon?: React.ReactNode;
/**
*
*/
emptyState?: React.ReactNode;
/**
*
*/
loadingPlaceholder?: React.ReactNode;
/**
*
*/
@ -82,6 +75,33 @@ interface InfiniteScrollListProps<T> {
*
*/
onDataChange?: (data: T[], total: number) => void;
/**
*
*/
searchValue?: string;
/**
*
* @param item -
* @param searchValue -
*/
localFilter?: (item: T, searchValue: string) => boolean;
/**
*
*/
selectedItem?: T;
/**
*
*/
onItemClick?: (item: T) => void;
/**
*
*/
itemKey?: keyof T;
}
export function InfiniteScrollList<T>({
@ -98,16 +118,32 @@ export function InfiniteScrollList<T>({
showInitialLoading = true,
resetTrigger,
onDataChange,
searchValue = '',
localFilter,
selectedItem,
onItemClick,
itemKey,
}: InfiniteScrollListProps<T>) {
const [data, setData] = useState<T[]>([]);
const [page, setPage] = useState(0);
const [page, setPage] = useState(1); // ✅ 从 1 开始
const [hasMore, setHasMore] = useState(true);
const [loading, setLoading] = useState(false);
const [initialLoading, setInitialLoading] = useState(showInitialLoading);
// ✅ 搜索防抖300ms
const [debouncedSearchValue, setDebouncedSearchValue] = useState(searchValue);
// 使用 ref 跟踪是否正在加载,避免重复请求
const loadingRef = useRef(false);
// ✅ 搜索防抖处理
useEffect(() => {
const timer = setTimeout(() => {
setDebouncedSearchValue(searchValue);
}, 300);
return () => clearTimeout(timer);
}, [searchValue]);
// 加载数据
const fetchData = useCallback(async (pageNum: number, reset: boolean = false) => {
if (loadingRef.current) return;
@ -155,17 +191,29 @@ export function InfiniteScrollList<T>({
// 重置并重新加载
const reset = useCallback(() => {
setData([]);
setPage(0);
setPage(1); // ✅ 重置为 1
setHasMore(true);
setInitialLoading(showInitialLoading);
fetchData(0, true);
fetchData(1, true); // ✅ 从第 1 页开始
}, [fetchData, showInitialLoading]);
// 初始加载
// 当 resetTrigger 或 debouncedSearchValue 变化时重置
useEffect(() => {
reset();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [resetTrigger]);
}, [resetTrigger, debouncedSearchValue]);
// ✅ 本地过滤数据
const filteredData = React.useMemo(() => {
if (!localFilter || !debouncedSearchValue) return data;
return data.filter(item => localFilter(item, debouncedSearchValue));
}, [data, localFilter, debouncedSearchValue]);
// ✅ 判断项是否被选中
const isSelected = (item: T) => {
if (!selectedItem || !itemKey) return false;
return selectedItem[itemKey] === item[itemKey];
};
// 初始 loading 状态
if (initialLoading && data.length === 0) {
@ -177,7 +225,7 @@ export function InfiniteScrollList<T>({
}
// 空状态
if (!loading && data.length === 0) {
if (!loading && filteredData.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" />}
@ -189,7 +237,7 @@ export function InfiniteScrollList<T>({
// 列表渲染
return (
<InfiniteScroll
dataLength={data.length}
dataLength={filteredData.length}
next={loadMore}
hasMore={hasMore}
loader={
@ -199,7 +247,7 @@ export function InfiniteScrollList<T>({
</div>
}
endMessage={
data.length > 0 ? (
filteredData.length > 0 ? (
<div className="flex items-center justify-center py-4 text-sm text-muted-foreground">
{endMessage}
</div>
@ -208,15 +256,21 @@ export function InfiniteScrollList<T>({
scrollableTarget={scrollableTarget}
className={className || listClassName}
>
{data.map((item, index) => (
<React.Fragment key={index}>
{renderItem(item, index)}
</React.Fragment>
))}
{filteredData.map((item, index) => {
const selected = isSelected(item);
return (
<div
key={itemKey ? String(item[itemKey]) : index}
onClick={() => onItemClick?.(item)}
className={onItemClick ? 'cursor-pointer' : ''}
>
{renderItem(item, index, selected)}
</div>
);
})}
</InfiniteScroll>
);
}
// 导出类型供外部使用
export type { InfiniteScrollListProps };

View File

@ -5,16 +5,7 @@ import {
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { ConfirmDialog } from '@/components/ui/confirm-dialog';
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
@ -197,25 +188,7 @@ const ApplicationManageDialog: React.FC<ApplicationManageDialogProps> = ({
// 确认删除
const handleDeleteConfirm = async () => {
if (!deleteRecord) return;
try {
await deleteTeamApplication(deleteRecord.id);
toast({
title: "删除成功",
description: `应用 "${deleteRecord.applicationName}" 已移除`,
});
setDeleteDialogOpen(false);
setDeleteRecord(null);
loadApplications();
onSuccess?.();
} catch (error) {
console.error('删除失败:', error);
toast({
variant: "destructive",
title: "删除失败",
description: error instanceof Error ? error.message : '未知错误',
});
}
};
// 过滤出未关联的应用
@ -401,24 +374,25 @@ const ApplicationManageDialog: React.FC<ApplicationManageDialogProps> = ({
</Dialog>
{/* 删除确认对话框 */}
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle></AlertDialogTitle>
<AlertDialogDescription>
"{deleteRecord?.applicationName}"
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel onClick={() => setDeleteDialogOpen(false)}>
</AlertDialogCancel>
<AlertDialogAction onClick={handleDeleteConfirm} className="bg-destructive text-destructive-foreground hover:bg-destructive/90">
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<ConfirmDialog
open={deleteDialogOpen}
onOpenChange={setDeleteDialogOpen}
title="确认删除"
description={`确定要将应用 "${deleteRecord?.applicationName}" 从团队中移除吗?此操作无法撤销。`}
item={deleteRecord}
onConfirm={handleDeleteConfirm}
onSuccess={() => {
toast({
title: "删除成功",
description: `应用 "${deleteRecord?.applicationName}" 已移除`,
});
setDeleteRecord(null);
loadApplications();
onSuccess?.();
}}
variant="destructive"
confirmText="确认删除"
/>
</>
);
};

View File

@ -1,15 +1,7 @@
import React from 'react';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { AlertCircle } from 'lucide-react';
import { useToast } from '@/components/ui/use-toast';
import { ConfirmDialog } from '@/components/ui/confirm-dialog';
import { deleteTeam } from '../service';
import type { TeamResponse } from '../types';
@ -33,38 +25,36 @@ const DeleteDialog: React.FC<DeleteDialogProps> = ({
const handleConfirm = async () => {
if (!record) return;
try {
await deleteTeam(record.id);
toast({ title: '删除成功', description: `团队 "${record.teamName}" 已删除` });
onSuccess();
onOpenChange(false);
} catch (error) {
console.error('删除失败:', error);
toast({
title: '删除失败',
description: error instanceof Error ? error.message : '未知错误',
variant: 'destructive'
});
}
};
if (!record) return null;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<ConfirmDialog
open={open}
onOpenChange={onOpenChange}
title={
<div className="flex items-center gap-2">
<AlertCircle className="h-5 w-5 text-destructive" />
</DialogTitle>
<DialogDescription>
</DialogDescription>
</DialogHeader>
<div className="space-y-3 py-4">
</div>
}
description="此操作将永久删除该团队,该操作无法撤销。"
item={record}
onConfirm={handleConfirm}
onSuccess={() => {
toast({
title: '删除成功',
description: `团队 "${record.teamName}" 已删除`
});
onSuccess();
}}
variant="destructive"
confirmText="确认删除"
maxWidth="md"
>
<div className="space-y-3">
<div className="rounded-lg bg-muted p-4 space-y-2">
<div className="flex justify-between items-center">
<span className="text-sm text-muted-foreground"></span>
@ -85,13 +75,17 @@ const DeleteDialog: React.FC<DeleteDialogProps> = ({
{record.memberCount !== undefined && record.memberCount > 0 && (
<div className="flex justify-between items-center">
<span className="text-sm text-muted-foreground"></span>
<span className="text-sm font-medium text-destructive">{record.memberCount} </span>
<span className="text-sm font-medium text-destructive">
{record.memberCount}
</span>
</div>
)}
{record.applicationCount !== undefined && record.applicationCount > 0 && (
<div className="flex justify-between items-center">
<span className="text-sm text-muted-foreground"></span>
<span className="text-sm font-medium text-destructive">{record.applicationCount} </span>
<span className="text-sm font-medium text-destructive">
{record.applicationCount}
</span>
</div>
)}
</div>
@ -106,19 +100,8 @@ const DeleteDialog: React.FC<DeleteDialogProps> = ({
</div>
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
</Button>
<Button variant="destructive" onClick={handleConfirm}>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</ConfirmDialog>
);
};
export default DeleteDialog;

View File

@ -5,16 +5,7 @@ import {
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { ConfirmDialog } from '@/components/ui/confirm-dialog';
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
@ -245,25 +236,7 @@ const MemberManageDialog: React.FC<MemberManageDialogProps> = ({
// 确认删除
const handleDeleteConfirm = async () => {
if (!deleteRecord) return;
try {
await deleteTeamMember(deleteRecord.id);
toast({
title: "删除成功",
description: `成员 "${deleteRecord.userName}" 已移除`,
});
setDeleteDialogOpen(false);
setDeleteRecord(null);
loadMembers();
onSuccess?.();
} catch (error) {
console.error('删除失败:', error);
toast({
variant: "destructive",
title: "删除失败",
description: error instanceof Error ? error.message : '未知错误',
});
}
};
// 过滤出未加入的用户(排除负责人和已加入的成员)
@ -488,24 +461,25 @@ const MemberManageDialog: React.FC<MemberManageDialogProps> = ({
</Dialog>
{/* 删除确认对话框 */}
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle></AlertDialogTitle>
<AlertDialogDescription>
"{deleteRecord?.userName}"
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel onClick={() => setDeleteDialogOpen(false)}>
</AlertDialogCancel>
<AlertDialogAction onClick={handleDeleteConfirm} className="bg-destructive text-destructive-foreground hover:bg-destructive/90">
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<ConfirmDialog
open={deleteDialogOpen}
onOpenChange={setDeleteDialogOpen}
title="确认删除"
description={`确定要将成员 "${deleteRecord?.userName}" 从团队中移除吗?此操作无法撤销。`}
item={deleteRecord}
onConfirm={handleDeleteConfirm}
onSuccess={() => {
toast({
title: "删除成功",
description: `成员 "${deleteRecord?.userName}" 已移除`,
});
setDeleteRecord(null);
loadMembers();
onSuccess?.();
}}
variant="destructive"
confirmText="确认删除"
/>
</>
);
};

View File

@ -243,7 +243,7 @@ const TeamApplicationDialog: React.FC<TeamApplicationDialogProps> = ({
{/* 应用选择 */}
<div className="space-y-2">
<Label>
<span className="text-destructive">*</span>
<span className="text-destructive">*</span>
</Label>
<Select
value={formData.appId?.toString() || ''}
@ -261,7 +261,7 @@ const TeamApplicationDialog: React.FC<TeamApplicationDialogProps> = ({
) : (
applications.map((app) => (
<SelectItem key={app.id} value={app.id.toString()}>
{app.appName}
{app.appName}{app.appCode}
</SelectItem>
))
)}
@ -477,7 +477,7 @@ const TeamApplicationDialog: React.FC<TeamApplicationDialogProps> = ({
{/* 工作流定义 */}
<div className="space-y-2">
<Label></Label>
<Label></Label>
<Select
value={formData.workflowDefinitionId?.toString() || ''}
onValueChange={(value) =>

View File

@ -15,16 +15,7 @@ import {
TableHeader,
TableRow,
} from '@/components/ui/table';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import { ConfirmDialog } from '@/components/ui/confirm-dialog';
import { Button } from '@/components/ui/button';
import { useToast } from '@/components/ui/use-toast';
import { Plus, Edit, Trash2, Loader2 } from 'lucide-react';
@ -76,7 +67,6 @@ export const TeamApplicationManageDialog: React.FC<
// 删除确认对话框状态
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [deletingApp, setDeletingApp] = useState<TeamApplication | null>(null);
const [deleting, setDeleting] = useState(false);
// 加载基础数据和应用列表
useEffect(() => {
@ -196,27 +186,7 @@ export const TeamApplicationManageDialog: React.FC<
const handleConfirmDelete = async () => {
if (!deletingApp) return;
setDeleting(true);
try {
await deleteTeamApplication(deletingApp.id!);
toast({
title: '删除成功',
description: '应用配置已删除',
});
setDeleteDialogOpen(false);
setDeletingApp(null);
loadTeamApplications();
onSuccess?.();
} catch (error: any) {
toast({
title: '删除失败',
description: error.message || '删除应用配置失败',
variant: 'destructive',
});
} finally {
setDeleting(false);
}
};
const getEnvironmentName = (environmentId: number) => {
@ -346,29 +316,25 @@ export const TeamApplicationManageDialog: React.FC<
)}
{/* 删除确认对话框 */}
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle></AlertDialogTitle>
<AlertDialogDescription>
"
{deletingApp?.applicationName || `应用 ${deletingApp?.applicationId}`}"
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={deleting}></AlertDialogCancel>
<AlertDialogAction
onClick={handleConfirmDelete}
disabled={deleting}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
{deleting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<ConfirmDialog
open={deleteDialogOpen}
onOpenChange={setDeleteDialogOpen}
title="确认删除"
description={`确定要删除应用配置 "${deletingApp?.applicationName || `应用 ${deletingApp?.applicationId}`}" 吗?此操作无法撤销。`}
item={deletingApp}
onConfirm={handleConfirmDelete}
onSuccess={() => {
toast({
title: '删除成功',
description: '应用配置已删除',
});
setDeletingApp(null);
loadTeamApplications();
onSuccess?.();
}}
variant="destructive"
confirmText="删除"
/>
</>
);
};

View File

@ -15,16 +15,7 @@ import {
TableHeader,
TableRow,
} from '@/components/ui/table';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import { ConfirmDialog } from '@/components/ui/confirm-dialog';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { useToast } from '@/components/ui/use-toast';
@ -85,7 +76,6 @@ export const TeamEnvironmentManageDialog: React.FC<
const [deletingConfig, setDeletingConfig] = useState<
TeamEnvironmentConfig | null
>(null);
const [deleting, setDeleting] = useState(false);
// 加载团队的环境配置列表
useEffect(() => {
@ -139,28 +129,7 @@ export const TeamEnvironmentManageDialog: React.FC<
const handleConfirmDelete = async () => {
if (!deletingConfig) return;
setDeleting(true);
try {
await deleteTeamEnvironmentConfig(deletingConfig.id);
toast({
title: '删除成功',
description: '环境配置已删除',
});
setDeleteDialogOpen(false);
setDeletingConfig(null);
loadEnvironmentConfigs();
onSuccess?.();
} catch (error: any) {
toast({
title: '删除失败',
description: error.message || '删除环境配置失败',
variant: 'destructive',
});
} finally {
setDeleting(false);
}
};
const handleConfigSuccess = () => {
@ -365,28 +334,25 @@ export const TeamEnvironmentManageDialog: React.FC<
)}
{/* 删除确认对话框 */}
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle></AlertDialogTitle>
<AlertDialogDescription>
"{deletingConfig?.environmentName}"
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={deleting}></AlertDialogCancel>
<AlertDialogAction
onClick={handleConfirmDelete}
disabled={deleting}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
{deleting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<ConfirmDialog
open={deleteDialogOpen}
onOpenChange={setDeleteDialogOpen}
title="确认删除"
description={`确定要删除环境"${deletingConfig?.environmentName}"的配置吗?此操作将同时删除该环境下的所有应用配置,且无法撤销。`}
item={deletingConfig}
onConfirm={handleConfirmDelete}
onSuccess={() => {
toast({
title: '删除成功',
description: '环境配置已删除',
});
setDeletingConfig(null);
loadEnvironmentConfigs();
onSuccess?.();
}}
variant="destructive"
confirmText="删除"
/>
</>
);
};

View File

@ -43,7 +43,7 @@ import {
Calendar,
User,
GitCommit,
Download,
RotateCw,
} from 'lucide-react';
import type {
RepositoryGroupResponse,
@ -111,6 +111,13 @@ const GitManager: React.FC = () => {
branches: false,
});
// 数量统计
const [projectCount, setProjectCount] = useState(0);
const [branchCount, setBranchCount] = useState(0);
// 刷新触发器
const [projectRefreshKey, setProjectRefreshKey] = useState(0);
const [branchRefreshKey, setBranchRefreshKey] = useState(0);
// 搜索过滤 - 仅保留仓库组搜索
const [groupSearch, setGroupSearch] = useState('');
@ -193,9 +200,6 @@ const GitManager: React.FC = () => {
}
}, [selectedInstanceId, buildTree, toast]);
// 加载项目列表支持不传repoGroupId加载所有项目- 现在不再需要,由 InfiniteScrollList 处理
// 加载分支列表支持不传repoProjectId加载该组所有分支- 现在不再需要,由 InfiniteScrollList 处理
// 同步所有数据(异步任务)
const handleSyncAll = useCallback(async () => {
if (!selectedInstanceId) return;
@ -462,8 +466,6 @@ const GitManager: React.FC = () => {
);
};
// 项目和分支列表现在由 InfiniteScrollList 组件内部管理,不再需要客户端过滤
// 加载 Git 实例列表(只在组件挂载时执行一次)
useEffect(() => {
const loadInstancesEffect = async () => {
@ -513,6 +515,17 @@ const GitManager: React.FC = () => {
const filteredGroupTree = useMemo(() => filterGroupTree(groupTree), [groupTree, filterGroupTree]);
// 缓存 resetTrigger 避免不必要的重置
const projectResetTrigger = React.useMemo(
() => [selectedInstanceId, selectedGroup?.id, projectRefreshKey],
[selectedInstanceId, selectedGroup?.id, projectRefreshKey]
);
const branchResetTrigger = React.useMemo(
() => [selectedInstanceId, selectedProject?.id, branchRefreshKey],
[selectedInstanceId, selectedProject?.id, branchRefreshKey]
);
return (
<TooltipProvider>
@ -576,6 +589,7 @@ const GitManager: React.FC = () => {
className="h-7 w-7"
onClick={loadGroupTree}
disabled={loading.groups || !selectedInstanceId}
title="刷新列表"
>
<RefreshCw
className={`h-4 w-4 ${loading.groups ? 'animate-spin' : ''}`}
@ -587,9 +601,10 @@ const GitManager: React.FC = () => {
className="h-7 w-7"
onClick={handleSyncGroups}
disabled={syncing.groups || !selectedInstanceId}
title="从Git同步"
>
<Download
className={`h-4 w-4 ${syncing.groups ? 'animate-bounce' : ''}`}
<RotateCw
className={`h-4 w-4 ${syncing.groups ? 'animate-spin' : ''}`}
/>
</Button>
</div>
@ -632,19 +647,30 @@ const GitManager: React.FC = () => {
<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">
{selectedGroup && ` - ${selectedGroup.name}`}
<CardTitle className="text-base flex items-center gap-2">
({projectCount}){selectedGroup && ` - ${selectedGroup.name}`}
</CardTitle>
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={() => setProjectRefreshKey(prev => prev + 1)}
disabled={!selectedInstanceId}
title="刷新列表"
>
<RefreshCw className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={handleSyncProjects}
disabled={syncing.projects || !selectedInstanceId}
title="从Git同步"
>
<Download
className={`h-4 w-4 ${syncing.projects ? 'animate-bounce' : ''}`}
<RotateCw
className={`h-4 w-4 ${syncing.projects ? 'animate-spin' : ''}`}
/>
</Button>
</div>
@ -663,6 +689,7 @@ const GitManager: React.FC = () => {
size,
})
}
onDataChange={(data, total) => setProjectCount(data.length)}
renderItem={(project) => (
<div
key={project.id}
@ -734,7 +761,7 @@ const GitManager: React.FC = () => {
)}
scrollableTarget="projectScrollableDiv"
pageSize={20}
resetTrigger={selectedGroup?.id}
resetTrigger={projectResetTrigger}
emptyText={selectedGroup ? '当前仓库组暂无项目' : '暂无项目'}
className="p-2 space-y-2"
/>
@ -745,19 +772,30 @@ const GitManager: React.FC = () => {
<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">
{selectedProject && ` - ${selectedProject.name}`}
<CardTitle className="text-base flex items-center gap-2">
({branchCount}){selectedProject && ` - ${selectedProject.name}`}
</CardTitle>
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={() => setBranchRefreshKey(prev => prev + 1)}
disabled={!selectedInstanceId}
title="刷新列表"
>
<RefreshCw className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={handleSyncBranches}
disabled={syncing.branches || !selectedInstanceId}
title="从Git同步"
>
<Download
className={`h-4 w-4 ${syncing.branches ? 'animate-bounce' : ''}`}
<RotateCw
className={`h-4 w-4 ${syncing.branches ? 'animate-spin' : ''}`}
/>
</Button>
</div>
@ -776,6 +814,7 @@ const GitManager: React.FC = () => {
size,
})
}
onDataChange={(data, total) => setBranchCount(data.length)}
renderItem={(branch) => (
<div key={branch.id} className="p-3 hover:bg-accent transition-colors border-b">
<div className="flex items-start gap-2">
@ -845,7 +884,7 @@ const GitManager: React.FC = () => {
)}
scrollableTarget="branchScrollableDiv"
pageSize={20}
resetTrigger={selectedProject?.id}
resetTrigger={branchResetTrigger}
emptyText={selectedProject ? '当前项目暂无分支' : '暂无分支'}
className=""
/>
@ -859,4 +898,3 @@ const GitManager: React.FC = () => {
};
export default GitManager;

View File

@ -54,7 +54,7 @@ export const getRepositoryProjects = (params: {
}) =>
request.get<Page<RepositoryProjectResponse>>(`${PROJECT_URL}/page`, {
params: {
pageNum: params.pageNum ?? 0,
pageNum: params.pageNum ?? 1,
size: params.size ?? 20,
externalSystemId: params.externalSystemId,
repoGroupId: params.repoGroupId,
@ -84,7 +84,7 @@ export const getRepositoryBranches = (params: {
}) =>
request.get<Page<RepositoryBranchResponse>>(`${BRANCH_URL}/page`, {
params: {
pageNum: params.pageNum ?? 0,
pageNum: params.pageNum ?? 1,
size: params.size ?? 20,
sortField: 'lastUpdateTime',
sortOrder: 'desc',

View File

@ -1,5 +1,5 @@
import React, { useState, useEffect, useMemo, useCallback, useRef } from 'react';
import InfiniteScroll from 'react-infinite-scroll-component';
import React, { useState, useEffect, useCallback } from 'react';
import { InfiniteScrollList } from '@/components/ui/infinite-scroll-list';
import {
Card,
CardHeader,
@ -38,7 +38,7 @@ import {
AlertTriangle,
Calendar,
Activity,
Download,
RotateCw,
} from 'lucide-react';
import type {
JenkinsInstance,
@ -85,25 +85,13 @@ const JenkinsManager: React.FC = () => {
// 数据状态
const [views, setViews] = useState<JenkinsViewDTO[]>([]);
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>();
// 加载状态
const [loading, setLoading] = useState({
views: false,
jobs: false,
builds: false,
});
const [loading, setLoading] = useState({ views: false });
// 同步状态
const [syncing, setSyncing] = useState({
@ -117,39 +105,13 @@ const JenkinsManager: React.FC = () => {
const [jobSearch, setJobSearch] = useState('');
const [buildSearch, setBuildSearch] = useState('');
// 防抖搜索值 - 优化大量数据过滤性能
const [debouncedViewSearch, setDebouncedViewSearch] = useState('');
const [debouncedJobSearch, setDebouncedJobSearch] = useState('');
const [debouncedBuildSearch, setDebouncedBuildSearch] = useState('');
// 数量统计
const [jobCount, setJobCount] = useState(0);
const [buildCount, setBuildCount] = useState(0);
// 使用 ref 跟踪初始化状态,避免重复请求
const isInitializedRef = useRef(false);
const viewsLoadedRef = useRef(false);
const jobsLoadedRef = useRef(false);
// 视图搜索防抖
useEffect(() => {
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 [jobRefreshKey, setJobRefreshKey] = useState(0);
const [buildRefreshKey, setBuildRefreshKey] = useState(0);
// 加载视图列表
const loadViews = useCallback(async () => {
@ -169,127 +131,7 @@ const JenkinsManager: React.FC = () => {
}
}, [selectedInstanceId, toast]);
// 加载任务列表(首次加载或重置)
const loadJobs = useCallback(async (viewId?: number, reset: boolean = true) => {
if (!selectedInstanceId) return;
setLoading((prev) => ({ ...prev, jobs: true }));
try {
const pageNum = reset ? 0 : jobPage;
const response = await getJenkinsJobs({
externalSystemId: selectedInstanceId,
viewId,
pageNum,
size: 20,
});
if (response) {
if (reset) {
setJobs(response.content || []);
setJobPage(0);
} else {
setJobs((prev) => [...prev, ...(response.content || [])]);
}
setJobHasMore(!response.last);
}
} catch (error) {
toast({
variant: 'destructive',
title: '加载失败',
description: '获取任务列表失败',
});
} finally {
setLoading((prev) => ({ ...prev, jobs: false }));
}
}, [selectedInstanceId, jobPage, toast]);
// 加载更多任务
const loadMoreJobs = useCallback(async () => {
if (!selectedInstanceId || !jobHasMore || loading.jobs) return;
setLoading((prev) => ({ ...prev, jobs: true }));
try {
const nextPage = jobPage + 1;
const response = await getJenkinsJobs({
externalSystemId: selectedInstanceId,
viewId: selectedView?.id,
pageNum: nextPage,
size: 20,
});
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, buildPage, toast]);
// 加载更多构建
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 实例列表(只在组件挂载时执行一次)
// 加载 Jenkins 实例列表
useEffect(() => {
const loadInstances = async () => {
try {
@ -308,7 +150,16 @@ const JenkinsManager: React.FC = () => {
};
loadInstances();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []); // 只在组件挂载时执行一次
}, []);
// 实例变化时加载视图
useEffect(() => {
if (!selectedInstanceId) return;
setSelectedView(undefined);
setSelectedJob(undefined);
loadViews();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedInstanceId]);
// 同步视图
const handleSyncViews = useCallback(async () => {
@ -345,10 +196,6 @@ const JenkinsManager: React.FC = () => {
title: '同步成功',
description: selectedView ? '视图任务同步任务已启动' : '所有任务同步任务已启动',
});
setTimeout(() => {
// 无论是否选择视图,都重新加载任务列表
loadJobs(selectedView?.id);
}, 2000);
} catch (error) {
toast({
variant: 'destructive',
@ -358,7 +205,7 @@ const JenkinsManager: React.FC = () => {
} finally {
setSyncing((prev) => ({ ...prev, jobs: false }));
}
}, [selectedInstanceId, selectedView, toast, loadJobs]);
}, [selectedInstanceId, selectedView, toast]);
// 同步构建
const handleSyncBuilds = useCallback(async () => {
@ -373,10 +220,6 @@ const JenkinsManager: React.FC = () => {
title: '同步成功',
description: selectedJob ? '任务构建同步任务已启动' : '所有构建同步任务已启动',
});
setTimeout(() => {
// 无论是否选择任务,都重新加载构建列表
loadBuilds(selectedJob?.id);
}, 2000);
} catch (error) {
toast({
variant: 'destructive',
@ -386,109 +229,15 @@ const JenkinsManager: React.FC = () => {
} finally {
setSyncing((prev) => ({ ...prev, builds: false }));
}
}, [selectedInstanceId, selectedJob, toast, loadBuilds]);
}, [selectedInstanceId, selectedJob, toast]);
// 实例变化时 - 重置状态并加载视图
useEffect(() => {
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 && views.length > 0 && !viewsLoadedRef.current) {
viewsLoadedRef.current = true;
loadJobs(undefined, true);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedInstanceId, views.length]); // 监听 views.length 变化
// 任务加载完成后 - 自动加载构建(只执行一次)
useEffect(() => {
if (selectedInstanceId && jobs.length > 0 && !jobsLoadedRef.current) {
jobsLoadedRef.current = true;
isInitializedRef.current = true; // 标记初始化完成
loadBuilds(undefined, true);
}
// 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(() => {
if (!debouncedViewSearch) return views;
// 过滤视图
const filteredViews = React.useMemo(() => {
if (!viewSearch) return views;
return views.filter((view) =>
view.viewName.toLowerCase().includes(debouncedViewSearch.toLowerCase())
view.viewName.toLowerCase().includes(viewSearch.toLowerCase())
);
}, [views, debouncedViewSearch]);
// 过滤任务(使用防抖搜索值)
const filteredJobs = useMemo(() => {
if (!debouncedJobSearch) return jobs;
return jobs.filter((job) =>
job.jobName.toLowerCase().includes(debouncedJobSearch.toLowerCase())
);
}, [jobs, debouncedJobSearch]);
// 过滤构建(使用防抖搜索值 - 特别重要5000+条数据)
const filteredBuilds = useMemo(() => {
if (!debouncedBuildSearch) return builds;
const searchLower = debouncedBuildSearch.toLowerCase();
return builds.filter((build) =>
build.buildNumber.toString().includes(searchLower) ||
build.buildStatus.toLowerCase().includes(searchLower)
);
}, [builds, debouncedBuildSearch]);
}, [views, viewSearch]);
// 获取构建状态徽章
const getBuildStatusBadge = useCallback((status: string) => {
@ -513,6 +262,16 @@ const JenkinsManager: React.FC = () => {
return `${hours}小时${minutes % 60}分钟`;
}, []);
// 缓存 resetTrigger 避免不必要的重置
const jobResetTrigger = React.useMemo(
() => [selectedInstanceId, selectedView?.id, jobRefreshKey],
[selectedInstanceId, selectedView?.id, jobRefreshKey]
);
const buildResetTrigger = React.useMemo(
() => [selectedInstanceId, selectedJob?.id, buildRefreshKey],
[selectedInstanceId, selectedJob?.id, buildRefreshKey]
);
return (
<TooltipProvider>
@ -559,10 +318,9 @@ const JenkinsManager: React.FC = () => {
className="h-7 w-7"
onClick={loadViews}
disabled={loading.views || !selectedInstanceId}
title="刷新列表"
>
<RefreshCw
className={`h-4 w-4 ${loading.views ? 'animate-spin' : ''}`}
/>
<RefreshCw className={`h-4 w-4 ${loading.views ? 'animate-spin' : ''}`} />
</Button>
<Button
variant="ghost"
@ -570,10 +328,9 @@ const JenkinsManager: React.FC = () => {
className="h-7 w-7"
onClick={handleSyncViews}
disabled={syncing.views || !selectedInstanceId}
title="从Jenkins同步"
>
<Download
className={`h-4 w-4 ${syncing.views ? 'animate-bounce' : ''}`}
/>
<RotateCw className={`h-4 w-4 ${syncing.views ? 'animate-spin' : ''}`} />
</Button>
</div>
</div>
@ -603,16 +360,12 @@ const JenkinsManager: React.FC = () => {
<div
key={view.id}
className={`group p-3 border rounded-lg cursor-pointer hover:bg-accent transition-colors ${
selectedView?.id === view.id
? 'bg-accent border-primary'
: ''
selectedView?.id === view.id ? 'bg-accent border-primary' : ''
}`}
onClick={() => setSelectedView(selectedView?.id === view.id ? undefined : view)}
>
<div className="flex items-center gap-2 mb-2">
<span className="flex-1 truncate font-medium">
{view.viewName}
</span>
<span className="flex-1 truncate font-medium">{view.viewName}</span>
{view.jobCount !== undefined && view.jobCount > 0 && (
<Badge variant="secondary" className="text-xs px-1.5 py-0 h-5">
{view.jobCount}
@ -655,20 +408,19 @@ const JenkinsManager: React.FC = () => {
<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">
({filteredJobs.length}){selectedView && ` - ${selectedView.viewName}`}
<CardTitle className="text-base flex items-center gap-2">
({jobCount}){selectedView && ` - ${selectedView.viewName}`}
</CardTitle>
<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}
onClick={() => setJobRefreshKey(prev => prev + 1)}
disabled={!selectedInstanceId}
title="刷新列表"
>
<RefreshCw
className={`h-4 w-4 ${loading.jobs ? 'animate-spin' : ''}`}
/>
<RefreshCw className="h-4 w-4" />
</Button>
<Button
variant="ghost"
@ -676,10 +428,9 @@ const JenkinsManager: React.FC = () => {
className="h-7 w-7"
onClick={handleSyncJobs}
disabled={syncing.jobs || !selectedInstanceId}
title="从Jenkins同步"
>
<Download
className={`h-4 w-4 ${syncing.jobs ? 'animate-bounce' : ''}`}
/>
<RotateCw className={`h-4 w-4 ${syncing.jobs ? 'animate-spin' : ''}`} />
</Button>
</div>
</div>
@ -693,48 +444,19 @@ const JenkinsManager: React.FC = () => {
/>
</div>
</CardHeader>
<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>
) : 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">{selectedView ? '当前视图暂无任务' : '暂无任务'}</p>
</div>
) : (
<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}
className={`group p-3 border rounded-lg cursor-pointer hover:bg-accent transition-colors ${
selectedJob?.id === job.id
? 'bg-accent border-primary'
: ''
}`}
onClick={() => setSelectedJob(selectedJob?.id === job.id ? undefined : job)}
>
<div id="jobScrollableDiv" className="flex-1 overflow-auto">
<InfiniteScrollList<JenkinsJobDTO>
loadData={(pageNum, size) => getJenkinsJobs({
externalSystemId: selectedInstanceId!,
viewId: selectedView?.id,
pageNum,
size,
})}
onDataChange={(data, total) => setJobCount(data.length)}
renderItem={(job, index, isSelected) => (
<div className={`group p-3 border rounded-lg transition-colors ${
isSelected ? 'bg-accent border-primary' : 'hover:bg-accent'
}`}>
<div className="flex items-center gap-2 mb-2">
<span className="flex-1 truncate font-medium">{job.jobName}</span>
{job.buildCount !== undefined && job.buildCount > 0 && (
@ -763,13 +485,11 @@ const JenkinsManager: React.FC = () => {
</Tooltip>
)}
</div>
{job.description && (
<p className="text-xs text-muted-foreground line-clamp-2 mb-2">
{job.description}
</p>
)}
<div className="flex items-center gap-3 text-xs">
{job.lastBuildStatus && getBuildStatusBadge(job.lastBuildStatus)}
{job.healthReportScore !== undefined && (
@ -777,10 +497,7 @@ const JenkinsManager: React.FC = () => {
<TooltipTrigger asChild>
<div className="flex items-center gap-1">
<Activity className="h-3 w-3 text-muted-foreground" />
<Progress
value={job.healthReportScore}
className="w-16 h-2"
/>
<Progress value={job.healthReportScore} className="w-16 h-2" />
<span className="text-muted-foreground">
{job.healthReportScore}%
</span>
@ -792,7 +509,6 @@ const JenkinsManager: React.FC = () => {
</Tooltip>
)}
</div>
{job.lastBuildTime && (
<div className="flex items-center gap-1 text-xs text-muted-foreground mt-2">
<Calendar className="h-3 w-3" />
@ -800,9 +516,19 @@ const JenkinsManager: React.FC = () => {
</div>
)}
</div>
))}
</InfiniteScroll>
)}
searchValue={jobSearch}
localFilter={(job, search) =>
job.jobName.toLowerCase().includes(search.toLowerCase())
}
selectedItem={selectedJob}
onItemClick={(job) => setSelectedJob(selectedJob?.id === job.id ? undefined : job)}
itemKey="id"
scrollableTarget="jobScrollableDiv"
emptyText={selectedView ? '当前视图暂无任务' : '暂无任务'}
emptyIcon={<Box className="h-12 w-12 mb-2 opacity-20" />}
resetTrigger={jobResetTrigger}
/>
</div>
</Card>
@ -810,20 +536,19 @@ const JenkinsManager: React.FC = () => {
<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">
({filteredBuilds.length}){selectedJob && ` - ${selectedJob.jobName}`}
<CardTitle className="text-base flex items-center gap-2">
({buildCount}){selectedJob && ` - ${selectedJob.jobName}`}
</CardTitle>
<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}
onClick={() => setBuildRefreshKey(prev => prev + 1)}
disabled={!selectedInstanceId}
title="刷新列表"
>
<RefreshCw
className={`h-4 w-4 ${loading.builds ? 'animate-spin' : ''}`}
/>
<RefreshCw className="h-4 w-4" />
</Button>
<Button
variant="ghost"
@ -831,10 +556,9 @@ const JenkinsManager: React.FC = () => {
className="h-7 w-7"
onClick={handleSyncBuilds}
disabled={syncing.builds || !selectedInstanceId}
title="从Jenkins同步"
>
<Download
className={`h-4 w-4 ${syncing.builds ? 'animate-bounce' : ''}`}
/>
<RotateCw className={`h-4 w-4 ${syncing.builds ? 'animate-spin' : ''}`} />
</Button>
</div>
</div>
@ -848,43 +572,17 @@ const JenkinsManager: React.FC = () => {
/>
</div>
</CardHeader>
<div
id="buildScrollableDiv"
className="flex-1 overflow-auto"
>
{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>
) : 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">{selectedJob ? '当前任务暂无构建' : '暂无构建'}</p>
</div>
) : (
<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"
>
{filteredBuilds.map((build) => (
<div
key={build.id}
className="group p-3 hover:bg-accent transition-colors border rounded-lg"
>
<div id="buildScrollableDiv" className="flex-1 overflow-auto">
<InfiniteScrollList<JenkinsBuildDTO>
loadData={(pageNum, size) => getJenkinsBuilds({
externalSystemId: selectedInstanceId!,
jobId: selectedJob?.id,
pageNum,
size,
})}
onDataChange={(data, total) => setBuildCount(data.length)}
renderItem={(build) => (
<div 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">
<span className="font-mono font-semibold text-sm">
@ -912,14 +610,12 @@ const JenkinsManager: React.FC = () => {
</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" />
@ -927,9 +623,18 @@ const JenkinsManager: React.FC = () => {
</div>
)}
</div>
))}
</InfiniteScroll>
)}
searchValue={buildSearch}
localFilter={(build, search) => {
const searchLower = search.toLowerCase();
return build.buildNumber.toString().includes(searchLower) ||
build.buildStatus.toLowerCase().includes(searchLower);
}}
scrollableTarget="buildScrollableDiv"
emptyText={selectedJob ? '当前任务暂无构建' : '暂无构建'}
emptyIcon={<GitBranch className="h-12 w-12 mb-2 opacity-20" />}
resetTrigger={buildResetTrigger}
/>
</div>
</Card>
</div>

View File

@ -15,7 +15,7 @@ export const getJenkinsInstances = () =>
// 获取视图列表
export const getJenkinsViews = (externalSystemId: number) =>
request.get<JenkinsViewDTO[]>(`/api/v1/jenkins-view`, {
request.get<JenkinsViewDTO[]>(`/api/v1/jenkins-view/list`, {
params: { externalSystemId }
});
@ -28,6 +28,7 @@ export const syncJenkinsViews = (externalSystemId: number) =>
// ==================== Jenkins 任务 ====================
// 获取任务列表(分页)
// 注意pageNum 从 1 开始,后端会自动转换为从 0 开始给 Spring Data JPA
export const getJenkinsJobs = (params: {
externalSystemId: number;
viewId?: number;
@ -36,7 +37,7 @@ export const getJenkinsJobs = (params: {
}) =>
request.get<Page<JenkinsJobDTO>>(`/api/v1/jenkins-job/page`, {
params: {
pageNum: params.pageNum ?? 0,
pageNum: params.pageNum ?? 1,
size: params.size ?? 20,
externalSystemId: params.externalSystemId,
viewId: params.viewId
@ -50,6 +51,7 @@ export const syncJenkinsJobs = (params: { externalSystemId: number; viewId?: num
// ==================== Jenkins 构建 ====================
// 获取构建列表(分页,按开始时间倒序排序)
// 注意pageNum 从 1 开始,后端会自动转换为从 0 开始给 Spring Data JPA
export const getJenkinsBuilds = (params: {
externalSystemId: number;
jobId?: number;
@ -58,7 +60,7 @@ export const getJenkinsBuilds = (params: {
}) =>
request.get<Page<JenkinsBuildDTO>>(`/api/v1/jenkins-build/page`, {
params: {
pageNum: params.pageNum ?? 0,
pageNum: params.pageNum ?? 1,
size: params.size ?? 20,
sortField: 'starttime',
sortOrder: 'desc',