feat(audit): enhance file selection and exclude patterns UI

- Update demo data to mark running task as completed with audit results
- Add reset to defaults button for exclude patterns in CreateTaskDialog
- Implement quick-add buttons for common exclude patterns (.test., .spec., .min., coverage/, docs/, .md)
- Improve exclude patterns input with better placeholder text and visual feedback
- Refactor FileSelectionDialog with tree-based folder structure for better file organization
- Add file type icons (code files, config files, generic files) for improved visual distinction
- Implement folder expansion/collapse functionality with chevron indicators
- Add file filtering by extension and search capabilities
- Enhance UI with better spacing, visual hierarchy, and user guidance
- Improve accessibility with proper icon imports and component organization
This commit is contained in:
lintsinghua 2025-12-10 19:20:31 +08:00
parent b872dc63fe
commit a995bef28c
5 changed files with 724 additions and 102 deletions

View File

@ -143,7 +143,7 @@ async def create_demo_data(db: AsyncSession, user: User) -> None:
# 项目2: 移动端 App
{"project_idx": 1, "status": "completed", "days_ago": 20, "files": 89, "lines": 8900, "issues": 15, "score": 68.7},
{"project_idx": 1, "status": "completed", "days_ago": 8, "files": 95, "lines": 9500, "issues": 8, "score": 82.1},
{"project_idx": 1, "status": "running", "days_ago": 0, "files": 98, "lines": 9800, "issues": 0, "score": 0},
{"project_idx": 1, "status": "completed", "days_ago": 1, "files": 98, "lines": 9800, "issues": 6, "score": 84.5},
# 项目3: 数据分析平台
{"project_idx": 2, "status": "completed", "days_ago": 12, "files": 45, "lines": 5600, "issues": 9, "score": 76.4},
{"project_idx": 2, "status": "completed", "days_ago": 2, "files": 52, "lines": 6200, "issues": 5, "score": 88.9},

View File

