From a995bef28cb5845ce449cfcaa3d94c6a234efb1b Mon Sep 17 00:00:00 2001 From: lintsinghua Date: Wed, 10 Dec 2025 19:20:31 +0800 Subject: [PATCH] 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 --- backend/app/db/init_db.py | 2 +- .../src/components/audit/CreateTaskDialog.tsx | 46 +- .../components/audit/FileSelectionDialog.tsx | 614 +++++++++++++++--- frontend/src/pages/AuditTasks.tsx | 86 ++- frontend/src/pages/TaskDetail.tsx | 78 ++- 5 files changed, 724 insertions(+), 102 deletions(-) diff --git a/backend/app/db/init_db.py b/backend/app/db/init_db.py index b06f50f..e164d21 100644 --- a/backend/app/db/init_db.py +++ b/backend/app/db/init_db.py @@ -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}, diff --git a/frontend/src/components/audit/CreateTaskDialog.tsx b/frontend/src/components/audit/CreateTaskDialog.tsx index 4b20df8..fcc9305 100644 --- a/frontend/src/components/audit/CreateTaskDialog.tsx +++ b/frontend/src/components/audit/CreateTaskDialog.tsx @@ -445,10 +445,21 @@ export default function CreateTaskDialog({ {/* 排除模式 */} -
- - 排除模式 - +
+
+ + 排除模式 + + +
+ + {/* 已选择的排除模式 */}
{excludePatterns.map((p) => ( ))} + {excludePatterns.length === 0 && ( + 无排除模式 + )}
+ + {/* 快捷添加常用模式 */} +
+ 快捷添加: + {[".test.", ".spec.", ".min.", "coverage/", "docs/", ".md"].map((pattern) => ( + + ))} +
+ + {/* 自定义输入 */} { if (e.key === "Enter" && e.currentTarget.value) { diff --git a/frontend/src/components/audit/FileSelectionDialog.tsx b/frontend/src/components/audit/FileSelectionDialog.tsx index 81ff2b6..e156379 100644 --- a/frontend/src/components/audit/FileSelectionDialog.tsx +++ b/frontend/src/components/audit/FileSelectionDialog.tsx @@ -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; + 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 ; + } + if (configExts.includes(ext)) { + return ; + } + return ; +}; + +// 获取文件扩展名 +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([]); const [loading, setLoading] = useState(false); const [selectedFiles, setSelectedFiles] = useState>(new Set()); const [searchTerm, setSearchTerm] = useState(""); + const [expandedFolders, setExpandedFolders] = useState>(new Set()); + const [viewMode, setViewMode] = useState<"tree" | "flat">("tree"); + const [filterType, setFilterType] = useState(""); 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(); + 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(); + 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 ( - - - - -
- - 选择要审计的文件 + // 检查文件夹的选中状态 + 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( +
+
+ +
e.stopPropagation()}> + { + 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" + /> +
+ {isExpanded ? ( + + ) : ( + + )} + handleExpandFolder(folder.path)} + > + {folder.name} + + + { + filteredFiles.filter((f) => + f.path.startsWith(folder.path + "/") + ).length + } +
- {excludePatterns && excludePatterns.length > 0 && ( - - 已排除 {excludePatterns.length} 种模式 + {isExpanded && renderFolderTree(folder, depth + 1)} +
+ ); + }); + + // 渲染文件 + node.files + .sort((a, b) => a.path.localeCompare(b.path)) + .forEach((file) => { + const fileName = file.path.split("/").pop() || file.path; + items.push( +
handleToggleFile(file.path)} + > +
e.stopPropagation()}> + handleToggleFile(file.path)} + className="rounded-none border-2 border-black data-[state=checked]:bg-primary data-[state=checked]:text-white" + /> +
+ {getFileIcon(file.path)} + + {fileName} + + {file.size > 0 && ( + + {formatSize(file.size)} )} +
+ ); + }); + + return items; + }; + + // 渲染扁平列表 + const renderFlatList = () => { + return filteredFiles.map((file) => ( +
handleToggleFile(file.path)} + > +
e.stopPropagation()}> + handleToggleFile(file.path)} + className="rounded-none border-2 border-black data-[state=checked]:bg-primary data-[state=checked]:text-white" + /> +
+ {getFileIcon(file.path)} +
+

+ {file.path} +

+
+ {file.size > 0 && ( + + {formatSize(file.size)} + + )} +
+ )); + }; + + return ( + + + + +
+ + 选择要审计的文件 +
+
+ {excludePatterns && excludePatterns.length > 0 && ( + + 已排除 {excludePatterns.length} 种模式 + + )} +
-
-
-
+
+ {/* 工具栏 */} +
+ {/* 搜索框 */} +
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" />
- - + + {/* 文件类型筛选 */} + {fileTypes.length > 0 && ( +
+ + +
+ )} + + {/* 视图切换 */} +
+ + +
-
- 共 {files.length} 个文件 - 已选择 {selectedFiles.size} 个 + {/* 操作按钮 */} +
+
+ + + + {(searchTerm || filterType) && ( + + )} +
+
+ {searchTerm || filterType ? ( + + 筛选: {filteredFiles.length}/{files.length} 个文件, + 已选 {selectedFiles.size} 个 + + ) : ( + + 共 {files.length} 个文件,已选 {selectedFiles.size} 个 + + )} +
-
+ {/* 文件列表 - 使用固定高度确保滚动正常工作 */} +
{loading ? (
) : filteredFiles.length > 0 ? ( - -
- {filteredFiles.map((file) => ( -
handleToggleFile(file.path)} - > - handleToggleFile(file.path)} - className="rounded-none border-2 border-black data-[state=checked]:bg-primary data-[state=checked]:text-white" - /> -
-

- {file.path} -

-
- {file.size > 0 && ( - - {formatSize(file.size)} - - )} -
- ))} +
+
+ {viewMode === "tree" + ? renderFolderTree(folderTree) + : renderFlatList()}
- +
) : (
-

没有找到文件

+

+ {searchTerm || filterType + ? "没有匹配的文件" + : "没有找到文件"} +

)}
- - - + +
+ 提示:点击文件夹可展开/折叠,点击文件夹复选框可批量选择 +
+
+ + +
diff --git a/frontend/src/pages/AuditTasks.tsx b/frontend/src/pages/AuditTasks.tsx index 1189acd..5e8a0ad 100644 --- a/frontend/src/pages/AuditTasks.tsx +++ b/frontend/src/pages/AuditTasks.tsx @@ -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([]); const [loading, setLoading] = useState(true); const [searchTerm, setSearchTerm] = useState(""); const [statusFilter, setStatusFilter] = useState("all"); const [showCreateDialog, setShowCreateDialog] = useState(false); + const [cancellingTaskId, setCancellingTaskId] = useState(null); + + // 僵尸任务检测:记录每个任务的上次进度和时间 + const taskProgressRef = useRef>(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() {
+ {/* 运行中或等待中的任务显示取消按钮 */} + {(task.status === 'running' || task.status === 'pending') && ( + + )} + )} + {/* 已完成的任务显示导出按钮 */} {task.status === 'completed' && (