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:
parent
b872dc63fe
commit
a995bef28c
|
|
@ -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},
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
<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) {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
// 过滤后的文件
|
||||
const filteredFiles = useMemo(() => {
|
||||
let result = files;
|
||||
|
||||
// 按搜索词过滤
|
||||
if (searchTerm) {
|
||||
const term = searchTerm.toLowerCase();
|
||||
result = result.filter((f) => f.path.toLowerCase().includes(term));
|
||||
}
|
||||
|
||||
// 按文件类型过滤
|
||||
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);
|
||||
}
|
||||
setSelectedFiles(newSelected);
|
||||
};
|
||||
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>
|
||||
</div>
|
||||
{excludePatterns && excludePatterns.length > 0 && (
|
||||
<Badge variant="outline" className="rounded-none border-gray-400 text-gray-600 font-mono text-xs">
|
||||
已排除 {excludePatterns.length} 种模式
|
||||
</Badge>
|
||||
)}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
// 检查文件夹的选中状态
|
||||
const getFolderSelectionState = (
|
||||
folderPath: string
|
||||
): "all" | "some" | "none" => {
|
||||
const folderFiles = filteredFiles.filter((f) =>
|
||||
f.path.startsWith(folderPath + "/")
|
||||
);
|
||||
if (folderFiles.length === 0) return "none";
|
||||
|
||||
<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">
|
||||
<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"
|
||||
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>
|
||||
<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>
|
||||
{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>
|
||||
{isExpanded && renderFolderTree(folder, depth + 1)}
|
||||
</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>
|
||||
|
||||
<div className="border-2 border-black bg-gray-50 relative overflow-hidden" style={{ height: '300px' }}>
|
||||
{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) => (
|
||||
// 渲染文件
|
||||
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.path}
|
||||
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">
|
||||
<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-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 h-9 rounded-none border-2 border-black font-mono text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 文件类型筛选 */}
|
||||
{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">
|
||||
<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 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 ? (
|
||||
<div className="h-full overflow-auto">
|
||||
<div className="p-2">
|
||||
{viewMode === "tree"
|
||||
? renderFolderTree(folderTree)
|
||||
: renderFlatList()}
|
||||
</div>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
) : (
|
||||
<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">
|
||||
<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} className="retro-btn bg-primary text-white hover:bg-primary/90">
|
||||
<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})
|
||||
确认选择 ({selectedFiles.size})
|
||||
</Button>
|
||||
</div>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
|
|
|||
|
|
@ -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,12 +106,34 @@ 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 {
|
||||
setLoading(true);
|
||||
|
|
@ -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" />
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue