CodeReview/src/components/audit/CreateTaskDialog.tsx

681 lines
28 KiB
TypeScript
Raw Normal View History

import { useState, useEffect } from "react";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Checkbox } from "@/components/ui/checkbox";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent } from "@/components/ui/card";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import {
GitBranch,
Settings,
FileText,
AlertCircle,
Info,
Zap,
Shield,
Search
} from "lucide-react";
import { api } from "@/shared/config/database";
import type { Project, CreateAuditTaskForm } from "@/shared/types";
import { toast } from "sonner";
import TerminalProgressDialog from "./TerminalProgressDialog";
import { runRepositoryAudit } from "@/features/projects/services/repoScan";
import { scanZipFile, validateZipFile } from "@/features/projects/services/repoZipScan";
interface CreateTaskDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
onTaskCreated: () => void;
preselectedProjectId?: string;
}
export default function CreateTaskDialog({ open, onOpenChange, onTaskCreated, preselectedProjectId }: CreateTaskDialogProps) {
const [projects, setProjects] = useState<Project[]>([]);
const [loading, setLoading] = useState(false);
const [creating, setCreating] = useState(false);
const [searchTerm, setSearchTerm] = useState("");
const [showTerminalDialog, setShowTerminalDialog] = useState(false);
const [currentTaskId, setCurrentTaskId] = useState<string | null>(null);
const [zipFile, setZipFile] = useState<File | null>(null);
const [taskForm, setTaskForm] = useState<CreateAuditTaskForm>({
project_id: "",
task_type: "repository",
branch_name: "main",
exclude_patterns: ["node_modules/**", ".git/**", "dist/**", "build/**", "*.log"],
scan_config: {
include_tests: true,
include_docs: false,
max_file_size: 1024, // KB
analysis_depth: "standard"
}
});
const commonExcludePatterns = [
{ label: "node_modules", value: "node_modules/**", description: "Node.js 依赖包" },
{ label: ".git", value: ".git/**", description: "Git 版本控制文件" },
{ label: "dist/build", value: "dist/**", description: "构建输出目录" },
{ label: "logs", value: "*.log", description: "日志文件" },
{ label: "cache", value: ".cache/**", description: "缓存文件" },
{ label: "temp", value: "temp/**", description: "临时文件" },
{ label: "vendor", value: "vendor/**", description: "第三方库" },
{ label: "coverage", value: "coverage/**", description: "测试覆盖率报告" }
];
useEffect(() => {
if (open) {
loadProjects();
// 如果有预选择的项目ID设置到表单中
if (preselectedProjectId) {
setTaskForm(prev => ({ ...prev, project_id: preselectedProjectId }));
}
}
}, [open, preselectedProjectId]);
const loadProjects = async () => {
try {
setLoading(true);
const data = await api.getProjects();
setProjects(data.filter(p => p.is_active));
} catch (error) {
console.error('Failed to load projects:', error);
toast.error("加载项目失败");
} finally {
setLoading(false);
}
};
const handleCreateTask = async () => {
if (!taskForm.project_id) {
toast.error("请选择项目");
return;
}
if (taskForm.task_type === "repository" && !taskForm.branch_name?.trim()) {
toast.error("请输入分支名称");
return;
}
const project = selectedProject;
if (!project) {
toast.error("未找到选中的项目");
return;
}
try {
setCreating(true);
console.log('🎯 开始创建审计任务...', {
projectId: project.id,
projectName: project.name,
repositoryType: project.repository_type
});
let taskId: string;
// 根据项目是否有repository_url判断使用哪种扫描方式
if (!project.repository_url || project.repository_url.trim() === '') {
// ZIP上传的项目需要有ZIP文件才能扫描
if (!zipFile) {
toast.error("请上传ZIP文件进行扫描");
return;
}
console.log('📦 调用 scanZipFile...');
taskId = await scanZipFile({
projectId: project.id,
zipFile: zipFile,
excludePatterns: taskForm.exclude_patterns,
createdBy: 'local-user'
});
} else {
// GitHub/GitLab等远程仓库
console.log('📡 调用 runRepositoryAudit...');
// 从运行时配置中获取 Token
const getRuntimeConfig = () => {
try {
const saved = localStorage.getItem('xcodereviewer_runtime_config');
return saved ? JSON.parse(saved) : null;
} catch {
return null;
}
};
const runtimeConfig = getRuntimeConfig();
const githubToken = runtimeConfig?.githubToken || (import.meta.env.VITE_GITHUB_TOKEN as string | undefined);
const gitlabToken = runtimeConfig?.gitlabToken || (import.meta.env.VITE_GITLAB_TOKEN as string | undefined);
taskId = await runRepositoryAudit({
projectId: project.id,
repoUrl: project.repository_url!,
branch: taskForm.branch_name || project.default_branch || 'main',
exclude: taskForm.exclude_patterns,
githubToken,
gitlabToken,
createdBy: 'local-user'
});
}
console.log('✅ 任务创建成功:', taskId);
// 关闭创建对话框
onOpenChange(false);
resetForm();
onTaskCreated();
// 显示终端进度窗口
setCurrentTaskId(taskId);
setShowTerminalDialog(true);
toast.success("审计任务已创建并启动");
} catch (error) {
console.error('❌ 创建任务失败:', error);
toast.error("创建任务失败: " + (error as Error).message);
} finally {
setCreating(false);
}
};
const resetForm = () => {
setTaskForm({
project_id: "",
task_type: "repository",
branch_name: "main",
exclude_patterns: ["node_modules/**", ".git/**", "dist/**", "build/**", "*.log"],
scan_config: {
include_tests: true,
include_docs: false,
max_file_size: 1024,
analysis_depth: "standard"
}
});
setSearchTerm("");
};
const toggleExcludePattern = (pattern: string) => {
const patterns = taskForm.exclude_patterns || [];
if (patterns.includes(pattern)) {
setTaskForm({
...taskForm,
exclude_patterns: patterns.filter(p => p !== pattern)
});
} else {
setTaskForm({
...taskForm,
exclude_patterns: [...patterns, pattern]
});
}
};
const addCustomPattern = (pattern: string) => {
if (pattern.trim() && !taskForm.exclude_patterns.includes(pattern.trim())) {
setTaskForm({
...taskForm,
exclude_patterns: [...taskForm.exclude_patterns, pattern.trim()]
});
}
};
const removeExcludePattern = (pattern: string) => {
setTaskForm({
...taskForm,
exclude_patterns: taskForm.exclude_patterns.filter(p => p !== pattern)
});
};
const selectedProject = projects.find(p => p.id === taskForm.project_id);
const filteredProjects = projects.filter(project =>
project.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
project.description?.toLowerCase().includes(searchTerm.toLowerCase())
);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-4xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle className="flex items-center space-x-2">
<Shield className="w-5 h-5 text-primary" />
<span></span>
</DialogTitle>
</DialogHeader>
<div className="space-y-6">
{/* 项目选择 */}
<div className="space-y-4">
<div className="flex items-center justify-between">
<Label className="text-base font-medium"></Label>
<Badge variant="outline" className="text-xs">
{filteredProjects.length}
</Badge>
</div>
{/* 项目搜索 */}
<div className="relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 w-4 h-4" />
<Input
placeholder="搜索项目名称..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="pl-10"
/>
</div>
{/* 项目列表 */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-3 max-h-60 overflow-y-auto">
{loading ? (
<div className="col-span-2 flex items-center justify-center py-8">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
</div>
) : filteredProjects.length > 0 ? (
filteredProjects.map((project) => (
<Card
key={project.id}
className={`cursor-pointer transition-all hover:shadow-md ${
taskForm.project_id === project.id
? 'ring-2 ring-primary bg-primary/5'
: 'hover:bg-gray-50'
}`}
onClick={() => setTaskForm({ ...taskForm, project_id: project.id })}
>
<CardContent className="p-4">
<div className="flex items-start justify-between">
<div className="flex-1">
<h4 className="font-medium text-sm">{project.name}</h4>
{project.description && (
<p className="text-xs text-gray-500 mt-1 line-clamp-2">
{project.description}
</p>
)}
<div className="flex items-center space-x-4 mt-2 text-xs text-gray-400">
<span>{project.repository_type?.toUpperCase() || 'OTHER'}</span>
<span>{project.default_branch}</span>
</div>
</div>
{taskForm.project_id === project.id && (
<div className="w-5 h-5 rounded-full bg-primary flex items-center justify-center">
<div className="w-2 h-2 rounded-full bg-white"></div>
</div>
)}
</div>
</CardContent>
</Card>
))
) : (
<div className="col-span-2 text-center py-8 text-gray-500">
<FileText className="w-8 h-8 mx-auto mb-2 opacity-50" />
<p className="text-sm">
{searchTerm ? '未找到匹配的项目' : '暂无可用项目'}
</p>
</div>
)}
</div>
</div>
{/* 任务配置 */}
{selectedProject && (
<Tabs defaultValue="basic" className="w-full">
<TabsList className="grid w-full grid-cols-3">
<TabsTrigger value="basic" className="flex items-center space-x-2">
<GitBranch className="w-4 h-4" />
<span></span>
</TabsTrigger>
<TabsTrigger value="exclude" className="flex items-center space-x-2">
<FileText className="w-4 h-4" />
<span></span>
</TabsTrigger>
<TabsTrigger value="advanced" className="flex items-center space-x-2">
<Settings className="w-4 h-4" />
<span></span>
</TabsTrigger>
</TabsList>
<TabsContent value="basic" className="space-y-4 mt-6">
{/* ZIP项目文件上传 */}
{(!selectedProject.repository_url || selectedProject.repository_url.trim() === '') && (
<Card className="bg-amber-50 border-amber-200">
<CardContent className="p-4">
<div className="space-y-3">
<div className="flex items-start space-x-3">
<AlertCircle className="w-5 h-5 text-amber-600 mt-0.5" />
<div>
<p className="font-medium text-amber-900 text-sm">ZIP项目需要上传文件</p>
<p className="text-xs text-amber-700 mt-1">
ZIP上传创建的ZIP文件进行扫描
</p>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="zipFile">ZIP文件</Label>
<Input
id="zipFile"
type="file"
accept=".zip"
onChange={(e) => {
const file = e.target.files?.[0];
if (file) {
console.log('📁 选择的文件:', {
name: file.name,
size: file.size,
type: file.type,
sizeMB: (file.size / 1024 / 1024).toFixed(2)
});
const validation = validateZipFile(file);
if (!validation.valid) {
toast.error(validation.error || "文件无效");
e.target.value = '';
return;
}
setZipFile(file);
const sizeMB = (file.size / 1024 / 1024).toFixed(2);
const sizeKB = (file.size / 1024).toFixed(2);
const sizeText = file.size >= 1024 * 1024 ? `${sizeMB} MB` : `${sizeKB} KB`;
toast.success(`已选择文件: ${file.name} (${sizeText})`);
}
}}
className="cursor-pointer"
/>
{zipFile && (
<p className="text-xs text-green-600">
: {zipFile.name} (
{zipFile.size >= 1024 * 1024
? `${(zipFile.size / 1024 / 1024).toFixed(2)} MB`
: zipFile.size >= 1024
? `${(zipFile.size / 1024).toFixed(2)} KB`
: `${zipFile.size} B`
})
</p>
)}
</div>
</div>
</CardContent>
</Card>
)}
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="task_type"></Label>
<Select
value={taskForm.task_type}
onValueChange={(value: any) => setTaskForm({ ...taskForm, task_type: value })}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="repository">
<div className="flex items-center space-x-2">
<GitBranch className="w-4 h-4" />
<span></span>
</div>
</SelectItem>
<SelectItem value="instant">
<div className="flex items-center space-x-2">
<Zap className="w-4 h-4" />
<span></span>
</div>
</SelectItem>
</SelectContent>
</Select>
</div>
{taskForm.task_type === "repository" && (selectedProject.repository_url) && (
<div className="space-y-2">
<Label htmlFor="branch_name"></Label>
<Input
id="branch_name"
value={taskForm.branch_name || ""}
onChange={(e) => setTaskForm({ ...taskForm, branch_name: e.target.value })}
placeholder={selectedProject.default_branch || "main"}
/>
</div>
)}
</div>
{/* 项目信息展示 */}
<Card className="bg-blue-50 border-blue-200">
<CardContent className="p-4">
<div className="flex items-start space-x-3">
<Info className="w-5 h-5 text-blue-600 mt-0.5" />
<div className="text-sm">
<p className="font-medium text-blue-900 mb-1">{selectedProject.name}</p>
<div className="text-blue-700 space-y-1">
{selectedProject.description && (
<p>{selectedProject.description}</p>
)}
<p>{selectedProject.default_branch}</p>
{selectedProject.programming_languages && (
<p>{JSON.parse(selectedProject.programming_languages).join(', ')}</p>
)}
</div>
</div>
</div>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="exclude" className="space-y-4 mt-6">
<div className="space-y-4">
<div>
<Label className="text-base font-medium"></Label>
<p className="text-sm text-gray-500 mt-1">
</p>
</div>
{/* 常用排除模式 */}
<div className="grid grid-cols-2 gap-3">
{commonExcludePatterns.map((pattern) => (
<div key={pattern.value} className="flex items-center space-x-3 p-3 border rounded-lg hover:bg-gray-50">
<Checkbox
checked={taskForm.exclude_patterns.includes(pattern.value)}
onCheckedChange={() => toggleExcludePattern(pattern.value)}
/>
<div className="flex-1">
<p className="text-sm font-medium">{pattern.label}</p>
<p className="text-xs text-gray-500">{pattern.description}</p>
</div>
</div>
))}
</div>
{/* 自定义排除模式 */}
<div className="space-y-2">
<Label></Label>
<div className="flex space-x-2">
<Input
placeholder="例如: *.tmp, test/**"
onKeyPress={(e) => {
if (e.key === 'Enter') {
addCustomPattern(e.currentTarget.value);
e.currentTarget.value = '';
}
}}
/>
<Button
type="button"
variant="outline"
onClick={(e) => {
const input = e.currentTarget.previousElementSibling as HTMLInputElement;
addCustomPattern(input.value);
input.value = '';
}}
>
</Button>
</div>
</div>
{/* 已选择的排除模式 */}
{taskForm.exclude_patterns.length > 0 && (
<div className="space-y-2">
<Label></Label>
<div className="flex flex-wrap gap-2">
{taskForm.exclude_patterns.map((pattern) => (
<Badge
key={pattern}
variant="secondary"
className="cursor-pointer hover:bg-red-100 hover:text-red-800"
onClick={() => removeExcludePattern(pattern)}
>
{pattern} ×
</Badge>
))}
</div>
</div>
)}
</div>
</TabsContent>
<TabsContent value="advanced" className="space-y-4 mt-6">
<div className="space-y-6">
<div>
<Label className="text-base font-medium"></Label>
<p className="text-sm text-gray-500 mt-1">
</p>
</div>
<div className="grid grid-cols-2 gap-6">
<div className="space-y-4">
<div className="flex items-center space-x-3">
<Checkbox
checked={taskForm.scan_config.include_tests}
onCheckedChange={(checked) =>
setTaskForm({
...taskForm,
scan_config: { ...taskForm.scan_config, include_tests: !!checked }
})
}
/>
<div>
<p className="text-sm font-medium"></p>
<p className="text-xs text-gray-500"> *test*, *spec* </p>
</div>
</div>
<div className="flex items-center space-x-3">
<Checkbox
checked={taskForm.scan_config.include_docs}
onCheckedChange={(checked) =>
setTaskForm({
...taskForm,
scan_config: { ...taskForm.scan_config, include_docs: !!checked }
})
}
/>
<div>
<p className="text-sm font-medium"></p>
<p className="text-xs text-gray-500"> README, docs </p>
</div>
</div>
</div>
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="max_file_size"> (KB)</Label>
<Input
id="max_file_size"
type="number"
value={taskForm.scan_config.max_file_size}
onChange={(e) =>
setTaskForm({
...taskForm,
scan_config: {
...taskForm.scan_config,
max_file_size: parseInt(e.target.value) || 1024
}
})
}
min="1"
max="10240"
/>
</div>
<div className="space-y-2">
<Label htmlFor="analysis_depth"></Label>
<Select
value={taskForm.scan_config.analysis_depth}
onValueChange={(value: any) =>
setTaskForm({
...taskForm,
scan_config: { ...taskForm.scan_config, analysis_depth: value }
})
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="basic"></SelectItem>
<SelectItem value="standard"></SelectItem>
<SelectItem value="deep"></SelectItem>
</SelectContent>
</Select>
</div>
</div>
</div>
{/* 分析深度说明 */}
<Card className="bg-amber-50 border-amber-200">
<CardContent className="p-4">
<div className="flex items-start space-x-3">
<AlertCircle className="w-5 h-5 text-amber-600 mt-0.5" />
<div className="text-sm">
<p className="font-medium text-amber-900 mb-2"></p>
<ul className="text-amber-800 space-y-1 text-xs">
<li> <strong></strong></li>
<li> <strong></strong></li>
<li> <strong></strong></li>
</ul>
</div>
</div>
</CardContent>
</Card>
</div>
</TabsContent>
</Tabs>
)}
{/* 操作按钮 */}
<div className="flex justify-end space-x-3 pt-4 border-t">
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={creating}>
</Button>
<Button
onClick={handleCreateTask}
disabled={!taskForm.project_id || creating}
className="btn-primary"
>
{creating ? (
<>
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2"></div>
...
</>
) : (
<>
<Shield className="w-4 h-4 mr-2" />
</>
)}
</Button>
</div>
</div>
</DialogContent>
{/* 终端进度对话框 */}
<TerminalProgressDialog
open={showTerminalDialog}
onOpenChange={setShowTerminalDialog}
taskId={currentTaskId}
taskType="repository"
/>
</Dialog>
);
}