重构前端逻辑

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

View File

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

View File

@ -1,15 +1,7 @@
import React from 'react'; 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 { AlertCircle } from 'lucide-react';
import { useToast } from '@/components/ui/use-toast'; import { useToast } from '@/components/ui/use-toast';
import { ConfirmDialog } from '@/components/ui/confirm-dialog';
import { deleteTeam } from '../service'; import { deleteTeam } from '../service';
import type { TeamResponse } from '../types'; import type { TeamResponse } from '../types';
@ -33,92 +25,83 @@ const DeleteDialog: React.FC<DeleteDialogProps> = ({
const handleConfirm = async () => { const handleConfirm = async () => {
if (!record) return; if (!record) return;
await deleteTeam(record.id);
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; if (!record) return null;
return ( return (
<Dialog open={open} onOpenChange={onOpenChange}> <ConfirmDialog
<DialogContent className="max-w-md"> open={open}
<DialogHeader> onOpenChange={onOpenChange}
<DialogTitle className="flex items-center gap-2"> title={
<AlertCircle className="h-5 w-5 text-destructive" /> <div className="flex items-center gap-2">
<AlertCircle className="h-5 w-5 text-destructive" />
</DialogTitle>
<DialogDescription> </div>
}
</DialogDescription> description="此操作将永久删除该团队,该操作无法撤销。"
</DialogHeader> item={record}
onConfirm={handleConfirm}
<div className="space-y-3 py-4"> onSuccess={() => {
<div className="rounded-lg bg-muted p-4 space-y-2"> toast({
<div className="flex justify-between items-center"> title: '删除成功',
<span className="text-sm text-muted-foreground"></span> description: `团队 "${record.teamName}" 已删除`
<code className="text-sm font-mono bg-background px-2 py-1 rounded"> });
{record.teamCode} onSuccess();
</code> }}
</div> variant="destructive"
<div className="flex justify-between items-center"> confirmText="确认删除"
<span className="text-sm text-muted-foreground"></span> maxWidth="md"
<span className="text-sm font-medium">{record.teamName}</span> >
</div> <div className="space-y-3">
{record.ownerName && ( <div className="rounded-lg bg-muted p-4 space-y-2">
<div className="flex justify-between items-center"> <div className="flex justify-between items-center">
<span className="text-sm text-muted-foreground"></span> <span className="text-sm text-muted-foreground"></span>
<span className="text-sm">{record.ownerName}</span> <code className="text-sm font-mono bg-background px-2 py-1 rounded">
</div> {record.teamCode}
)} </code>
{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>
</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>
</div>
)}
</div> </div>
<div className="flex justify-between items-center">
{((record.memberCount !== undefined && record.memberCount > 0) || <span className="text-sm text-muted-foreground"></span>
(record.applicationCount !== undefined && record.applicationCount > 0)) && ( <span className="text-sm font-medium">{record.teamName}</span>
<div className="flex items-start gap-2 p-3 rounded-lg bg-destructive/10 text-destructive"> </div>
<AlertCircle className="h-4 w-4 mt-0.5 flex-shrink-0" /> {record.ownerName && (
<p className="text-sm"> <div className="flex justify-between items-center">
{record.memberCount || 0}{record.applicationCount || 0} <span className="text-sm text-muted-foreground"></span>
</p> <span className="text-sm">{record.ownerName}</span>
</div>
)}
{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>
</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>
</div> </div>
)} )}
</div> </div>
<DialogFooter> {((record.memberCount !== undefined && record.memberCount > 0) ||
<Button variant="outline" onClick={() => onOpenChange(false)}> (record.applicationCount !== undefined && record.applicationCount > 0)) && (
<div className="flex items-start gap-2 p-3 rounded-lg bg-destructive/10 text-destructive">
</Button> <AlertCircle className="h-4 w-4 mt-0.5 flex-shrink-0" />
<Button variant="destructive" onClick={handleConfirm}> <p className="text-sm">
{record.memberCount || 0}{record.applicationCount || 0}
</Button> </p>
</DialogFooter> </div>
</DialogContent> )}
</Dialog> </div>
</ConfirmDialog>
); );
}; };
export default DeleteDialog; export default DeleteDialog;

View File

@ -5,16 +5,7 @@ import {
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
} from "@/components/ui/dialog"; } from "@/components/ui/dialog";
import { import { ConfirmDialog } from '@/components/ui/confirm-dialog';
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { import {
@ -245,25 +236,7 @@ const MemberManageDialog: React.FC<MemberManageDialogProps> = ({
// 确认删除 // 确认删除
const handleDeleteConfirm = async () => { const handleDeleteConfirm = async () => {
if (!deleteRecord) return; if (!deleteRecord) return;
await deleteTeamMember(deleteRecord.id);
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> </Dialog>
{/* 删除确认对话框 */} {/* 删除确认对话框 */}
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}> <ConfirmDialog
<AlertDialogContent> open={deleteDialogOpen}
<AlertDialogHeader> onOpenChange={setDeleteDialogOpen}
<AlertDialogTitle></AlertDialogTitle> title="确认删除"
<AlertDialogDescription> description={`确定要将成员 "${deleteRecord?.userName}" 从团队中移除吗?此操作无法撤销。`}
"{deleteRecord?.userName}" item={deleteRecord}
</AlertDialogDescription> onConfirm={handleDeleteConfirm}
</AlertDialogHeader> onSuccess={() => {
<AlertDialogFooter> toast({
<AlertDialogCancel onClick={() => setDeleteDialogOpen(false)}> title: "删除成功",
description: `成员 "${deleteRecord?.userName}" 已移除`,
</AlertDialogCancel> });
<AlertDialogAction onClick={handleDeleteConfirm} className="bg-destructive text-destructive-foreground hover:bg-destructive/90"> setDeleteRecord(null);
loadMembers();
</AlertDialogAction> onSuccess?.();
</AlertDialogFooter> }}
</AlertDialogContent> variant="destructive"
</AlertDialog> confirmText="确认删除"
/>
</> </>
); );
}; };

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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