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 { runRepositoryAudit } from "@/features/projects/services/repoScan";
import { scanZipFile, validateZipFile } from "@/features/projects/services/repoZipScan";
import { loadZipFile } from "@/shared/utils/zipStorage";
interface CreateTaskDialogProps {
open: boolean;
@ -40,6 +41,8 @@ export default function CreateTaskDialog({ open, onOpenChange, onTaskCreated, pr
const [showTerminalDialog, setShowTerminalDialog] = useState(false);
const [currentTaskId, setCurrentTaskId] = useState<string | null>(null);
const [zipFile, setZipFile] = useState<File | null>(null);
const [loadingZipFile, setLoadingZipFile] = useState(false);
const [hasLoadedZip, setHasLoadedZip] = useState(false);
const [taskForm, setTaskForm] = useState<CreateAuditTaskForm>({
project_id: "",
@ -72,9 +75,40 @@ export default function CreateTaskDialog({ open, onOpenChange, onTaskCreated, pr
if (preselectedProjectId) {
setTaskForm(prev => ({ ...prev, project_id: preselectedProjectId }));
}
// 重置ZIP文件状态
setZipFile(null);
setHasLoadedZip(false);
}
}, [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 () => {
try {
setLoading(true);
@ -338,61 +372,86 @@ export default function CreateTaskDialog({ open, onOpenChange, onTaskCreated, pr
<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>
{loadingZipFile ? (
<div className="flex items-center space-x-3 p-4 bg-blue-50 border border-blue-200 rounded-lg">
<div className="animate-spin rounded-full h-5 w-5 border-b-2 border-blue-600"></div>
<p className="text-sm text-blue-800">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>
) : zipFile ? (
<div className="flex items-start space-x-3 p-4 bg-green-50 border border-green-200 rounded-lg">
<Info className="w-5 h-5 text-green-600 mt-0.5" />
<div className="flex-1">
<p className="font-medium text-green-900 text-sm"></p>
<p className="text-xs text-green-700 mt-1">
使ZIP文件: {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>
<Button
size="sm"
variant="outline"
onClick={() => {
setZipFile(null);
setHasLoadedZip(false);
}}
>
</Button>
</div>
) : (
<>
<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文件
</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);
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>
</CardContent>
</Card>

View File

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

View File

@ -24,8 +24,9 @@ import {
FileText
} from "lucide-react";
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 { loadZipFile } from "@/shared/utils/zipStorage";
import { toast } from "sonner";
import CreateTaskDialog from "@/components/audit/CreateTaskDialog";
import TerminalProgressDialog from "@/components/audit/TerminalProgressDialog";
@ -81,34 +82,76 @@ export default function ProjectDetail() {
const handleRunAudit = async () => {
if (!project || !id) return;
if (!project.repository_url || project.repository_type !== 'github') {
toast.error('请在项目中配置 GitHub 仓库地址');
return;
}
try {
setScanning(true);
console.log('开始启动审计任务...');
const taskId = await runRepositoryAudit({
projectId: id,
repoUrl: project.repository_url,
branch: project.default_branch || 'main',
githubToken: undefined,
createdBy: undefined
});
console.log('审计任务创建成功taskId:', taskId);
// 显示终端进度窗口
setCurrentTaskId(taskId);
setShowTerminalDialog(true);
// 重新加载项目数据
loadProjectData();
} catch (e: any) {
console.error('启动审计失败:', e);
toast.error(e?.message || '启动审计失败');
} finally {
setScanning(false);
// 如果是GitHub项目且有仓库地址直接启动审计
if (project.repository_type === 'github' && project.repository_url) {
try {
setScanning(true);
console.log('开始启动审计任务...');
const taskId = await runRepositoryAudit({
projectId: id,
repoUrl: project.repository_url,
branch: project.default_branch || 'main',
githubToken: undefined,
createdBy: undefined
});
console.log('审计任务创建成功taskId:', taskId);
// 显示终端进度窗口
setCurrentTaskId(taskId);
setShowTerminalDialog(true);
// 重新加载项目数据
loadProjectData();
} catch (e: any) {
console.error('启动审计失败:', e);
toast.error(e?.message || '启动审计失败');
} finally {
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
} from "lucide-react";
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 { saveZipFile } from "@/shared/utils/zipStorage";
import { Link } from "react-router-dom";
import { toast } from "sonner";
import CreateTaskDialog from "@/components/audit/CreateTaskDialog";
import TerminalProgressDialog from "@/components/audit/TerminalProgressDialog";
export default function Projects() {
const [projects, setProjects] = useState<Project[]>([]);
@ -46,8 +46,6 @@ export default function Projects() {
const [uploadProgress, setUploadProgress] = useState(0);
const [uploading, setUploading] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const [showTerminalDialog, setShowTerminalDialog] = useState(false);
const [currentTaskId, setCurrentTaskId] = useState<string | null>(null);
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
const [projectToDelete, setProjectToDelete] = useState<Project | null>(null);
const [showEditDialog, setShowEditDialog] = useState(false);
@ -144,30 +142,29 @@ export default function Projects() {
setUploading(true);
setUploadProgress(0);
// 模拟上传进度
const progressInterval = setInterval(() => {
setUploadProgress(prev => {
if (prev >= 100) {
clearInterval(progressInterval);
return 100;
}
return prev + 20;
});
}, 100);
// 创建项目
const project = await api.createProject({
...createForm,
repository_type: "other"
} as any);
// 模拟上传进度
const progressInterval = setInterval(() => {
setUploadProgress(prev => {
if (prev >= 90) {
clearInterval(progressInterval);
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
});
// 保存ZIP文件到IndexedDB使用项目ID作为key
try {
await saveZipFile(project.id, file);
} catch (error) {
console.error('保存ZIP文件失败:', error);
}
clearInterval(progressInterval);
setUploadProgress(100);
@ -177,9 +174,10 @@ export default function Projects() {
resetCreateForm();
loadProjects();
// 显示终端进度窗口
setCurrentTaskId(taskId);
setShowTerminalDialog(true);
toast.success(`项目 "${project.name}" 已创建`, {
description: 'ZIP文件已保存您可以启动代码审计',
duration: 4000
});
} catch (error: any) {
console.error('Upload failed:', error);
@ -520,8 +518,8 @@ export default function Projects() {
<ul className="space-y-1 text-xs">
<li> ZIP </li>
<li> node_modules.git </li>
<li> </li>
<li> </li>
<li> ZIP </li>
<li> </li>
</ul>
</div>
</div>
@ -766,14 +764,6 @@ export default function Projects() {
preselectedProjectId={selectedProjectForTask}
/>
{/* 终端进度对话框 */}
<TerminalProgressDialog
open={showTerminalDialog}
onOpenChange={setShowTerminalDialog}
taskId={currentTaskId}
taskType="zip"
/>
{/* 编辑项目对话框 */}
<Dialog open={showEditDialog} onOpenChange={setShowEditDialog}>
<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 type { Project } from "@/shared/types";
import { toast } from "sonner";
import { deleteZipFile } from "@/shared/utils/zipStorage";
export default function RecycleBin() {
const [deletedProjects, setDeletedProjects] = useState<Project[]>([]);
@ -73,7 +74,16 @@ export default function RecycleBin() {
if (!selectedProject) return;
try {
// 删除项目数据
await api.permanentlyDeleteProject(selectedProject.id);
// 删除保存的ZIP文件如果有
try {
await deleteZipFile(selectedProject.id);
} catch (error) {
console.error('删除ZIP文件失败:', error);
}
toast.success(`项目 "${selectedProject.name}" 已永久删除`);
setShowPermanentDeleteDialog(false);
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;
}
}