@ -445,10 +445,21 @@ export default function CreateTaskDialog({
</CollapsibleTrigger>
<CollapsibleContent className="mt-3 space-y-3">
{/* 排除模式 */}
<div className="p-3 border-2 border-dashed border-gray-400 bg-gray-50 space-y-2">
<span className="font-mono text-xs uppercase font-bold text-gray-600">
</span>
<div className="p-3 border-2 border-dashed border-gray-400 bg-gray-50 space-y-3">
<div className="flex items-center justify-between">
<span className="font-mono text-xs uppercase font-bold text-gray-600">
</span>
<button
type="button"
onClick={() => setExcludePatterns(DEFAULT_EXCLUDES)}
className="text-xs font-mono text-blue-600 hover:text-blue-800 hover:underline"
>
</button>
</div>
{/* 已选择的排除模式 */}
<div className="flex flex-wrap gap-1.5">
{excludePatterns.map((p) => (
<Badge
@ -464,9 +475,34 @@ export default function CreateTaskDialog({
{p} ×
</Badge>
))}
{excludePatterns.length === 0 && (
<span className="text-xs text-gray-400 font-mono"></span>
)}
</div>
{/* 快捷添加常用模式 */}
<div className="flex flex-wrap gap-1">
<span className="text-xs text-gray-500 font-mono mr-1">:</span>
{[".test.", ".spec.", ".min.", "coverage/", "docs/", ".md"].map((pattern) => (
<button
key={pattern}
type="button"
disabled={excludePatterns.includes(pattern)}
onClick={() => {
if (!excludePatterns.includes(pattern)) {
setExcludePatterns((prev) => [...prev, pattern]);
}
}}
className="text-xs font-mono px-1.5 py-0.5 border border-gray-300 bg-white hover:bg-gray-100 disabled:opacity-40 disabled:cursor-not-allowed"
>
+{pattern}
</button>
))}
</div>
{/* 自定义输入 */}
<Input
placeholder="添加排除模式,回车确认"
placeholder="添加自定义排除模式,回车确认(如: .log, temp/, secret"
className="h-8 rounded-none border-2 border-black font-mono text-sm focus:ring-0"
onKeyDown={(e) => {
if (e.key === "Enter" && e.currentTarget.value) {

View File

@ -1,11 +1,31 @@
import { useState, useEffect, useMemo } from "react";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
import React, { useState, useEffect, useMemo, useCallback } from "react";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Checkbox } from "@/components/ui/checkbox";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Badge } from "@/components/ui/badge";
import { Search, FileText, CheckSquare, Square, FolderOpen } from "lucide-react";
import {
Search,
FileText,
CheckSquare,
Square,
FolderOpen,
Folder,
ChevronRight,
ChevronDown,
FileCode,
FileJson,
File,
Filter,
RotateCcw,
RefreshCw,
} from "lucide-react";
import { api } from "@/shared/config/database";
import { toast } from "sonner";
@ -23,30 +43,119 @@ interface FileNode {
size: number;
}
export default function FileSelectionDialog({ open, onOpenChange, projectId, branch, excludePatterns, onConfirm }: FileSelectionDialogProps) {
interface FolderNode {
name: string;
path: string;
files: FileNode[];
subfolders: Map<string, FolderNode>;
expanded: boolean;
}
// 文件类型图标映射
const getFileIcon = (path: string) => {
const ext = path.split(".").pop()?.toLowerCase() || "";
const codeExts = [
"js", "ts", "tsx", "jsx", "py", "java", "go", "rs", "cpp", "c", "h",
"cs", "php", "rb", "swift", "kt", "sh",
];
const configExts = ["json", "yml", "yaml", "toml", "xml", "ini"];
if (codeExts.includes(ext)) {
return <FileCode className="w-4 h-4 text-blue-600" />;
}
if (configExts.includes(ext)) {
return <FileJson className="w-4 h-4 text-yellow-600" />;
}
return <File className="w-4 h-4 text-gray-500" />;
};
// 获取文件扩展名
const getExtension = (path: string): string => {
const ext = path.split(".").pop()?.toLowerCase() || "";
return ext;
};
// 构建文件夹树结构
const buildFolderTree = (files: FileNode[]): FolderNode => {
const root: FolderNode = {
name: "",
path: "",
files: [],
subfolders: new Map(),
expanded: true,
};
files.forEach((file) => {
const parts = file.path.split("/");
let current = root;
// 遍历路径的每个部分(除了文件名)
for (let i = 0; i < parts.length - 1; i++) {
const folderName = parts[i];
const folderPath = parts.slice(0, i + 1).join("/");
if (!current.subfolders.has(folderName)) {
current.subfolders.set(folderName, {
name: folderName,
path: folderPath,
files: [],
subfolders: new Map(),
expanded: true,
});
}
current = current.subfolders.get(folderName)!;
}
// 添加文件到当前文件夹
current.files.push(file);
});
return root;
};
export default function FileSelectionDialog({
open,
onOpenChange,
projectId,
branch,
excludePatterns,
onConfirm,
}: FileSelectionDialogProps) {
const [files, setFiles] = useState<FileNode[]>([]);
const [loading, setLoading] = useState(false);
const [selectedFiles, setSelectedFiles] = useState<Set<string>>(new Set());
const [searchTerm, setSearchTerm] = useState("");
const [expandedFolders, setExpandedFolders] = useState<Set<string>>(new Set());
const [viewMode, setViewMode] = useState<"tree" | "flat">("tree");
const [filterType, setFilterType] = useState<string>("");
useEffect(() => {
if (open && projectId) {
loadFiles();
} else {
// Reset state when closed
setFiles([]);
setSelectedFiles(new Set());
setSearchTerm("");
setExpandedFolders(new Set());
setFilterType("");
}
}, [open, projectId, branch, excludePatterns]);
const loadFiles = async () => {
try {
setLoading(true);
// 传入排除模式,让后端过滤文件
const data = await api.getProjectFiles(projectId, branch, excludePatterns);
setFiles(data);
setSelectedFiles(new Set(data.map(f => f.path)));
setSelectedFiles(new Set(data.map((f) => f.path)));
// 默认展开所有文件夹
const folders = new Set<string>();
data.forEach((f) => {
const parts = f.path.split("/");
for (let i = 1; i < parts.length; i++) {
folders.add(parts.slice(0, i).join("/"));
}
});
setExpandedFolders(folders);
} catch (error) {
console.error("Failed to load files:", error);
toast.error("加载文件列表失败");
@ -55,31 +164,113 @@ export default function FileSelectionDialog({ open, onOpenChange, projectId, bra
}
};
const filteredFiles = useMemo(() => {
if (!searchTerm) return files;
return files.filter(f => f.path.toLowerCase().includes(searchTerm.toLowerCase()));
}, [files, searchTerm]);
// 获取所有文件类型
const fileTypes = useMemo(() => {
const types = new Map<string, number>();
files.forEach((f) => {
const ext = getExtension(f.path);
if (ext) {
types.set(ext, (types.get(ext) || 0) + 1);
}
});
return Array.from(types.entries()).sort((a, b) => b[1] - a[1]);
}, [files]);
const handleToggleFile = (path: string) => {
const newSelected = new Set(selectedFiles);
if (newSelected.has(path)) {
newSelected.delete(path);
} else {
newSelected.add(path);
// 过滤后的文件
const filteredFiles = useMemo(() => {
let result = files;
// 按搜索词过滤
if (searchTerm) {
const term = searchTerm.toLowerCase();
result = result.filter((f) => f.path.toLowerCase().includes(term));
}
setSelectedFiles(newSelected);
};
// 按文件类型过滤
if (filterType) {
result = result.filter((f) => getExtension(f.path) === filterType);
}
return result;
}, [files, searchTerm, filterType]);
// 构建文件夹树
const folderTree = useMemo(() => buildFolderTree(filteredFiles), [filteredFiles]);
const handleToggleFile = useCallback((path: string) => {
setSelectedFiles((prev) => {
const newSelected = new Set(prev);
if (newSelected.has(path)) {
newSelected.delete(path);
} else {
newSelected.add(path);
}
return newSelected;
});
}, []);
const handleToggleFolder = useCallback(
(folderPath: string) => {
// 获取该文件夹下的所有文件
const folderFiles = filteredFiles.filter(
(f) => f.path.startsWith(folderPath + "/") || f.path === folderPath
);
setSelectedFiles((prev) => {
const newSelected = new Set(prev);
const allSelected = folderFiles.every((f) => newSelected.has(f.path));
if (allSelected) {
// 取消选择该文件夹下的所有文件
folderFiles.forEach((f) => newSelected.delete(f.path));
} else {
// 选择该文件夹下的所有文件
folderFiles.forEach((f) => newSelected.add(f.path));
}
return newSelected;
});
},
[filteredFiles]
);
const handleExpandFolder = useCallback((folderPath: string) => {
setExpandedFolders((prev) => {
const newExpanded = new Set(prev);
if (newExpanded.has(folderPath)) {
newExpanded.delete(folderPath);
} else {
newExpanded.add(folderPath);
}
return newExpanded;
});
}, []);
const handleSelectAll = () => {
const newSelected = new Set(selectedFiles);
filteredFiles.forEach(f => newSelected.add(f.path));
setSelectedFiles(newSelected);
setSelectedFiles(new Set(filteredFiles.map((f) => f.path)));
};
const handleDeselectAll = () => {
const newSelected = new Set(selectedFiles);
filteredFiles.forEach(f => newSelected.delete(f.path));
setSelectedFiles(newSelected);
const filteredPaths = new Set(filteredFiles.map((f) => f.path));
setSelectedFiles((prev) => {
const newSelected = new Set(prev);
filteredPaths.forEach((p) => newSelected.delete(p));
return newSelected;
});
};
const handleInvertSelection = () => {
const filteredPaths = new Set(filteredFiles.map((f) => f.path));
setSelectedFiles((prev) => {
const newSelected = new Set(prev);
filteredPaths.forEach((p) => {
if (newSelected.has(p)) {
newSelected.delete(p);
} else {
newSelected.add(p);
}
});
return newSelected;
});
};
const handleConfirm = () => {
@ -98,99 +289,346 @@ export default function FileSelectionDialog({ open, onOpenChange, projectId, bra
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-3xl max-h-[85vh] flex flex-col bg-white border-2 border-black p-0 shadow-[8px_8px_0px_0px_rgba(0,0,0,1)] rounded-none">
<DialogHeader className="p-6 border-b-2 border-black bg-gray-50 flex-shrink-0">
<DialogTitle className="flex items-center justify-between">
<div className="flex items-center space-x-2 font-display font-bold uppercase text-xl">
<FolderOpen className="w-6 h-6 text-black" />
<span></span>
// 检查文件夹的选中状态
const getFolderSelectionState = (
folderPath: string
): "all" | "some" | "none" => {
const folderFiles = filteredFiles.filter((f) =>
f.path.startsWith(folderPath + "/")
);
if (folderFiles.length === 0) return "none";
const selectedCount = folderFiles.filter((f) =>
selectedFiles.has(f.path)
).length;
if (selectedCount === 0) return "none";
if (selectedCount === folderFiles.length) return "all";
return "some";
};
// 渲染文件夹树
const renderFolderTree = (node: FolderNode, depth: number = 0) => {
const items: React.ReactNode[] = [];
// 渲染子文件夹
Array.from(node.subfolders.values())
.sort((a, b) => a.name.localeCompare(b.name))
.forEach((folder) => {
const isExpanded = expandedFolders.has(folder.path);
const selectionState = getFolderSelectionState(folder.path);
items.push(
<div key={`folder-${folder.path}`}>
<div
className="flex items-center space-x-2 p-2 hover:bg-white border border-transparent hover:border-gray-200 cursor-pointer transition-colors"
style={{ paddingLeft: `${depth * 16 + 8}px` }}
>
<button
onClick={(e) => {
e.stopPropagation();
handleExpandFolder(folder.path);
}}
className="p-0.5 hover:bg-gray-200 rounded"
>
{isExpanded ? (
<ChevronDown className="w-4 h-4 text-gray-500" />
) : (
<ChevronRight className="w-4 h-4 text-gray-500" />
)}
</button>
<div onClick={(e) => e.stopPropagation()}>
<Checkbox
checked={selectionState === "all"}
ref={(el) => {
if (el) {
(el as HTMLButtonElement).dataset.state =
selectionState === "some" ? "indeterminate" : selectionState === "all" ? "checked" : "unchecked";
}
}}
onCheckedChange={() => handleToggleFolder(folder.path)}
className="rounded-none border-2 border-black data-[state=checked]:bg-primary data-[state=indeterminate]:bg-gray-400"
/>
</div>
{isExpanded ? (
<FolderOpen className="w-4 h-4 text-amber-500" />
) : (
<Folder className="w-4 h-4 text-amber-500" />
)}
<span
className="text-sm font-mono font-medium flex-1"
onClick={() => handleExpandFolder(folder.path)}
>
{folder.name}
</span>
<Badge
variant="outline"
className="text-xs font-mono rounded-none border-gray-300 text-gray-500"
>
{
filteredFiles.filter((f) =>
f.path.startsWith(folder.path + "/")
).length
}
</Badge>
</div>
{excludePatterns && excludePatterns.length > 0 && (
<Badge variant="outline" className="rounded-none border-gray-400 text-gray-600 font-mono text-xs">
{excludePatterns.length}
{isExpanded && renderFolderTree(folder, depth + 1)}
</div>
);
});
// 渲染文件
node.files
.sort((a, b) => a.path.localeCompare(b.path))
.forEach((file) => {
const fileName = file.path.split("/").pop() || file.path;
items.push(
<div
key={`file-${file.path}`}
className="flex items-center space-x-3 p-2 hover:bg-white border border-transparent hover:border-gray-200 cursor-pointer transition-colors"
style={{ paddingLeft: `${depth * 16 + 32}px` }}
onClick={() => handleToggleFile(file.path)}
>
<div onClick={(e) => e.stopPropagation()}>
<Checkbox
checked={selectedFiles.has(file.path)}
onCheckedChange={() => handleToggleFile(file.path)}
className="rounded-none border-2 border-black data-[state=checked]:bg-primary data-[state=checked]:text-white"
/>
</div>
{getFileIcon(file.path)}
<span
className="text-sm font-mono flex-1 min-w-0 truncate"
title={file.path}
>
{fileName}
</span>
{file.size > 0 && (
<Badge
variant="outline"
className="text-xs font-mono rounded-none border-gray-400 text-gray-500 flex-shrink-0"
>
{formatSize(file.size)}
</Badge>
)}
</div>
);
});
return items;
};
// 渲染扁平列表
const renderFlatList = () => {
return filteredFiles.map((file) => (
<div
key={file.path}
className="flex items-center space-x-3 p-2 hover:bg-white border border-transparent hover:border-gray-200 cursor-pointer transition-colors"
onClick={() => handleToggleFile(file.path)}
>
<div onClick={(e) => e.stopPropagation()}>
<Checkbox
checked={selectedFiles.has(file.path)}
onCheckedChange={() => handleToggleFile(file.path)}
className="rounded-none border-2 border-black data-[state=checked]:bg-primary data-[state=checked]:text-white"
/>
</div>
{getFileIcon(file.path)}
<div className="flex-1 min-w-0">
<p className="text-sm font-mono truncate" title={file.path}>
{file.path}
</p>
</div>
{file.size > 0 && (
<Badge
variant="outline"
className="text-xs font-mono rounded-none border-gray-400 text-gray-500 flex-shrink-0"
>
{formatSize(file.size)}
</Badge>
)}
</div>
));
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="!max-w-[1000px] !w-[95vw] max-h-[85vh] flex flex-col bg-white border-2 border-black p-0 shadow-[8px_8px_0px_0px_rgba(0,0,0,1)] rounded-none">
<DialogHeader className="p-5 border-b-2 border-black bg-gray-50 flex-shrink-0">
<DialogTitle className="flex items-center justify-between">
<div className="flex items-center space-x-2 font-display font-bold uppercase text-lg">
<FolderOpen className="w-5 h-5 text-black" />
<span></span>
</div>
<div className="flex items-center gap-2">
{excludePatterns && excludePatterns.length > 0 && (
<Badge
variant="outline"
className="rounded-none border-gray-400 text-gray-600 font-mono text-xs"
>
{excludePatterns.length}
</Badge>
)}
</div>
</DialogTitle>
</DialogHeader>
<div className="p-6 flex-1 flex flex-col min-h-0 space-y-4">
<div className="flex items-center space-x-2">
<div className="relative flex-1">
<div className="p-5 flex-1 flex flex-col min-h-0 space-y-3">
{/* 工具栏 */}
<div className="flex items-center gap-2 flex-wrap">
{/* 搜索框 */}
<div className="relative flex-1 min-w-[200px]">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-500 w-4 h-4" />
<Input
placeholder="搜索文件..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="pl-10 retro-input h-10"
className="pl-10 h-9 rounded-none border-2 border-black font-mono text-sm"
/>
</div>
<Button variant="outline" onClick={handleSelectAll} className="retro-btn bg-white text-black h-10 px-3">
<CheckSquare className="w-4 h-4 mr-2" />
</Button>
<Button variant="outline" onClick={handleDeselectAll} className="retro-btn bg-white text-black h-10 px-3">
<Square className="w-4 h-4 mr-2" />
</Button>
{/* 文件类型筛选 */}
{fileTypes.length > 0 && (
<div className="flex items-center gap-1">
<Filter className="w-4 h-4 text-gray-500" />
<select
value={filterType}
onChange={(e) => setFilterType(e.target.value)}
className="h-9 px-2 rounded-none border-2 border-black font-mono text-sm bg-white"
>
<option value=""></option>
{fileTypes.slice(0, 10).map(([ext, count]) => (
<option key={ext} value={ext}>
.{ext} ({count})
</option>
))}
</select>
</div>
)}
{/* 视图切换 */}
<div className="flex border-2 border-black">
<button
onClick={() => setViewMode("tree")}
className={`px-2 py-1 text-xs font-mono ${viewMode === "tree" ? "bg-black text-white" : "bg-white text-black hover:bg-gray-100"}`}
>
</button>
<button
onClick={() => setViewMode("flat")}
className={`px-2 py-1 text-xs font-mono border-l-2 border-black ${viewMode === "flat" ? "bg-black text-white" : "bg-white text-black hover:bg-gray-100"}`}
>
</button>
</div>
</div>
<div className="flex items-center justify-between text-sm font-mono font-bold text-gray-600">
<span> {files.length} </span>
<span> {selectedFiles.size} </span>
{/* 操作按钮 */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={handleSelectAll}
className="h-8 px-3 rounded-none border-2 border-black font-mono text-xs"
>
<CheckSquare className="w-3 h-3 mr-1" />
</Button>
<Button
variant="outline"
size="sm"
onClick={handleDeselectAll}
className="h-8 px-3 rounded-none border-2 border-black font-mono text-xs"
>
<Square className="w-3 h-3 mr-1" />
</Button>
<Button
variant="outline"
size="sm"
onClick={handleInvertSelection}
className="h-8 px-3 rounded-none border-2 border-black font-mono text-xs"
>
<RefreshCw className="w-3 h-3 mr-1" />
</Button>
{(searchTerm || filterType) && (
<Button
variant="outline"
size="sm"
onClick={() => {
setSearchTerm("");
setFilterType("");
}}
className="h-8 px-3 rounded-none border-2 border-gray-400 font-mono text-xs text-gray-600"
>
<RotateCcw className="w-3 h-3 mr-1" />
</Button>
)}
</div>
<div className="text-sm font-mono text-gray-600">
{searchTerm || filterType ? (
<span>
: {filteredFiles.length}/{files.length}
{selectedFiles.size}
</span>
) : (
<span>
{files.length} {selectedFiles.size}
</span>
)}
</div>
</div>
<div className="border-2 border-black bg-gray-50 relative overflow-hidden" style={{ height: '300px' }}>
{/* 文件列表 - 使用固定高度确保滚动正常工作 */}
<div className="border-2 border-black bg-gray-50 relative h-[450px] overflow-hidden">
{loading ? (
<div className="absolute inset-0 flex items-center justify-center">
<div className="animate-spin rounded-none h-8 w-8 border-4 border-primary border-t-transparent"></div>
</div>
) : filteredFiles.length > 0 ? (
<ScrollArea className="h-full w-full p-2">
<div className="space-y-1">
{filteredFiles.map((file) => (
<div
key={file.path}
className="flex items-center space-x-3 p-2 hover:bg-white border border-transparent hover:border-gray-200 cursor-pointer transition-colors"
onClick={() => handleToggleFile(file.path)}
>
<Checkbox
checked={selectedFiles.has(file.path)}
onCheckedChange={() => handleToggleFile(file.path)}
className="rounded-none border-2 border-black data-[state=checked]:bg-primary data-[state=checked]:text-white"
/>
<div className="flex-1 min-w-0">
<p className="text-sm font-mono truncate" title={file.path}>
{file.path}
</p>
</div>
{file.size > 0 && (
<Badge variant="outline" className="text-xs font-mono rounded-none border-gray-400 text-gray-500">
{formatSize(file.size)}
</Badge>
)}
</div>
))}
<div className="h-full overflow-auto">
<div className="p-2">
{viewMode === "tree"
? renderFolderTree(folderTree)
: renderFlatList()}
</div>
</ScrollArea>
</div>
) : (
<div className="absolute inset-0 flex flex-col items-center justify-center text-gray-500">
<FileText className="w-12 h-12 mb-2 opacity-20" />
<p className="font-mono text-sm"></p>
<p className="font-mono text-sm">
{searchTerm || filterType
? "没有匹配的文件"
: "没有找到文件"}
</p>
</div>
)}
</div>
</div>
<DialogFooter className="p-6 border-t-2 border-black bg-gray-50 flex-shrink-0">
<Button variant="outline" onClick={() => onOpenChange(false)} className="retro-btn bg-white text-black hover:bg-gray-100 mr-2">
</Button>
<Button onClick={handleConfirm} className="retro-btn bg-primary text-white hover:bg-primary/90">
<FileText className="w-4 h-4 mr-2" />
({selectedFiles.size})
</Button>
<DialogFooter className="p-5 border-t-2 border-black bg-gray-50 flex-shrink-0 flex justify-between">
<div className="text-xs font-mono text-gray-500">
/
</div>
<div className="flex gap-2">
<Button
variant="outline"
onClick={() => onOpenChange(false)}
className="px-4 h-9 rounded-none border-2 border-black font-mono"
>
</Button>
<Button
onClick={handleConfirm}
disabled={selectedFiles.size === 0}
className="px-4 h-9 rounded-none border-2 border-black bg-primary text-white font-mono shadow-[3px_3px_0px_0px_rgba(0,0,0,1)] hover:shadow-[1px_1px_0px_0px_rgba(0,0,0,1)] hover:translate-x-[2px] hover:translate-y-[2px] transition-all disabled:opacity-50"
>
<FileText className="w-4 h-4 mr-2" />
({selectedFiles.size})
</Button>
</div>
</DialogFooter>
</DialogContent>
</Dialog>

View File

@ -1,4 +1,4 @@
import { useState, useEffect } from "react";
import { useState, useEffect, useRef } from "react";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
@ -11,7 +11,8 @@ import {
Search,
FileText,
Calendar,
Plus
Plus,
XCircle
} from "lucide-react";
import { api } from "@/shared/config/database";
import type { AuditTask } from "@/shared/types";
@ -20,12 +21,19 @@ import { toast } from "sonner";
import CreateTaskDialog from "@/components/audit/CreateTaskDialog";
import { calculateTaskProgress } from "@/shared/utils/utils";
// 僵尸任务检测配置
const ZOMBIE_TIMEOUT = 180000; // 3分钟无进度变化视为可能的僵尸任务
export default function AuditTasks() {
const [tasks, setTasks] = useState<AuditTask[]>([]);
const [loading, setLoading] = useState(true);
const [searchTerm, setSearchTerm] = useState("");
const [statusFilter, setStatusFilter] = useState<string>("all");
const [showCreateDialog, setShowCreateDialog] = useState(false);
const [cancellingTaskId, setCancellingTaskId] = useState<string | null>(null);
// 僵尸任务检测:记录每个任务的上次进度和时间
const taskProgressRef = useRef<Map<string, { progress: number; time: number }>>(new Map());
useEffect(() => {
loadTasks();
@ -38,6 +46,8 @@ export default function AuditTasks() {
);
if (activeTasks.length === 0) {
// 清空进度记录
taskProgressRef.current.clear();
return;
}
@ -50,12 +60,45 @@ export default function AuditTasks() {
setTasks(prevTasks => {
return prevTasks.map(prevTask => {
const updated = updatedData.find(t => t.id === prevTask.id);
if (!updated) return prevTask;
// 僵尸任务检测
if (updated.status === 'running') {
const currentProgress = updated.scanned_files || 0;
const lastRecord = taskProgressRef.current.get(updated.id);
if (lastRecord) {
if (currentProgress !== lastRecord.progress) {
// 进度有变化,更新记录
taskProgressRef.current.set(updated.id, { progress: currentProgress, time: Date.now() });
} else if (Date.now() - lastRecord.time > ZOMBIE_TIMEOUT) {
// 超时无进度变化,提示用户
toast.warning(`任务 "${updated.project?.name || '未知'}" 可能已停止响应`, {
id: `zombie-${updated.id}`,
duration: 10000,
action: {
label: '取消任务',
onClick: () => handleCancelTask(updated.id),
},
});
// 重置时间避免重复提示
taskProgressRef.current.set(updated.id, { progress: currentProgress, time: Date.now() });
}
} else {
// 首次记录
taskProgressRef.current.set(updated.id, { progress: currentProgress, time: Date.now() });
}
} else {
// 任务不再运行,清除记录
taskProgressRef.current.delete(updated.id);
}
// 只有在进度、状态或问题数真正变化时才更新
if (updated && (
if (
updated.status !== prevTask.status ||
updated.scanned_files !== prevTask.scanned_files ||
updated.issues_count !== prevTask.issues_count
)) {
) {
return updated;
}
return prevTask;
@ -63,11 +106,33 @@ export default function AuditTasks() {
});
} catch (error) {
console.error('静默更新任务列表失败:', error);
toast.error("获取任务状态失败,请检查网络连接", {
id: 'network-error',
duration: 5000,
});
}
}, 3000); // 每3秒静默更新一次
return () => clearInterval(intervalId);
}, [tasks.map(t => t.id + t.status).join(',')]);
// 取消任务
const handleCancelTask = async (taskId: string) => {
if (cancellingTaskId) return;
try {
setCancellingTaskId(taskId);
await api.cancelAuditTask(taskId);
toast.success("任务已取消");
// 刷新任务列表
await loadTasks();
} catch (error: any) {
console.error('取消任务失败:', error);
toast.error(error?.response?.data?.detail || "取消任务失败");
} finally {
setCancellingTaskId(null);
}
};
const loadTasks = async () => {
try {
@ -321,6 +386,19 @@ export default function AuditTasks() {
</div>
<div className="flex gap-3">
{/* 运行中或等待中的任务显示取消按钮 */}
{(task.status === 'running' || task.status === 'pending') && (
<Button
variant="destructive"
size="sm"
className="retro-btn bg-red-600 text-white hover:bg-red-700 h-9"
onClick={() => handleCancelTask(task.id)}
disabled={cancellingTaskId === task.id}
>
<XCircle className="w-4 h-4 mr-2" />
{cancellingTaskId === task.id ? '取消中...' : '取消'}
</Button>
)}
<Link to={`/tasks/${task.id}`}>
<Button variant="outline" size="sm" className="retro-btn bg-white text-black hover:bg-gray-100 h-9">
<FileText className="w-4 h-4 mr-2" />

View File

@ -20,7 +20,8 @@ import {
Code,
Lightbulb,
Info,
Zap
Zap,
XCircle
} from "lucide-react";
import { api } from "@/shared/config/database";
import type { AuditTask, AuditIssue } from "@/shared/types";
@ -322,6 +323,12 @@ export default function TaskDetail() {
const [issues, setIssues] = useState<AuditIssue[]>([]);
const [loading, setLoading] = useState(true);
const [exportDialogOpen, setExportDialogOpen] = useState(false);
const [cancelling, setCancelling] = useState(false);
// 僵尸任务检测:记录上次进度变化时间
const [lastProgressTime, setLastProgressTime] = useState<number>(Date.now());
const [lastProgress, setLastProgress] = useState<number>(0);
const ZOMBIE_TIMEOUT = 180000; // 3分钟无进度变化视为僵尸任务
useEffect(() => {
if (id) {
@ -345,23 +352,72 @@ export default function TaskDetail() {
api.getAuditIssues(id)
]);
if (!taskData) {
console.error('任务数据获取失败');
return;
}
// 检测僵尸任务:如果任务仍在运行但长时间无进度变化
const currentProgress = taskData.scanned_files || 0;
if (currentProgress !== lastProgress) {
setLastProgress(currentProgress);
setLastProgressTime(Date.now());
} else if (taskData.status === 'running' && Date.now() - lastProgressTime > ZOMBIE_TIMEOUT) {
// 可能是僵尸任务,提示用户
toast.warning("任务可能已停止响应,建议取消后重试", {
id: 'zombie-warning',
duration: 10000,
});
}
// 只有数据真正变化时才更新状态
if (taskData && (
if (
taskData.status !== task.status ||
taskData.scanned_files !== task.scanned_files ||
taskData.issues_count !== task.issues_count
)) {
) {
setTask(taskData);
setIssues(issuesData);
// 如果任务已完成/失败/取消,停止轮询
if (['completed', 'failed', 'cancelled'].includes(taskData.status)) {
clearInterval(intervalId);
}
}
} catch (error) {
console.error('静默更新任务失败:', error);
// 网络错误时也提示用户
toast.error("获取任务状态失败,请检查网络连接", {
id: 'network-error',
duration: 5000,
});
}
}, 3000); // 每3秒静默更新一次
return () => clearInterval(intervalId);
}
}, [task?.status, task?.scanned_files, id]);
}, [task?.status, task?.scanned_files, id, lastProgress, lastProgressTime]);
// 取消任务
const handleCancelTask = async () => {
if (!id || cancelling) return;
try {
setCancelling(true);
await api.cancelAuditTask(id);
toast.success("任务已取消");
// 刷新任务状态
const taskData = await api.getAuditTaskById(id);
if (taskData) {
setTask(taskData);
}
} catch (error: any) {
console.error('取消任务失败:', error);
toast.error(error?.response?.data?.detail || "取消任务失败");
} finally {
setCancelling(false);
}
};
const loadTaskDetail = async () => {
if (!id) return;
@ -470,6 +526,20 @@ export default function TaskDetail() {
</span>
</Badge>
{/* 运行中或等待中的任务显示取消按钮 */}
{(task.status === 'running' || task.status === 'pending') && (
<Button
size="sm"
variant="destructive"
className="retro-btn bg-red-600 text-white hover:bg-red-700 h-10"
onClick={handleCancelTask}
disabled={cancelling}
>
<XCircle className="w-4 h-4 mr-2" />
{cancelling ? '取消中...' : '取消任务'}
</Button>
)}
{/* 已完成的任务显示导出按钮 */}
{task.status === 'completed' && (
<Button