feat(audit): Enhance CreateTaskDialog with improved task creation workflow

- Add navigation to project details page after task creation
- Update task creation process with more detailed toast notifications
- Modify created_by field to handle null scenario for system users
- Refactor CreateTaskDialog to improve user experience and error handling
- Optimize repoScan service with more robust background task processing
- Update example image for visual consistency
Improves the audit task creation flow by providing better user feedback and streamlining the post-creation experience.
This commit is contained in:
lintsinghua 2025-10-22 22:18:19 +08:00
parent 96e15452e8
commit 9b11e47b36
5 changed files with 153 additions and 93 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 424 KiB

After

Width:  |  Height:  |  Size: 368 KiB

View File

@ -1,4 +1,5 @@
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
import { useNavigate } from "react-router-dom";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"; import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/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";
@ -30,6 +31,7 @@ interface CreateTaskDialogProps {
} }
export default function CreateTaskDialog({ open, onOpenChange, onTaskCreated, preselectedProjectId }: CreateTaskDialogProps) { export default function CreateTaskDialog({ open, onOpenChange, onTaskCreated, preselectedProjectId }: CreateTaskDialogProps) {
const navigate = useNavigate();
const [projects, setProjects] = useState<Project[]>([]); const [projects, setProjects] = useState<Project[]>([]);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [creating, setCreating] = useState(false); const [creating, setCreating] = useState(false);
@ -98,13 +100,21 @@ export default function CreateTaskDialog({ open, onOpenChange, onTaskCreated, pr
await api.createAuditTask({ await api.createAuditTask({
...taskForm, ...taskForm,
created_by: "system" // 无登录场景下使用系统用户 created_by: null // 无登录场景下设置为null
} as any); } as any);
toast.success("审计任务创建成功"); // 显示详细的提示信息
toast.success("审计任务创建成功", {
description: '因为网络和代码文件大小等因素审计时长通常至少需要1分钟请耐心等待...',
duration: 5000
});
onOpenChange(false); onOpenChange(false);
resetForm(); resetForm();
onTaskCreated(); onTaskCreated();
// 跳转到项目详情页面
navigate(`/projects/${taskForm.project_id}`);
} catch (error) { } catch (error) {
console.error('Failed to create task:', error); console.error('Failed to create task:', error);
toast.error("创建任务失败"); toast.error("创建任务失败");

View File

@ -45,74 +45,81 @@ export async function runRepositoryAudit(params: {
created_by: params.createdBy created_by: params.createdBy
} as any); } as any);
try { const taskId = (task as any).id as string;
const m = params.repoUrl.match(/github\.com\/(.+?)\/(.+?)(?:\.git)?$/i);
if (!m) throw new Error("仅支持 GitHub 仓库 URL例如 https://github.com/owner/repo");
const owner = m[1];
const repo = m[2];
const treeUrl = `https://api.github.com/repos/${owner}/${repo}/git/trees/${encodeURIComponent(branch)}?recursive=1`; // 启动后台审计任务,不阻塞返回
const tree = await githubApi<{ tree: GithubTreeItem[] }>(treeUrl, params.githubToken); (async () => {
let files = (tree.tree || []).filter(i => i.type === "blob" && isTextFile(i.path) && !matchExclude(i.path, excludes)); try {
// 采样限制,优先分析较小文件与常见语言 const m = params.repoUrl.match(/github\.com\/(.+?)\/(.+?)(?:\.git)?$/i);
files = files if (!m) throw new Error("仅支持 GitHub 仓库 URL例如 https://github.com/owner/repo");
.sort((a, b) => (a.path.length - b.path.length)) const owner = m[1];
.slice(0, MAX_ANALYZE_FILES); const repo = m[2];
let totalFiles = 0, totalLines = 0, createdIssues = 0; const treeUrl = `https://api.github.com/repos/${owner}/${repo}/git/trees/${encodeURIComponent(branch)}?recursive=1`;
let index = 0; const tree = await githubApi<{ tree: GithubTreeItem[] }>(treeUrl, params.githubToken);
const worker = async () => { let files = (tree.tree || []).filter(i => i.type === "blob" && isTextFile(i.path) && !matchExclude(i.path, excludes));
while (true) { // 采样限制,优先分析较小文件与常见语言
const current = index++; files = files
if (current >= files.length) break; .sort((a, b) => (a.path.length - b.path.length))
const f = files[current]; .slice(0, MAX_ANALYZE_FILES);
totalFiles++;
try {
const rawUrl = `https://raw.githubusercontent.com/${owner}/${repo}/${encodeURIComponent(branch)}/${f.path}`;
const contentRes = await fetch(rawUrl);
if (!contentRes.ok) { await new Promise(r=>setTimeout(r, LLM_GAP_MS)); continue; }
const content = await contentRes.text();
if (content.length > MAX_FILE_SIZE_BYTES) { await new Promise(r=>setTimeout(r, LLM_GAP_MS)); continue; }
totalLines += content.split(/\r?\n/).length;
const language = (f.path.split(".").pop() || "").toLowerCase();
const analysis = await CodeAnalysisEngine.analyzeCode(content, language);
const issues = analysis.issues || [];
createdIssues += issues.length;
for (const issue of issues) {
await api.createAuditIssue({
task_id: (task as any).id,
file_path: f.path,
line_number: issue.line || null,
column_number: issue.column || null,
issue_type: issue.type || "maintainability",
severity: issue.severity || "low",
title: issue.title || "Issue",
description: issue.description || null,
suggestion: issue.suggestion || null,
code_snippet: issue.code_snippet || null,
ai_explanation: issue.xai ? JSON.stringify(issue.xai) : (issue.ai_explanation || null),
status: "open",
resolved_by: null,
resolved_at: null
} as any);
}
if (totalFiles % 10 === 0) {
await api.updateAuditTask((task as any).id, { status: "running", total_files: totalFiles, scanned_files: totalFiles, total_lines: totalLines, issues_count: createdIssues } as any);
}
} catch {}
await new Promise(r=>setTimeout(r, LLM_GAP_MS));
}
};
const pool = Array.from({ length: Math.min(LLM_CONCURRENCY, files.length) }, () => worker()); let totalFiles = 0, totalLines = 0, createdIssues = 0;
await Promise.all(pool); let index = 0;
const worker = async () => {
while (true) {
const current = index++;
if (current >= files.length) break;
const f = files[current];
totalFiles++;
try {
const rawUrl = `https://raw.githubusercontent.com/${owner}/${repo}/${encodeURIComponent(branch)}/${f.path}`;
const contentRes = await fetch(rawUrl);
if (!contentRes.ok) { await new Promise(r=>setTimeout(r, LLM_GAP_MS)); continue; }
const content = await contentRes.text();
if (content.length > MAX_FILE_SIZE_BYTES) { await new Promise(r=>setTimeout(r, LLM_GAP_MS)); continue; }
totalLines += content.split(/\r?\n/).length;
const language = (f.path.split(".").pop() || "").toLowerCase();
const analysis = await CodeAnalysisEngine.analyzeCode(content, language);
const issues = analysis.issues || [];
createdIssues += issues.length;
for (const issue of issues) {
await api.createAuditIssue({
task_id: taskId,
file_path: f.path,
line_number: issue.line || null,
column_number: issue.column || null,
issue_type: issue.type || "maintainability",
severity: issue.severity || "low",
title: issue.title || "Issue",
description: issue.description || null,
suggestion: issue.suggestion || null,
code_snippet: issue.code_snippet || null,
ai_explanation: issue.xai ? JSON.stringify(issue.xai) : (issue.ai_explanation || null),
status: "open",
resolved_by: null,
resolved_at: null
} as any);
}
if (totalFiles % 10 === 0) {
await api.updateAuditTask(taskId, { status: "running", total_files: totalFiles, scanned_files: totalFiles, total_lines: totalLines, issues_count: createdIssues } as any);
}
} catch {}
await new Promise(r=>setTimeout(r, LLM_GAP_MS));
}
};
await api.updateAuditTask((task as any).id, { status: "completed", total_files: totalFiles, scanned_files: totalFiles, total_lines: totalLines, issues_count: createdIssues, quality_score: 0 } as any); const pool = Array.from({ length: Math.min(LLM_CONCURRENCY, files.length) }, () => worker());
return (task as any).id as string; await Promise.all(pool);
} catch (e) {
await api.updateAuditTask((task as any).id, { status: "failed" } as any); await api.updateAuditTask(taskId, { status: "completed", total_files: totalFiles, scanned_files: totalFiles, total_lines: totalLines, issues_count: createdIssues, quality_score: 0 } as any);
throw e; } catch (e) {
} console.error('审计任务执行失败:', e);
await api.updateAuditTask(taskId, { status: "failed" } as any);
}
})();
// 立即返回任务ID让用户可以跳转到任务详情页面
return taskId;
} }

