594 lines
22 KiB
TypeScript
594 lines
22 KiB
TypeScript
|
|
/**
|
|||
|
|
* Agent 审计任务创建对话框
|
|||
|
|
* 专门用于 Agent Audit 页面,UI 风格与终端界面保持一致
|
|||
|
|
*/
|
|||
|
|
|
|||
|
|
import { useState, useEffect, useMemo } from "react";
|
|||
|
|
import { useNavigate } from "react-router-dom";
|
|||
|
|
import {
|
|||
|
|
Dialog,
|
|||
|
|
DialogContent,
|
|||
|
|
DialogHeader,
|
|||
|
|
DialogTitle,
|
|||
|
|
} from "@/components/ui/dialog";
|
|||
|
|
import { Button } from "@/components/ui/button";
|
|||
|
|
import { Input } from "@/components/ui/input";
|
|||
|
|
import { Badge } from "@/components/ui/badge";
|
|||
|
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
|||
|
|
import {
|
|||
|
|
Collapsible,
|
|||
|
|
CollapsibleContent,
|
|||
|
|
CollapsibleTrigger,
|
|||
|
|
} from "@/components/ui/collapsible";
|
|||
|
|
import {
|
|||
|
|
Select,
|
|||
|
|
SelectContent,
|
|||
|
|
SelectItem,
|
|||
|
|
SelectTrigger,
|
|||
|
|
SelectValue,
|
|||
|
|
} from "@/components/ui/select";
|
|||
|
|
import {
|
|||
|
|
Search,
|
|||
|
|
ChevronRight,
|
|||
|
|
GitBranch,
|
|||
|
|
Package,
|
|||
|
|
Globe,
|
|||
|
|
Loader2,
|
|||
|
|
Bot,
|
|||
|
|
Settings2,
|
|||
|
|
Play,
|
|||
|
|
Upload,
|
|||
|
|
FolderOpen,
|
|||
|
|
} from "lucide-react";
|
|||
|
|
import { toast } from "sonner";
|
|||
|
|
import { api } from "@/shared/config/database";
|
|||
|
|
import { createAgentTask } from "@/shared/api/agentTasks";
|
|||
|
|
import { isRepositoryProject, isZipProject } from "@/shared/utils/projectUtils";
|
|||
|
|
import { getZipFileInfo, type ZipFileMeta } from "@/shared/utils/zipStorage";
|
|||
|
|
import { validateZipFile } from "@/features/projects/services/repoZipScan";
|
|||
|
|
import type { Project } from "@/shared/types";
|
|||
|
|
import FileSelectionDialog from "@/components/audit/FileSelectionDialog";
|
|||
|
|
|
|||
|
|
interface CreateAgentTaskDialogProps {
|
|||
|
|
open: boolean;
|
|||
|
|
onOpenChange: (open: boolean) => void;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const DEFAULT_EXCLUDES = [
|
|||
|
|
"node_modules/**",
|
|||
|
|
".git/**",
|
|||
|
|
"dist/**",
|
|||
|
|
"build/**",
|
|||
|
|
"*.log",
|
|||
|
|
];
|
|||
|
|
|
|||
|
|
export default function CreateAgentTaskDialog({
|
|||
|
|
open,
|
|||
|
|
onOpenChange,
|
|||
|
|
}: CreateAgentTaskDialogProps) {
|
|||
|
|
const navigate = useNavigate();
|
|||
|
|
|
|||
|
|
// 状态
|
|||
|
|
const [projects, setProjects] = useState<Project[]>([]);
|
|||
|
|
const [loadingProjects, setLoadingProjects] = useState(true);
|
|||
|
|
const [selectedProjectId, setSelectedProjectId] = useState<string>("");
|
|||
|
|
const [searchTerm, setSearchTerm] = useState("");
|
|||
|
|
const [branch, setBranch] = useState("main");
|
|||
|
|
const [branches, setBranches] = useState<string[]>([]);
|
|||
|
|
const [loadingBranches, setLoadingBranches] = useState(false);
|
|||
|
|
const [excludePatterns, setExcludePatterns] = useState(DEFAULT_EXCLUDES);
|
|||
|
|
const [showAdvanced, setShowAdvanced] = useState(false);
|
|||
|
|
const [creating, setCreating] = useState(false);
|
|||
|
|
|
|||
|
|
// ZIP 文件状态
|
|||
|
|
const [zipFile, setZipFile] = useState<File | null>(null);
|
|||
|
|
const [storedZipInfo, setStoredZipInfo] = useState<ZipFileMeta | null>(null);
|
|||
|
|
const [useStoredZip, setUseStoredZip] = useState(true);
|
|||
|
|
|
|||
|
|
// 文件选择状态
|
|||
|
|
const [selectedFiles, setSelectedFiles] = useState<string[] | undefined>();
|
|||
|
|
const [showFileSelection, setShowFileSelection] = useState(false);
|
|||
|
|
|
|||
|
|
const selectedProject = projects.find((p) => p.id === selectedProjectId);
|
|||
|
|
|
|||
|
|
// 加载项目列表
|
|||
|
|
useEffect(() => {
|
|||
|
|
if (open) {
|
|||
|
|
setLoadingProjects(true);
|
|||
|
|
api.getProjects()
|
|||
|
|
.then((data) => {
|
|||
|
|
setProjects(data.filter((p: Project) => p.is_active));
|
|||
|
|
})
|
|||
|
|
.catch(() => {
|
|||
|
|
toast.error("加载项目列表失败");
|
|||
|
|
})
|
|||
|
|
.finally(() => setLoadingProjects(false));
|
|||
|
|
|
|||
|
|
// 重置状态
|
|||
|
|
setSelectedProjectId("");
|
|||
|
|
setSearchTerm("");
|
|||
|
|
setBranch("main");
|
|||
|
|
setExcludePatterns(DEFAULT_EXCLUDES);
|
|||
|
|
setShowAdvanced(false);
|
|||
|
|
setZipFile(null);
|
|||
|
|
setStoredZipInfo(null);
|
|||
|
|
setSelectedFiles(undefined);
|
|||
|
|
}
|
|||
|
|
}, [open]);
|
|||
|
|
|
|||
|
|
// 加载分支列表
|
|||
|
|
useEffect(() => {
|
|||
|
|
const loadBranches = async () => {
|
|||
|
|
// 使用 selectedProjectId 从 projects 中获取最新的 project 对象
|
|||
|
|
const project = projects.find((p) => p.id === selectedProjectId);
|
|||
|
|
if (!project || !isRepositoryProject(project)) {
|
|||
|
|
setBranches([]);
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
setLoadingBranches(true);
|
|||
|
|
try {
|
|||
|
|
const result = await api.getProjectBranches(project.id);
|
|||
|
|
console.log("[Branch] 加载分支结果:", result);
|
|||
|
|
|
|||
|
|
if (result.error) {
|
|||
|
|
console.warn("[Branch] 加载分支警告:", result.error);
|
|||
|
|
toast.error(`加载分支失败: ${result.error}`);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
setBranches(result.branches);
|
|||
|
|
if (result.default_branch) {
|
|||
|
|
setBranch(result.default_branch);
|
|||
|
|
}
|
|||
|
|
} catch (err) {
|
|||
|
|
const msg = err instanceof Error ? err.message : "未知错误";
|
|||
|
|
console.error("[Branch] 加载分支失败:", msg);
|
|||
|
|
toast.error(`加载分支失败: ${msg}`);
|
|||
|
|
setBranches([project.default_branch || "main"]);
|
|||
|
|
} finally {
|
|||
|
|
setLoadingBranches(false);
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
loadBranches();
|
|||
|
|
}, [selectedProjectId, projects]);
|
|||
|
|
|
|||
|
|
// 加载 ZIP 文件信息
|
|||
|
|
useEffect(() => {
|
|||
|
|
const loadZipInfo = async () => {
|
|||
|
|
if (!selectedProject || !isZipProject(selectedProject)) {
|
|||
|
|
setStoredZipInfo(null);
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
try {
|
|||
|
|
const info = await getZipFileInfo(selectedProject.id);
|
|||
|
|
setStoredZipInfo(info);
|
|||
|
|
setUseStoredZip(info.has_file);
|
|||
|
|
} catch {
|
|||
|
|
setStoredZipInfo(null);
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
loadZipInfo();
|
|||
|
|
}, [selectedProject?.id]);
|
|||
|
|
|
|||
|
|
// 过滤项目
|
|||
|
|
const filteredProjects = useMemo(() => {
|
|||
|
|
if (!searchTerm) return projects;
|
|||
|
|
const term = searchTerm.toLowerCase();
|
|||
|
|
return projects.filter(
|
|||
|
|
(p) =>
|
|||
|
|
p.name.toLowerCase().includes(term) ||
|
|||
|
|
p.description?.toLowerCase().includes(term)
|
|||
|
|
);
|
|||
|
|
}, [projects, searchTerm]);
|
|||
|
|
|
|||
|
|
// 是否可以开始
|
|||
|
|
const canStart = useMemo(() => {
|
|||
|
|
if (!selectedProject) return false;
|
|||
|
|
if (isZipProject(selectedProject)) {
|
|||
|
|
return (useStoredZip && storedZipInfo?.has_file) || !!zipFile;
|
|||
|
|
}
|
|||
|
|
return !!selectedProject.repository_url && !!branch.trim();
|
|||
|
|
}, [selectedProject, useStoredZip, storedZipInfo, zipFile, branch]);
|
|||
|
|
|
|||
|
|
// 创建任务
|
|||
|
|
const handleCreate = async () => {
|
|||
|
|
if (!selectedProject) return;
|
|||
|
|
|
|||
|
|
setCreating(true);
|
|||
|
|
try {
|
|||
|
|
const agentTask = await createAgentTask({
|
|||
|
|
project_id: selectedProject.id,
|
|||
|
|
name: `Agent审计-${selectedProject.name}`,
|
|||
|
|
branch_name: isRepositoryProject(selectedProject) ? branch : undefined,
|
|||
|
|
exclude_patterns: excludePatterns,
|
|||
|
|
target_files: selectedFiles,
|
|||
|
|
verification_level: "sandbox",
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
onOpenChange(false);
|
|||
|
|
toast.success("Agent 审计任务已创建");
|
|||
|
|
navigate(`/agent-audit/${agentTask.id}`);
|
|||
|
|
} catch (err) {
|
|||
|
|
const msg = err instanceof Error ? err.message : "创建失败";
|
|||
|
|
toast.error(msg);
|
|||
|
|
} finally {
|
|||
|
|
setCreating(false);
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// 处理文件上传
|
|||
|
|
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|||
|
|
const file = e.target.files?.[0];
|
|||
|
|
if (file) {
|
|||
|
|
const validation = validateZipFile(file);
|
|||
|
|
if (!validation.valid) {
|
|||
|
|
toast.error(validation.error || "文件无效");
|
|||
|
|
e.target.value = "";
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
setZipFile(file);
|
|||
|
|
setUseStoredZip(false);
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
return (
|
|||
|
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
|||
|
|
<DialogContent className="!w-[min(90vw,480px)] !max-w-none max-h-[85vh] flex flex-col p-0 gap-0 bg-[#0d0d12] border border-gray-800 rounded-lg">
|
|||
|
|
{/* Header */}
|
|||
|
|
<DialogHeader className="px-5 py-4 border-b border-gray-800 flex-shrink-0">
|
|||
|
|
<DialogTitle className="flex items-center gap-3 font-mono text-white">
|
|||
|
|
<div className="p-2 bg-primary/20 rounded">
|
|||
|
|
<Bot className="w-5 h-5 text-primary" />
|
|||
|
|
</div>
|
|||
|
|
<div>
|
|||
|
|
<span className="text-base font-bold">New Agent Audit</span>
|
|||
|
|
<p className="text-xs text-gray-500 font-normal mt-0.5">
|
|||
|
|
AI-Powered Security Analysis
|
|||
|
|
</p>
|
|||
|
|
</div>
|
|||
|
|
</DialogTitle>
|
|||
|
|
</DialogHeader>
|
|||
|
|
|
|||
|
|
<div className="flex-1 overflow-y-auto p-5 space-y-5">
|
|||
|
|
{/* 项目选择 */}
|
|||
|
|
<div className="space-y-3">
|
|||
|
|
<div className="flex items-center justify-between">
|
|||
|
|
<span className="text-xs font-mono font-bold uppercase text-gray-400">
|
|||
|
|
Select Project
|
|||
|
|
</span>
|
|||
|
|
<Badge variant="outline" className="border-gray-700 text-gray-500 font-mono text-[10px]">
|
|||
|
|
{filteredProjects.length} available
|
|||
|
|
</Badge>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
{/* 搜索框 */}
|
|||
|
|
<div className="relative">
|
|||
|
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-600" />
|
|||
|
|
<Input
|
|||
|
|
placeholder="Search projects..."
|
|||
|
|
value={searchTerm}
|
|||
|
|
onChange={(e) => setSearchTerm(e.target.value)}
|
|||
|
|
className="pl-9 h-10 bg-gray-900/50 border-gray-800 text-white font-mono placeholder:text-gray-600 focus:border-primary focus:ring-0"
|
|||
|
|
/>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
{/* 项目列表 */}
|
|||
|
|
<ScrollArea className="h-[200px] border border-gray-800 rounded-lg bg-gray-900/30">
|
|||
|
|
{loadingProjects ? (
|
|||
|
|
<div className="flex items-center justify-center h-full">
|
|||
|
|
<Loader2 className="w-5 h-5 animate-spin text-primary" />
|
|||
|
|
</div>
|
|||
|
|
) : filteredProjects.length === 0 ? (
|
|||
|
|
<div className="flex flex-col items-center justify-center h-full text-gray-600 font-mono">
|
|||
|
|
<Package className="w-8 h-8 mb-2 opacity-50" />
|
|||
|
|
<span className="text-sm">{searchTerm ? "No matches" : "No projects"}</span>
|
|||
|
|
</div>
|
|||
|
|
) : (
|
|||
|
|
<div className="p-1">
|
|||
|
|
{filteredProjects.map((project) => (
|
|||
|
|
<ProjectItem
|
|||
|
|
key={project.id}
|
|||
|
|
project={project}
|
|||
|
|
selected={selectedProjectId === project.id}
|
|||
|
|
onSelect={() => setSelectedProjectId(project.id)}
|
|||
|
|
/>
|
|||
|
|
))}
|
|||
|
|
</div>
|
|||
|
|
)}
|
|||
|
|
</ScrollArea>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
{/* 配置区域 */}
|
|||
|
|
{selectedProject && (
|
|||
|
|
<div className="space-y-4">
|
|||
|
|
{/* 仓库项目:分支选择 */}
|
|||
|
|
{isRepositoryProject(selectedProject) && (
|
|||
|
|
<div className="flex items-center gap-3 p-3 border border-gray-800 rounded-lg bg-blue-950/20">
|
|||
|
|
<GitBranch className="w-5 h-5 text-blue-400" />
|
|||
|
|
<span className="font-mono text-sm text-gray-400 w-16">Branch</span>
|
|||
|
|
{loadingBranches ? (
|
|||
|
|
<div className="flex items-center gap-2 flex-1">
|
|||
|
|
<Loader2 className="w-4 h-4 animate-spin text-blue-400" />
|
|||
|
|
<span className="text-sm text-blue-400 font-mono">Loading...</span>
|
|||
|
|
</div>
|
|||
|
|
) : (
|
|||
|
|
<Select value={branch} onValueChange={setBranch}>
|
|||
|
|
<SelectTrigger className="flex-1 h-9 bg-gray-900/50 border-gray-700 text-white font-mono focus:ring-0">
|
|||
|
|
<SelectValue placeholder="Select branch" />
|
|||
|
|
</SelectTrigger>
|
|||
|
|
<SelectContent className="bg-gray-900 border-gray-700">
|
|||
|
|
{branches.map((b) => (
|
|||
|
|
<SelectItem key={b} value={b} className="font-mono text-white">
|
|||
|
|
{b}
|
|||
|
|
</SelectItem>
|
|||
|
|
))}
|
|||
|
|
</SelectContent>
|
|||
|
|
</Select>
|
|||
|
|
)}
|
|||
|
|
</div>
|
|||
|
|
)}
|
|||
|
|
|
|||
|
|
{/* ZIP 项目:文件选择 */}
|
|||
|
|
{isZipProject(selectedProject) && (
|
|||
|
|
<div className="p-3 border border-gray-800 rounded-lg bg-amber-950/20 space-y-3">
|
|||
|
|
<div className="flex items-center gap-3">
|
|||
|
|
<Package className="w-5 h-5 text-amber-400" />
|
|||
|
|
<span className="font-mono text-sm text-gray-400">ZIP File</span>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
{storedZipInfo?.has_file && (
|
|||
|
|
<div
|
|||
|
|
className={`p-2 rounded border cursor-pointer transition-colors ${
|
|||
|
|
useStoredZip
|
|||
|
|
? 'border-green-500 bg-green-950/30'
|
|||
|
|
: 'border-gray-700 hover:border-gray-600'
|
|||
|
|
}`}
|
|||
|
|
onClick={() => setUseStoredZip(true)}
|
|||
|
|
>
|
|||
|
|
<div className="flex items-center gap-2">
|
|||
|
|
<div className={`w-3 h-3 rounded-full border-2 ${
|
|||
|
|
useStoredZip ? 'border-green-500 bg-green-500' : 'border-gray-600'
|
|||
|
|
}`} />
|
|||
|
|
<span className="text-sm text-white font-mono">
|
|||
|
|
{storedZipInfo.original_filename}
|
|||
|
|
</span>
|
|||
|
|
<Badge className="bg-green-500/20 text-green-400 border-0 text-[10px]">
|
|||
|
|
Stored
|
|||
|
|
</Badge>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
)}
|
|||
|
|
|
|||
|
|
<div
|
|||
|
|
className={`p-2 rounded border cursor-pointer transition-colors ${
|
|||
|
|
!useStoredZip && zipFile
|
|||
|
|
? 'border-amber-500 bg-amber-950/30'
|
|||
|
|
: 'border-gray-700 hover:border-gray-600'
|
|||
|
|
}`}
|
|||
|
|
>
|
|||
|
|
<label className="flex items-center gap-2 cursor-pointer">
|
|||
|
|
<div className={`w-3 h-3 rounded-full border-2 ${
|
|||
|
|
!useStoredZip && zipFile ? 'border-amber-500 bg-amber-500' : 'border-gray-600'
|
|||
|
|
}`} />
|
|||
|
|
<Upload className="w-4 h-4 text-gray-500" />
|
|||
|
|
<span className="text-sm text-gray-400 font-mono">
|
|||
|
|
{zipFile ? zipFile.name : "Upload new file..."}
|
|||
|
|
</span>
|
|||
|
|
<input
|
|||
|
|
type="file"
|
|||
|
|
accept=".zip"
|
|||
|
|
onChange={handleFileChange}
|
|||
|
|
className="hidden"
|
|||
|
|
/>
|
|||
|
|
</label>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
)}
|
|||
|
|
|
|||
|
|
{/* 高级选项 */}
|
|||
|
|
<Collapsible open={showAdvanced} onOpenChange={setShowAdvanced}>
|
|||
|
|
<CollapsibleTrigger className="flex items-center gap-2 text-xs font-mono text-gray-500 hover:text-gray-300 transition-colors">
|
|||
|
|
<ChevronRight className={`w-4 h-4 transition-transform ${showAdvanced ? "rotate-90" : ""}`} />
|
|||
|
|
<Settings2 className="w-4 h-4" />
|
|||
|
|
<span className="uppercase font-bold">Advanced Options</span>
|
|||
|
|
</CollapsibleTrigger>
|
|||
|
|
<CollapsibleContent className="mt-3 space-y-3">
|
|||
|
|
{/* 文件选择 */}
|
|||
|
|
{(() => {
|
|||
|
|
const isRepo = isRepositoryProject(selectedProject);
|
|||
|
|
const isZip = isZipProject(selectedProject);
|
|||
|
|
const hasStoredZip = storedZipInfo?.has_file;
|
|||
|
|
// 可以选择文件的条件:仓库项目 或 ZIP项目使用已存储文件
|
|||
|
|
const canSelectFiles = isRepo || (isZip && useStoredZip && hasStoredZip);
|
|||
|
|
|
|||
|
|
return (
|
|||
|
|
<div className="flex items-center justify-between p-3 border border-dashed border-gray-700 rounded-lg bg-gray-900/30">
|
|||
|
|
<div>
|
|||
|
|
<p className="font-mono text-xs uppercase font-bold text-gray-500">
|
|||
|
|
Scan Scope
|
|||
|
|
</p>
|
|||
|
|
<p className="text-sm text-white font-mono mt-1">
|
|||
|
|
{selectedFiles
|
|||
|
|
? `${selectedFiles.length} files selected`
|
|||
|
|
: "All files"}
|
|||
|
|
</p>
|
|||
|
|
</div>
|
|||
|
|
<div className="flex gap-2">
|
|||
|
|
{selectedFiles && canSelectFiles && (
|
|||
|
|
<Button
|
|||
|
|
size="sm"
|
|||
|
|
variant="ghost"
|
|||
|
|
onClick={() => setSelectedFiles(undefined)}
|
|||
|
|
className="h-8 text-xs text-red-400 hover:bg-red-900/30 hover:text-red-300"
|
|||
|
|
>
|
|||
|
|
Reset
|
|||
|
|
</Button>
|
|||
|
|
)}
|
|||
|
|
<Button
|
|||
|
|
size="sm"
|
|||
|
|
variant="outline"
|
|||
|
|
onClick={() => setShowFileSelection(true)}
|
|||
|
|
disabled={!canSelectFiles}
|
|||
|
|
className="h-8 text-xs border-gray-700 text-gray-300 hover:bg-gray-800 hover:text-white font-mono disabled:opacity-50"
|
|||
|
|
>
|
|||
|
|
<FolderOpen className="w-3 h-3 mr-1" />
|
|||
|
|
Select Files
|
|||
|
|
</Button>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
);
|
|||
|
|
})()}
|
|||
|
|
|
|||
|
|
{/* 排除模式 */}
|
|||
|
|
<div className="p-3 border border-dashed border-gray-700 rounded-lg bg-gray-900/30 space-y-3">
|
|||
|
|
<div className="flex items-center justify-between">
|
|||
|
|
<span className="font-mono text-xs uppercase font-bold text-gray-500">
|
|||
|
|
Exclude Patterns
|
|||
|
|
</span>
|
|||
|
|
<button
|
|||
|
|
type="button"
|
|||
|
|
onClick={() => setExcludePatterns(DEFAULT_EXCLUDES)}
|
|||
|
|
className="text-xs font-mono text-primary hover:text-primary/80"
|
|||
|
|
>
|
|||
|
|
Reset
|
|||
|
|
</button>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div className="flex flex-wrap gap-1.5">
|
|||
|
|
{excludePatterns.map((p) => (
|
|||
|
|
<Badge
|
|||
|
|
key={p}
|
|||
|
|
variant="secondary"
|
|||
|
|
className="bg-gray-800 text-gray-300 border-0 font-mono text-xs cursor-pointer hover:bg-red-900/50 hover:text-red-400"
|
|||
|
|
onClick={() => setExcludePatterns((prev) => prev.filter((x) => x !== p))}
|
|||
|
|
>
|
|||
|
|
{p} ×
|
|||
|
|
</Badge>
|
|||
|
|
))}
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<Input
|
|||
|
|
placeholder="Add pattern, press Enter..."
|
|||
|
|
className="h-8 bg-gray-900/50 border-gray-700 text-white font-mono text-sm placeholder:text-gray-600 focus:ring-0"
|
|||
|
|
onKeyDown={(e) => {
|
|||
|
|
if (e.key === "Enter" && e.currentTarget.value) {
|
|||
|
|
const val = e.currentTarget.value.trim();
|
|||
|
|
if (val && !excludePatterns.includes(val)) {
|
|||
|
|
setExcludePatterns((prev) => [...prev, val]);
|
|||
|
|
}
|
|||
|
|
e.currentTarget.value = "";
|
|||
|
|
}
|
|||
|
|
}}
|
|||
|
|
/>
|
|||
|
|
</div>
|
|||
|
|
</CollapsibleContent>
|
|||
|
|
</Collapsible>
|
|||
|
|
</div>
|
|||
|
|
)}
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
{/* Footer */}
|
|||
|
|
<div className="flex-shrink-0 flex justify-end gap-3 px-5 py-4 bg-gray-900/50 border-t border-gray-800">
|
|||
|
|
<Button
|
|||
|
|
variant="ghost"
|
|||
|
|
onClick={() => onOpenChange(false)}
|
|||
|
|
disabled={creating}
|
|||
|
|
className="px-4 h-10 font-mono text-gray-400 hover:text-white hover:bg-gray-800"
|
|||
|
|
>
|
|||
|
|
Cancel
|
|||
|
|
</Button>
|
|||
|
|
<Button
|
|||
|
|
onClick={handleCreate}
|
|||
|
|
disabled={!canStart || creating}
|
|||
|
|
className="px-5 h-10 bg-primary hover:bg-primary/90 text-white font-mono font-bold"
|
|||
|
|
>
|
|||
|
|
{creating ? (
|
|||
|
|
<>
|
|||
|
|
<Loader2 className="w-4 h-4 animate-spin mr-2" />
|
|||
|
|
Starting...
|
|||
|
|
</>
|
|||
|
|
) : (
|
|||
|
|
<>
|
|||
|
|
<Play className="w-4 h-4 mr-2" />
|
|||
|
|
Start Audit
|
|||
|
|
</>
|
|||
|
|
)}
|
|||
|
|
</Button>
|
|||
|
|
</div>
|
|||
|
|
</DialogContent>
|
|||
|
|
|
|||
|
|
{/* 文件选择对话框 */}
|
|||
|
|
<FileSelectionDialog
|
|||
|
|
open={showFileSelection}
|
|||
|
|
onOpenChange={setShowFileSelection}
|
|||
|
|
projectId={selectedProjectId}
|
|||
|
|
branch={branch}
|
|||
|
|
excludePatterns={excludePatterns}
|
|||
|
|
onConfirm={setSelectedFiles}
|
|||
|
|
/>
|
|||
|
|
</Dialog>
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 项目列表项
|
|||
|
|
function ProjectItem({
|
|||
|
|
project,
|
|||
|
|
selected,
|
|||
|
|
onSelect,
|
|||
|
|
}: {
|
|||
|
|
project: Project;
|
|||
|
|
selected: boolean;
|
|||
|
|
onSelect: () => void;
|
|||
|
|
}) {
|
|||
|
|
const isRepo = isRepositoryProject(project);
|
|||
|
|
|
|||
|
|
return (
|
|||
|
|
<div
|
|||
|
|
className={`flex items-center gap-3 p-3 cursor-pointer rounded-lg transition-all ${
|
|||
|
|
selected
|
|||
|
|
? "bg-primary/10 border border-primary/50"
|
|||
|
|
: "hover:bg-gray-800/50 border border-transparent"
|
|||
|
|
}`}
|
|||
|
|
onClick={onSelect}
|
|||
|
|
>
|
|||
|
|
<div className={`p-1.5 rounded ${isRepo ? "bg-blue-500/20" : "bg-amber-500/20"}`}>
|
|||
|
|
{isRepo ? (
|
|||
|
|
<Globe className="w-4 h-4 text-blue-400" />
|
|||
|
|
) : (
|
|||
|
|
<Package className="w-4 h-4 text-amber-400" />
|
|||
|
|
)}
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div className="flex-1 min-w-0">
|
|||
|
|
<div className="flex items-center gap-2">
|
|||
|
|
<span className={`font-mono text-sm truncate ${selected ? 'text-white font-bold' : 'text-gray-300'}`}>
|
|||
|
|
{project.name}
|
|||
|
|
</span>
|
|||
|
|
<Badge
|
|||
|
|
variant="outline"
|
|||
|
|
className={`text-[10px] px-1 py-0 font-mono ${
|
|||
|
|
isRepo
|
|||
|
|
? "border-blue-500/50 text-blue-400"
|
|||
|
|
: "border-amber-500/50 text-amber-400"
|
|||
|
|
}`}
|
|||
|
|
>
|
|||
|
|
{isRepo ? "REPO" : "ZIP"}
|
|||
|
|
</Badge>
|
|||
|
|
</div>
|
|||
|
|
{project.description && (
|
|||
|
|
<p className="text-xs text-gray-600 mt-0.5 font-mono truncate">
|
|||
|
|
{project.description}
|
|||
|
|
</p>
|
|||
|
|
)}
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
{selected && (
|
|||
|
|
<div className="w-2 h-2 rounded-full bg-primary animate-pulse" />
|
|||
|
|
)}
|
|||
|
|
</div>
|
|||
|
|
);
|
|||
|
|
}
|