feat: implement ZIP file management for project auditing

- Added functionality to automatically load saved ZIP files from IndexedDB when creating audit tasks.
- Introduced loading indicators and user feedback for ZIP file operations in the CreateTaskDialog component.
- Enhanced project detail page to handle ZIP file audits, including error handling and user notifications.
- Implemented methods for saving and deleting ZIP files in IndexedDB to streamline project management.
This commit is contained in:
lintsinghua 2025-10-27 18:34:37 +08:00
parent 281ab2c9e7
commit 86e3892d45
6 changed files with 461 additions and 119 deletions

View File

@ -24,6 +24,7 @@ import { toast } from "sonner";
import TerminalProgressDialog from "./TerminalProgressDialog"; import TerminalProgressDialog from "./TerminalProgressDialog";
import { runRepositoryAudit } from "@/features/projects/services/repoScan"; import { runRepositoryAudit } from "@/features/projects/services/repoScan";
import { scanZipFile, validateZipFile } from "@/features/projects/services/repoZipScan"; import { scanZipFile, validateZipFile } from "@/features/projects/services/repoZipScan";
import { loadZipFile } from "@/shared/utils/zipStorage";
interface CreateTaskDialogProps { interface CreateTaskDialogProps {
open: boolean; open: boolean;
@ -40,6 +41,8 @@ export default function CreateTaskDialog({ open, onOpenChange, onTaskCreated, pr
const [showTerminalDialog, setShowTerminalDialog] = useState(false); const [showTerminalDialog, setShowTerminalDialog] = useState(false);
const [currentTaskId, setCurrentTaskId] = useState<string | null>(null); const [currentTaskId, setCurrentTaskId] = useState<string | null>(null);
const [zipFile, setZipFile] = useState<File | null>(null); const [zipFile, setZipFile] = useState<File | null>(null);
const [loadingZipFile, setLoadingZipFile] = useState(false);
const [hasLoadedZip, setHasLoadedZip] = useState(false);
const [taskForm, setTaskForm] = useState<CreateAuditTaskForm>({ const [taskForm, setTaskForm] = useState<CreateAuditTaskForm>({
project_id: "", project_id: "",
@ -72,9 +75,40 @@ export default function CreateTaskDialog({ open, onOpenChange, onTaskCreated, pr
if (preselectedProjectId) { if (preselectedProjectId) {
setTaskForm(prev => ({ ...prev, project_id: preselectedProjectId })); setTaskForm(prev => ({ ...prev, project_id: preselectedProjectId }));
} }
// 重置ZIP文件状态
setZipFile(null);
setHasLoadedZip(false);
} }
}, [open, preselectedProjectId]); }, [open, preselectedProjectId]);
// 当项目ID变化时尝试自动加载保存的ZIP文件
useEffect(() => {
const autoLoadZipFile = async () => {
if (!taskForm.project_id || hasLoadedZip) return;
const project = projects.find(p => p.id === taskForm.project_id);
if (!project || project.repository_type !== 'other') return;
try {
setLoadingZipFile(true);
const savedFile = await loadZipFile(taskForm.project_id);
if (savedFile) {
setZipFile(savedFile);
setHasLoadedZip(true);
console.log('✓ 已自动加载保存的ZIP文件:', savedFile.name);
toast.success(`已加载保存的ZIP文件: ${savedFile.name}`);
}
} catch (error) {
console.error('自动加载ZIP文件失败:', error);
} finally {
setLoadingZipFile(false);
}
};
autoLoadZipFile();
}, [taskForm.project_id, projects, hasLoadedZip]);
const loadProjects = async () => { const loadProjects = async () => {
try { try {
setLoading(true); setLoading(true);
@ -338,61 +372,86 @@ export default function CreateTaskDialog({ open, onOpenChange, onTaskCreated, pr
<Card className="bg-amber-50 border-amber-200"> <Card className="bg-amber-50 border-amber-200">
<CardContent className="p-4"> <CardContent className="p-4">
<div className="space-y-3"> <div className="space-y-3">
<div className="flex items-start space-x-3"> {loadingZipFile ? (
<AlertCircle className="w-5 h-5 text-amber-600 mt-0.5" /> <div className="flex items-center space-x-3 p-4 bg-blue-50 border border-blue-200 rounded-lg">
<div> <div className="animate-spin rounded-full h-5 w-5 border-b-2 border-blue-600"></div>
<p className="font-medium text-amber-900 text-sm">ZIP项目需要上传文件</p> <p className="text-sm text-blue-800">ZIP文件...</p>
<p className="text-xs text-amber-700 mt-1">
ZIP上传创建的ZIP文件进行扫描
</p>
</div> </div>
</div> ) : zipFile ? (
<div className="flex items-start space-x-3 p-4 bg-green-50 border border-green-200 rounded-lg">
<div className="space-y-2"> <Info className="w-5 h-5 text-green-600 mt-0.5" />
<Label htmlFor="zipFile">ZIP文件</Label> <div className="flex-1">
<Input <p className="font-medium text-green-900 text-sm"></p>
id="zipFile" <p className="text-xs text-green-700 mt-1">
type="file" 使ZIP文件: {zipFile.name} (
accept=".zip" {zipFile.size >= 1024 * 1024
onChange={(e) => { ? `${(zipFile.size / 1024 / 1024).toFixed(2)} MB`
const file = e.target.files?.[0]; : zipFile.size >= 1024
if (file) { ? `${(zipFile.size / 1024).toFixed(2)} KB`
console.log('📁 选择的文件:', { : `${zipFile.size} B`
name: file.name, })
size: file.size, </p>
type: file.type, </div>
sizeMB: (file.size / 1024 / 1024).toFixed(2) <Button
}); size="sm"
variant="outline"
const validation = validateZipFile(file); onClick={() => {
if (!validation.valid) { setZipFile(null);
toast.error(validation.error || "文件无效"); setHasLoadedZip(false);
e.target.value = ''; }}
return; >
}
setZipFile(file); </Button>
</div>
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`; <div className="flex items-start space-x-3">
<AlertCircle className="w-5 h-5 text-amber-600 mt-0.5" />
toast.success(`已选择文件: ${file.name} (${sizeText})`); <div>
} <p className="font-medium text-amber-900 text-sm">ZIP文件</p>
}} <p className="text-xs text-amber-700 mt-1">
className="cursor-pointer" ZIP文件
/> </p>
{zipFile && ( </div>
<p className="text-xs text-green-600"> </div>
: {zipFile.name} (
{zipFile.size >= 1024 * 1024 <div className="space-y-2">
? `${(zipFile.size / 1024 / 1024).toFixed(2)} MB` <Label htmlFor="zipFile">ZIP文件</Label>
: zipFile.size >= 1024 <Input
? `${(zipFile.size / 1024).toFixed(2)} KB` id="zipFile"
: `${zipFile.size} B` type="file"
}) accept=".zip"
</p> onChange={(e) => {
)} const file = e.target.files?.[0];
</div> 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);
setHasLoadedZip(true);
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"
/>
</div>
</>
)}
</div> </div>
</CardContent> </CardContent>
</Card> </Card>

View File

@ -139,7 +139,7 @@ export function SystemConfig() {
const parsedConfig = JSON.parse(savedConfig); const parsedConfig = JSON.parse(savedConfig);
setConfig(parsedConfig); setConfig(parsedConfig);
setConfigSource('runtime'); setConfigSource('runtime');
toast.success("已加载运行时配置"); console.log('已加载运行时配置');
} else { } else {
// 使用构建时配置 // 使用构建时配置
loadFromEnv(); loadFromEnv();

View File

@ -24,8 +24,9 @@ import {
FileText FileText
} from "lucide-react"; } from "lucide-react";
import { api } from "@/shared/config/database"; import { api } from "@/shared/config/database";
import { runRepositoryAudit } from "@/features/projects/services"; import { runRepositoryAudit, scanZipFile } from "@/features/projects/services";
import type { Project, AuditTask, CreateProjectForm } from "@/shared/types"; import type { Project, AuditTask, CreateProjectForm } from "@/shared/types";
import { loadZipFile } from "@/shared/utils/zipStorage";
import { toast } from "sonner"; import { toast } from "sonner";
import CreateTaskDialog from "@/components/audit/CreateTaskDialog"; import CreateTaskDialog from "@/components/audit/CreateTaskDialog";
import TerminalProgressDialog from "@/components/audit/TerminalProgressDialog"; import TerminalProgressDialog from "@/components/audit/TerminalProgressDialog";
@ -81,34 +82,76 @@ export default function ProjectDetail() {
const handleRunAudit = async () => { const handleRunAudit = async () => {
if (!project || !id) return; if (!project || !id) return;
if (!project.repository_url || project.repository_type !== 'github') {
toast.error('请在项目中配置 GitHub 仓库地址'); // 如果是GitHub项目且有仓库地址直接启动审计
return; if (project.repository_type === 'github' && project.repository_url) {
} try {
try { setScanning(true);
setScanning(true); console.log('开始启动审计任务...');
console.log('开始启动审计任务...'); const taskId = await runRepositoryAudit({
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 });
});
console.log('审计任务创建成功taskId:', taskId);
console.log('审计任务创建成功taskId:', taskId);
// 显示终端进度窗口
// 显示终端进度窗口 setCurrentTaskId(taskId);
setCurrentTaskId(taskId); setShowTerminalDialog(true);
setShowTerminalDialog(true);
// 重新加载项目数据
// 重新加载项目数据 loadProjectData();
loadProjectData(); } 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); }
} else {
// 对于ZIP项目尝试从IndexedDB加载保存的文件
try {
setScanning(true);
const file = await loadZipFile(id);
if (file) {
console.log('找到保存的ZIP文件开始启动审计...');
try {
// 启动ZIP文件审计
const taskId = await scanZipFile({
projectId: id,
zipFile: file,
excludePatterns: ['node_modules/**', '.git/**', 'dist/**', 'build/**'],
createdBy: 'local-user'
});
console.log('审计任务创建成功taskId:', taskId);
// 显示终端进度窗口
setCurrentTaskId(taskId);
setShowTerminalDialog(true);
// 重新加载项目数据
loadProjectData();
} catch (e: any) {
console.error('启动审计失败:', e);
toast.error(e?.message || '启动审计失败');
} finally {
setScanning(false);
}
} else {
setScanning(false);
toast.error('未找到保存的ZIP文件请通过"新建任务"上传');
setShowCreateTaskDialog(true);
}
} catch (error) {
console.error('启动审计失败:', error);
setScanning(false);
toast.error('读取ZIP文件失败请通过"新建任务"重新上传');
setShowCreateTaskDialog(true);
}
} }
}; };

View File

@ -29,12 +29,12 @@ import {
CheckCircle CheckCircle
} from "lucide-react"; } from "lucide-react";
import { api } from "@/shared/config/database"; import { api } from "@/shared/config/database";
import { scanZipFile, validateZipFile } from "@/features/projects/services"; import { validateZipFile } from "@/features/projects/services";
import type { Project, CreateProjectForm } from "@/shared/types"; import type { Project, CreateProjectForm } from "@/shared/types";
import { saveZipFile } from "@/shared/utils/zipStorage";
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"; import CreateTaskDialog from "@/components/audit/CreateTaskDialog";
import TerminalProgressDialog from "@/components/audit/TerminalProgressDialog";
export default function Projects() { export default function Projects() {
const [projects, setProjects] = useState<Project[]>([]); const [projects, setProjects] = useState<Project[]>([]);
@ -46,8 +46,6 @@ export default function Projects() {
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);
const [showTerminalDialog, setShowTerminalDialog] = useState(false);
const [currentTaskId, setCurrentTaskId] = useState<string | null>(null);
const [showDeleteDialog, setShowDeleteDialog] = useState(false); const [showDeleteDialog, setShowDeleteDialog] = useState(false);
const [projectToDelete, setProjectToDelete] = useState<Project | null>(null); const [projectToDelete, setProjectToDelete] = useState<Project | null>(null);
const [showEditDialog, setShowEditDialog] = useState(false); const [showEditDialog, setShowEditDialog] = useState(false);
@ -144,30 +142,29 @@ export default function Projects() {
setUploading(true); setUploading(true);
setUploadProgress(0); setUploadProgress(0);
// 模拟上传进度
const progressInterval = setInterval(() => {
setUploadProgress(prev => {
if (prev >= 100) {
clearInterval(progressInterval);
return 100;
}
return prev + 20;
});
}, 100);
// 创建项目 // 创建项目
const project = await api.createProject({ const project = await api.createProject({
...createForm, ...createForm,
repository_type: "other" repository_type: "other"
} as any); } as any);
// 模拟上传进度 // 保存ZIP文件到IndexedDB使用项目ID作为key
const progressInterval = setInterval(() => { try {
setUploadProgress(prev => { await saveZipFile(project.id, file);
if (prev >= 90) { } catch (error) {
clearInterval(progressInterval); console.error('保存ZIP文件失败:', error);
return 90; }
}
return prev + 10;
});
}, 200);
// 扫描ZIP文件
const taskId = await scanZipFile({
projectId: project.id,
zipFile: file,
excludePatterns: ['node_modules/**', '.git/**', 'dist/**', 'build/**'],
createdBy: 'local-user' // 使用默认本地用户ID
});
clearInterval(progressInterval); clearInterval(progressInterval);
setUploadProgress(100); setUploadProgress(100);
@ -177,9 +174,10 @@ export default function Projects() {
resetCreateForm(); resetCreateForm();
loadProjects(); loadProjects();
// 显示终端进度窗口 toast.success(`项目 "${project.name}" 已创建`, {
setCurrentTaskId(taskId); description: 'ZIP文件已保存您可以启动代码审计',
setShowTerminalDialog(true); duration: 4000
});
} catch (error: any) { } catch (error: any) {
console.error('Upload failed:', error); console.error('Upload failed:', error);
@ -520,8 +518,8 @@ export default function Projects() {
<ul className="space-y-1 text-xs"> <ul className="space-y-1 text-xs">
<li> ZIP </li> <li> ZIP </li>
<li> node_modules.git </li> <li> node_modules.git </li>
<li> </li> <li> ZIP </li>
<li> </li> <li> </li>
</ul> </ul>
</div> </div>
</div> </div>
@ -766,14 +764,6 @@ export default function Projects() {
preselectedProjectId={selectedProjectForTask} preselectedProjectId={selectedProjectForTask}
/> />
{/* 终端进度对话框 */}
<TerminalProgressDialog
open={showTerminalDialog}
onOpenChange={setShowTerminalDialog}
taskId={currentTaskId}
taskType="zip"
/>
{/* 编辑项目对话框 */} {/* 编辑项目对话框 */}
<Dialog open={showEditDialog} onOpenChange={setShowEditDialog}> <Dialog open={showEditDialog} onOpenChange={setShowEditDialog}>
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto"> <DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">

View File

@ -18,6 +18,7 @@ import {
import { api } from "@/shared/config/database"; import { api } from "@/shared/config/database";
import type { Project } from "@/shared/types"; import type { Project } from "@/shared/types";
import { toast } from "sonner"; import { toast } from "sonner";
import { deleteZipFile } from "@/shared/utils/zipStorage";
export default function RecycleBin() { export default function RecycleBin() {
const [deletedProjects, setDeletedProjects] = useState<Project[]>([]); const [deletedProjects, setDeletedProjects] = useState<Project[]>([]);
@ -73,7 +74,16 @@ export default function RecycleBin() {
if (!selectedProject) return; if (!selectedProject) return;
try { try {
// 删除项目数据
await api.permanentlyDeleteProject(selectedProject.id); await api.permanentlyDeleteProject(selectedProject.id);
// 删除保存的ZIP文件如果有
try {
await deleteZipFile(selectedProject.id);
} catch (error) {
console.error('删除ZIP文件失败:', error);
}
toast.success(`项目 "${selectedProject.name}" 已永久删除`); toast.success(`项目 "${selectedProject.name}" 已永久删除`);
setShowPermanentDeleteDialog(false); setShowPermanentDeleteDialog(false);
setSelectedProject(null); setSelectedProject(null);

View File

@ -0,0 +1,240 @@
/**
* ZIP文件存储工具
* IndexedDB中的ZIP文件
*/
const DB_NAME = 'xcodereviewer_files';
const STORE_NAME = 'zipFiles';
/**
* ZIP文件到IndexedDB
*/
export async function saveZipFile(projectId: string, file: File): Promise<void> {
// 检查浏览器是否支持IndexedDB
if (!window.indexedDB) {
throw new Error('您的浏览器不支持IndexedDB无法保存ZIP文件');
}
return new Promise((resolve, reject) => {
// 不指定版本号让IndexedDB使用当前最新版本
const dbRequest = indexedDB.open(DB_NAME);
dbRequest.onupgradeneeded = (event) => {
try {
const db = (event.target as IDBOpenDBRequest).result;
if (!db.objectStoreNames.contains(STORE_NAME)) {
db.createObjectStore(STORE_NAME);
}
} catch (error) {
console.error('创建对象存储失败:', error);
reject(new Error('创建存储结构失败,请检查浏览器设置'));
}
};
dbRequest.onsuccess = async (event) => {
const db = (event.target as IDBOpenDBRequest).result;
// 检查对象存储是否存在,如果不存在则需要升级数据库
if (!db.objectStoreNames.contains(STORE_NAME)) {
db.close();
// 增加版本号以触发onupgradeneeded
const upgradeRequest = indexedDB.open(DB_NAME, db.version + 1);
upgradeRequest.onupgradeneeded = (event) => {
try {
const upgradeDb = (event.target as IDBOpenDBRequest).result;
if (!upgradeDb.objectStoreNames.contains(STORE_NAME)) {
upgradeDb.createObjectStore(STORE_NAME);
}
} catch (error) {
console.error('升级数据库时创建对象存储失败:', error);
}
};
upgradeRequest.onsuccess = async (event) => {
const upgradeDb = (event.target as IDBOpenDBRequest).result;
await performSave(upgradeDb, file, projectId, resolve, reject);
};
upgradeRequest.onerror = (event) => {
const error = (event.target as IDBOpenDBRequest).error;
console.error('升级数据库失败:', error);
reject(new Error(`升级数据库失败: ${error?.message || '未知错误'}`));
};
} else {
await performSave(db, file, projectId, resolve, reject);
}
};
dbRequest.onerror = (event) => {
const error = (event.target as IDBOpenDBRequest).error;
console.error('打开IndexedDB失败:', error);
const errorMsg = error?.message || '未知错误';
reject(new Error(`无法打开本地存储,可能是隐私模式或存储权限问题: ${errorMsg}`));
};
dbRequest.onblocked = () => {
console.warn('数据库被阻塞,可能有其他标签页正在使用');
reject(new Error('数据库被占用,请关闭其他标签页后重试'));
};
});
}
async function performSave(
db: IDBDatabase,
file: File,
projectId: string,
resolve: () => void,
reject: (error: Error) => void
) {
try {
const arrayBuffer = await file.arrayBuffer();
const transaction = db.transaction([STORE_NAME], 'readwrite');
const store = transaction.objectStore(STORE_NAME);
const putRequest = store.put({
buffer: arrayBuffer,
fileName: file.name,
fileSize: file.size,
uploadedAt: new Date().toISOString()
}, projectId);
putRequest.onerror = (event) => {
const error = (event.target as IDBRequest).error;
console.error('写入数据失败:', error);
reject(new Error(`保存ZIP文件失败: ${error?.message || '未知错误'}`));
};
transaction.oncomplete = () => {
console.log(`ZIP文件已保存到项目 ${projectId} (${(file.size / 1024 / 1024).toFixed(2)} MB)`);
db.close();
resolve();
};
transaction.onerror = (event) => {
const error = (event.target as IDBTransaction).error;
console.error('事务失败:', error);
reject(new Error(`保存事务失败: ${error?.message || '未知错误'}`));
};
transaction.onabort = () => {
console.error('事务被中止');
reject(new Error('保存操作被中止'));
};
} catch (error) {
console.error('保存ZIP文件时发生异常:', error);
reject(error as Error);
}
}
/**
* IndexedDB加载ZIP文件
*/
export async function loadZipFile(projectId: string): Promise<File | null> {
return new Promise((resolve, reject) => {
// 不指定版本号让IndexedDB使用当前最新版本
const dbRequest = indexedDB.open(DB_NAME);
dbRequest.onupgradeneeded = (event) => {
const db = (event.target as IDBOpenDBRequest).result;
if (!db.objectStoreNames.contains(STORE_NAME)) {
db.createObjectStore(STORE_NAME);
}
};
dbRequest.onsuccess = (event) => {
const db = (event.target as IDBOpenDBRequest).result;
if (!db.objectStoreNames.contains(STORE_NAME)) {
db.close();
resolve(null);
return;
}
const transaction = db.transaction([STORE_NAME], 'readonly');
const store = transaction.objectStore(STORE_NAME);
const getRequest = store.get(projectId);
getRequest.onsuccess = () => {
const savedFile = getRequest.result;
if (savedFile && savedFile.buffer) {
const blob = new Blob([savedFile.buffer], { type: 'application/zip' });
const file = new File([blob], savedFile.fileName, { type: 'application/zip' });
resolve(file);
} else {
resolve(null);
}
};
getRequest.onerror = () => {
reject(new Error('读取ZIP文件失败'));
};
};
dbRequest.onerror = () => {
// 数据库打开失败可能是首次使用返回null而不是报错
console.warn('打开ZIP文件数据库失败可能是首次使用');
resolve(null);
};
});
}
/**
* ZIP文件
*/
export async function deleteZipFile(projectId: string): Promise<void> {
return new Promise((resolve, reject) => {
// 不指定版本号让IndexedDB使用当前最新版本
const dbRequest = indexedDB.open(DB_NAME);
dbRequest.onupgradeneeded = (event) => {
const db = (event.target as IDBOpenDBRequest).result;
if (!db.objectStoreNames.contains(STORE_NAME)) {
db.createObjectStore(STORE_NAME);
}
};
dbRequest.onsuccess = (event) => {
const db = (event.target as IDBOpenDBRequest).result;
if (!db.objectStoreNames.contains(STORE_NAME)) {
db.close();
resolve();
return;
}
const transaction = db.transaction([STORE_NAME], 'readwrite');
const store = transaction.objectStore(STORE_NAME);
const deleteRequest = store.delete(projectId);
deleteRequest.onsuccess = () => {
console.log(`已删除项目 ${projectId} 的ZIP文件`);
resolve();
};
deleteRequest.onerror = () => {
reject(new Error('删除ZIP文件失败'));
};
};
dbRequest.onerror = () => {
// 数据库打开失败可能文件不存在直接resolve
console.warn('打开ZIP文件数据库失败跳过删除操作');
resolve();
};
});
}
/**
* ZIP文件
*/
export async function hasZipFile(projectId: string): Promise<boolean> {
try {
const file = await loadZipFile(projectId);
return file !== null;
} catch {
return false;
}
}