View File

@ -1,5 +1,5 @@
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
import { useParams, Link } from "react-router-dom"; import { useParams, Link, useNavigate } from "react-router-dom";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
@ -26,6 +26,7 @@ import CreateTaskDialog from "@/components/audit/CreateTaskDialog";
export default function ProjectDetail() { export default function ProjectDetail() {
const { id } = useParams<{ id: string }>(); const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const [project, setProject] = useState<Project | null>(null); const [project, setProject] = useState<Project | null>(null);
const [tasks, setTasks] = useState<AuditTask[]>([]); const [tasks, setTasks] = useState<AuditTask[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
@ -66,17 +67,28 @@ export default function ProjectDetail() {
} }
try { try {
setScanning(true); setScanning(true);
await runRepositoryAudit({ console.log('开始启动审计任务...');
const taskId = await runRepositoryAudit({
projectId: id, projectId: id,
repoUrl: project.repository_url, repoUrl: project.repository_url,
branch: project.default_branch || 'main', branch: project.default_branch || 'main',
githubToken: undefined, githubToken: undefined,
createdBy: undefined createdBy: undefined
}); });
toast.success('已启动仓库审计');
await loadProjectData(); console.log('审计任务创建成功taskId:', taskId);
// 显示详细的提示信息
toast.success('审计任务已启动', {
description: '因为网络和代码文件大小等因素审计时长通常至少需要1分钟请耐心等待...',
duration: 5000
});
// 跳转到任务详情页面
console.log('准备跳转到:', `/tasks/${taskId}`);
navigate(`/tasks/${taskId}`);
} catch (e: any) { } catch (e: any) {
console.error(e); console.error('启动审计失败:', e);
toast.error(e?.message || '启动审计失败'); toast.error(e?.message || '启动审计失败');
} finally { } finally {
setScanning(false); setScanning(false);
@ -116,7 +128,10 @@ export default function ProjectDetail() {
}; };
const handleTaskCreated = () => { const handleTaskCreated = () => {
toast.success("审计任务已创建"); toast.success("审计任务已创建", {
description: '因为网络和代码文件大小等因素审计时长通常至少需要1分钟请耐心等待...',
duration: 5000
});
loadProjectData(); // 重新加载项目数据以显示新任务 loadProjectData(); // 重新加载项目数据以显示新任务
}; };

View File

@ -9,13 +9,13 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Progress } from "@/components/ui/progress"; import { Progress } from "@/components/ui/progress";
import { import {
Plus, Plus,
Search, Search,
GitBranch, GitBranch,
Calendar, Calendar,
Users, Users,
Settings, Settings,
ExternalLink, ExternalLink,
Code, Code,
Shield, Shield,
@ -29,12 +29,15 @@ import { scanZipFile, validateZipFile } from "@/features/projects/services";
import type { Project, CreateProjectForm } from "@/shared/types"; import type { Project, CreateProjectForm } from "@/shared/types";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import { toast } from "sonner"; import { toast } from "sonner";
import CreateTaskDialog from "@/components/audit/CreateTaskDialog";
export default function Projects() { export default function Projects() {
const [projects, setProjects] = useState<Project[]>([]); const [projects, setProjects] = useState<Project[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [searchTerm, setSearchTerm] = useState(""); const [searchTerm, setSearchTerm] = useState("");
const [showCreateDialog, setShowCreateDialog] = useState(false); const [showCreateDialog, setShowCreateDialog] = useState(false);
const [showCreateTaskDialog, setShowCreateTaskDialog] = useState(false);
const [selectedProjectForTask, setSelectedProjectForTask] = useState<string>("");
const [uploadProgress, setUploadProgress] = useState(0); const [uploadProgress, setUploadProgress] = useState(0);
const [uploading, setUploading] = useState(false); const [uploading, setUploading] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
@ -79,7 +82,7 @@ export default function Projects() {
...createForm, ...createForm,
// 无登录场景下不传 owner_id由后端置为 null // 无登录场景下不传 owner_id由后端置为 null
} as any); } as any);
toast.success("项目创建成功"); toast.success("项目创建成功");
setShowCreateDialog(false); setShowCreateDialog(false);
resetCreateForm(); resetCreateForm();
@ -189,6 +192,19 @@ export default function Projects() {
return new Date(dateString).toLocaleDateString('zh-CN'); return new Date(dateString).toLocaleDateString('zh-CN');
}; };
const handleCreateTask = (projectId: string) => {
setSelectedProjectForTask(projectId);
setShowCreateTaskDialog(true);
};
const handleTaskCreated = () => {
toast.success("审计任务已创建", {
description: '因为网络和代码文件大小等因素审计时长通常至少需要1分钟请耐心等待...',
duration: 5000
});
// 任务创建后会自动跳转到项目详情页面
};
if (loading) { if (loading) {
return ( return (
<div className="flex items-center justify-center min-h-screen"> <div className="flex items-center justify-center min-h-screen">
@ -205,7 +221,7 @@ export default function Projects() {
<h1 className="page-title"></h1> <h1 className="page-title"></h1>
<p className="page-subtitle"></p> <p className="page-subtitle"></p>
</div> </div>
<Dialog open={showCreateDialog} onOpenChange={setShowCreateDialog}> <Dialog open={showCreateDialog} onOpenChange={setShowCreateDialog}>
<DialogTrigger asChild> <DialogTrigger asChild>
<Button> <Button>
@ -217,13 +233,13 @@ export default function Projects() {
<DialogHeader> <DialogHeader>
<DialogTitle></DialogTitle> <DialogTitle></DialogTitle>
</DialogHeader> </DialogHeader>
<Tabs defaultValue="repository" className="w-full"> <Tabs defaultValue="repository" className="w-full">
<TabsList className="grid w-full grid-cols-2"> <TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="repository">Git </TabsTrigger> <TabsTrigger value="repository">Git </TabsTrigger>
<TabsTrigger value="upload"></TabsTrigger> <TabsTrigger value="upload"></TabsTrigger>
</TabsList> </TabsList>
<TabsContent value="repository" className="space-y-4 mt-6"> <TabsContent value="repository" className="space-y-4 mt-6">
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<div className="space-y-2"> <div className="space-y-2">
@ -237,8 +253,8 @@ export default function Projects() {
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="repository_type"></Label> <Label htmlFor="repository_type"></Label>
<Select <Select
value={createForm.repository_type} value={createForm.repository_type}
onValueChange={(value: any) => setCreateForm({ ...createForm, repository_type: value })} onValueChange={(value: any) => setCreateForm({ ...createForm, repository_type: value })}
> >
<SelectTrigger> <SelectTrigger>
@ -323,7 +339,7 @@ export default function Projects() {
</Button> </Button>
</div> </div>
</TabsContent> </TabsContent>
<TabsContent value="upload" className="space-y-4 mt-6"> <TabsContent value="upload" className="space-y-4 mt-6">
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="upload-name"> *</Label> <Label htmlFor="upload-name"> *</Label>
@ -490,16 +506,16 @@ export default function Projects() {
</Badge> </Badge>
</div> </div>
</CardHeader> </CardHeader>
<CardContent className="space-y-4"> <CardContent className="space-y-4">
{/* 项目信息 */} {/* 项目信息 */}
<div className="space-y-3"> <div className="space-y-3">
{project.repository_url && ( {project.repository_url && (
<div className="flex items-center text-sm text-gray-500"> <div className="flex items-center text-sm text-gray-500">
<GitBranch className="w-4 h-4 mr-2 flex-shrink-0" /> <GitBranch className="w-4 h-4 mr-2 flex-shrink-0" />
<a <a
href={project.repository_url} href={project.repository_url}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="hover:text-primary transition-colors flex items-center truncate" className="hover:text-primary transition-colors flex items-center truncate"
> >
@ -508,7 +524,7 @@ export default function Projects() {
</a> </a>
</div> </div>
)} )}
<div className="flex items-center justify-between text-sm text-gray-500"> <div className="flex items-center justify-between text-sm text-gray-500">
<div className="flex items-center"> <div className="flex items-center">
<Calendar className="w-4 h-4 mr-2" /> <Calendar className="w-4 h-4 mr-2" />
@ -545,9 +561,13 @@ export default function Projects() {
</Button> </Button>
</Link> </Link>
<Button size="sm" className="btn-primary"> <Button
size="sm"
className="btn-primary"
onClick={() => handleCreateTask(project.id)}
>
<Shield className="w-4 h-4 mr-2" /> <Shield className="w-4 h-4 mr-2" />
</Button> </Button>
</div> </div>
</CardContent> </CardContent>
@ -638,6 +658,14 @@ export default function Projects() {
</Card> </Card>
</div> </div>
)} )}
{/* 创建任务对话框 */}
<CreateTaskDialog
open={showCreateTaskDialog}
onOpenChange={setShowCreateTaskDialog}
onTaskCreated={handleTaskCreated}
preselectedProjectId={selectedProjectForTask}
/>
</div> </div>
); );
} }