更新下载git

This commit is contained in:
DESKTOP-PB0N82B\admin 2026-01-15 15:11:15 +08:00
parent fc41d96a1f
commit 90a7fbd0b0
14 changed files with 725 additions and 61 deletions

1
.gitignore vendored
View File

@ -1,2 +1,3 @@
node_modules/
.cache/
项目功能文档.md

1
.vscode/settings.json vendored Normal file
View File

@ -0,0 +1 @@
{}

View File

@ -1,9 +1,26 @@
"use strict";
const electron = require("electron");
const path = require("path");
const fs = require("fs");
const fsSync = require("fs");
const Store = require("electron-store");
const crypto = require("crypto");
function _interopNamespaceDefault(e) {
const n = Object.create(null, { [Symbol.toStringTag]: { value: "Module" } });
if (e) {
for (const k in e) {
if (k !== "default") {
const d = Object.getOwnPropertyDescriptor(e, k);
Object.defineProperty(n, k, d.get ? d : {
enumerable: true,
get: () => e[k]
});
}
}
}
n.default = e;
return Object.freeze(n);
}
const fsSync__namespace = /* @__PURE__ */ _interopNamespaceDefault(fsSync);
let store = null;
function initStore() {
if (store) {
@ -113,7 +130,7 @@ class CacheManager {
const exeDir = path.dirname(exePath);
this.cacheDir = path.join(exeDir, "cache");
}
await fs.promises.mkdir(this.cacheDir, { recursive: true });
await fsSync.promises.mkdir(this.cacheDir, { recursive: true });
await this.loadMetadata();
this.initialized = true;
console.log("CacheManager 初始化完成");
@ -161,9 +178,9 @@ class CacheManager {
async loadMetadata() {
try {
const metadataPath = this.getMetadataPath();
const exists = await fs.promises.access(metadataPath).then(() => true).catch(() => false);
const exists = await fsSync.promises.access(metadataPath).then(() => true).catch(() => false);
if (exists) {
const content = await fs.promises.readFile(metadataPath, "utf-8");
const content = await fsSync.promises.readFile(metadataPath, "utf-8");
const data = JSON.parse(content);
this.metadataMap = new Map(Object.entries(data));
console.log(`加载了 ${this.metadataMap.size} 条缓存元数据`);
@ -180,7 +197,7 @@ class CacheManager {
try {
const metadataPath = this.getMetadataPath();
const data = Object.fromEntries(this.metadataMap);
await fs.promises.writeFile(metadataPath, JSON.stringify(data, null, 2), "utf-8");
await fsSync.promises.writeFile(metadataPath, JSON.stringify(data, null, 2), "utf-8");
} catch (error) {
console.error("保存缓存元数据失败:", error);
}
@ -204,11 +221,11 @@ class CacheManager {
const filePath = this.getCacheFilePath(key, namespace);
if (namespace) {
const namespaceDir = path.join(this.cacheDir, namespace);
await fs.promises.mkdir(namespaceDir, { recursive: true });
await fsSync.promises.mkdir(namespaceDir, { recursive: true });
}
const jsonString = JSON.stringify(data, null, 2);
await fs.promises.writeFile(filePath, jsonString, "utf-8");
const stats = await fs.promises.stat(filePath);
await fsSync.promises.writeFile(filePath, jsonString, "utf-8");
const stats = await fsSync.promises.stat(filePath);
const metadata = {
key,
type: "json",
@ -243,7 +260,7 @@ class CacheManager {
}
const namespace = config == null ? void 0 : config.namespace;
const filePath = this.getCacheFilePath(key, namespace);
const content = await fs.promises.readFile(filePath, "utf-8");
const content = await fsSync.promises.readFile(filePath, "utf-8");
const data = JSON.parse(content);
console.log(`读取缓存: ${key}`);
return data;
@ -263,10 +280,10 @@ class CacheManager {
const filePath = this.getCacheFilePath(key, namespace);
if (namespace) {
const namespaceDir = path.join(this.cacheDir, namespace);
await fs.promises.mkdir(namespaceDir, { recursive: true });
await fsSync.promises.mkdir(namespaceDir, { recursive: true });
}
await fs.promises.writeFile(filePath, buffer);
const stats = await fs.promises.stat(filePath);
await fsSync.promises.writeFile(filePath, buffer);
const stats = await fsSync.promises.stat(filePath);
const metadata = {
key,
type,
@ -301,7 +318,7 @@ class CacheManager {
}
const namespace = config == null ? void 0 : config.namespace;
const filePath = this.getCacheFilePath(key, namespace);
const buffer = await fs.promises.readFile(filePath);
const buffer = await fsSync.promises.readFile(filePath);
console.log(`读取二进制缓存: ${key}`);
return buffer;
} catch (error) {
@ -323,7 +340,7 @@ class CacheManager {
}
const namespace = config == null ? void 0 : config.namespace;
const filePath = this.getCacheFilePath(key, namespace);
const exists = await fs.promises.access(filePath).then(() => true).catch(() => false);
const exists = await fsSync.promises.access(filePath).then(() => true).catch(() => false);
return exists;
} catch (error) {
console.error(`检查缓存失败 [${key}]:`, error);
@ -337,7 +354,7 @@ class CacheManager {
this.ensureInitialized();
try {
const filePath = this.getCacheFilePath(key, namespace);
await fs.promises.unlink(filePath).catch(() => {
await fsSync.promises.unlink(filePath).catch(() => {
});
this.metadataMap.delete(key);
await this.saveMetadata();
@ -353,7 +370,7 @@ class CacheManager {
this.ensureInitialized();
try {
const namespaceDir = path.join(this.cacheDir, namespace);
await fs.promises.rm(namespaceDir, { recursive: true, force: true });
await fsSync.promises.rm(namespaceDir, { recursive: true, force: true });
const keysToDelete = [];
this.metadataMap.forEach((metadata, key) => {
const filePath = this.getCacheFilePath(key, namespace);
@ -374,14 +391,14 @@ class CacheManager {
async clearAll() {
this.ensureInitialized();
try {
const files = await fs.promises.readdir(this.cacheDir, { withFileTypes: true });
const files = await fsSync.promises.readdir(this.cacheDir, { withFileTypes: true });
for (const file of files) {
const filePath = path.join(this.cacheDir, file.name);
if (file.name === "_metadata.json") continue;
if (file.isDirectory()) {
await fs.promises.rm(filePath, { recursive: true, force: true });
await fsSync.promises.rm(filePath, { recursive: true, force: true });
} else {
await fs.promises.unlink(filePath);
await fsSync.promises.unlink(filePath);
}
}
this.metadataMap.clear();
@ -435,7 +452,10 @@ const IPC_CHANNELS = {
// 窗口操作
WINDOW_MINIMIZE: "window:minimize",
WINDOW_MAXIMIZE: "window:maximize",
WINDOW_CLOSE: "window:close"
WINDOW_CLOSE: "window:close",
// Git/System 操作
DIALOG_OPEN_DIRECTORY: "dialog:openDirectory",
GIT_CLONE: "git:clone"
};
function setupIpcHandlers() {
electron.ipcMain.handle(
@ -444,7 +464,7 @@ function setupIpcHandlers() {
try {
const desktopPath = electron.app.getPath("desktop");
const filePath = path.join(desktopPath, filename);
await fs.promises.writeFile(filePath, content, "utf-8");
await fsSync.promises.writeFile(filePath, content, "utf-8");
return {
success: true,
message: `文件已保存到: ${filePath}`,
@ -468,7 +488,7 @@ function setupIpcHandlers() {
const desktopPath = electron.app.getPath("desktop");
const filePath = path.join(desktopPath, filename);
try {
await fs.promises.access(filePath);
await fsSync.promises.access(filePath);
} catch {
return {
success: false,
@ -476,7 +496,7 @@ function setupIpcHandlers() {
content: null
};
}
const content = await fs.promises.readFile(filePath, "utf-8");
const content = await fsSync.promises.readFile(filePath, "utf-8");
return {
success: true,
message: "文件读取成功",
@ -750,6 +770,91 @@ function setupIpcHandlers() {
return { success: false, message: errorMessage };
}
});
electron.ipcMain.handle(IPC_CHANNELS.DIALOG_OPEN_DIRECTORY, async (event) => {
try {
const window = electron.BrowserWindow.fromWebContents(event.sender);
if (!window) return { success: false, message: "无法找到窗口" };
const result = await electron.dialog.showOpenDialog(window, {
properties: ["openDirectory", "createDirectory"]
});
if (result.canceled) {
return { success: false, canceled: true };
}
return {
success: true,
path: result.filePaths[0],
canceled: false
};
} catch (error) {
const errorMessage = error instanceof Error ? error.message : "未知错误";
console.error("打开目录失败:", errorMessage);
return { success: false, message: errorMessage };
}
});
electron.ipcMain.handle(IPC_CHANNELS.GIT_CLONE, async (event, repoUrl, targetDir) => {
return new Promise((resolve) => {
const window = electron.BrowserWindow.fromWebContents(event.sender);
try {
if (!fsSync__namespace.existsSync(targetDir)) {
fsSync__namespace.mkdirSync(targetDir, { recursive: true });
} else {
const files = fsSync__namespace.readdirSync(targetDir);
if (files.length > 0) {
return resolve({
success: false,
message: `目标文件夹不为空。请选择一个空文件夹。`
});
}
}
} catch (err) {
return resolve({ success: false, message: `检查目录失败: ${err}` });
}
console.log(`开始 Git Clone (spawn): ${repoUrl} -> ${targetDir}`);
const { spawn } = require("child_process");
const child = spawn("git", ["clone", "--progress", repoUrl, "."], { cwd: targetDir });
let stdoutData = "";
let stderrData = "";
child.stdout.on("data", (data) => {
const text = data.toString();
stdoutData += text;
console.log("Git Stdout:", text);
});
child.stderr.on("data", (data) => {
const text = data.toString();
stderrData += text;
if (window) {
window.webContents.send("git:progress", {
repoUrl,
raw: text
});
}
});
child.on("close", (code) => {
console.log(`Git clone process exited with code ${code}`);
if (code === 0) {
resolve({
success: true,
message: "项目克隆成功",
stdout: stdoutData
});
} else {
resolve({
success: false,
message: `Git clone 失败 (Code ${code})`,
stdout: stderrData
// git 错误通常在 stderr
});
}
});
child.on("error", (err) => {
console.error("Git spawn error:", err);
resolve({
success: false,
message: `启动 Git 进程失败: ${err.message}`
});
});
});
});
}
const VITE_DEV_SERVER_URL = process.env["VITE_DEV_SERVER_URL"];
let mainWindow = null;
@ -802,7 +907,7 @@ function createWindow() {
if (VITE_DEV_SERVER_URL) {
mainWindow.loadURL(VITE_DEV_SERVER_URL);
} else {
const fs2 = require("fs");
const fs = require("fs");
const possiblePaths = [
path.join(electron.app.getAppPath(), "dist-renderer", "index.html"),
// asar 内部(主要路径)
@ -813,14 +918,14 @@ function createWindow() {
];
console.log("尝试加载页面,检查以下路径:");
possiblePaths.forEach((p) => {
const exists = fs2.existsSync(p);
const exists = fs.existsSync(p);
console.log(` ${exists ? "✓" : "✗"} ${p}`);
});
console.log("process.resourcesPath:", process.resourcesPath);
console.log("app.getAppPath():", electron.app.getAppPath());
let indexPath = null;
for (const testPath of possiblePaths) {
if (fs2.existsSync(testPath)) {
if (fs.existsSync(testPath)) {
indexPath = testPath;
console.log("找到页面文件:", indexPath);
break;

View File

@ -18,7 +18,10 @@ const IPC_CHANNELS = {
// 窗口操作
WINDOW_MINIMIZE: "window:minimize",
WINDOW_MAXIMIZE: "window:maximize",
WINDOW_CLOSE: "window:close"
WINDOW_CLOSE: "window:close",
// Git/System 操作
DIALOG_OPEN_DIRECTORY: "dialog:openDirectory",
GIT_CLONE: "git:clone"
};
const electronAPI = {
// ============================================
@ -39,6 +42,40 @@ const electronAPI = {
*/
read: (filename) => {
return electron.ipcRenderer.invoke(IPC_CHANNELS.READ_FILE, filename);
},
/**
* 打开目录选择对话框
*/
openDirectory: () => {
return electron.ipcRenderer.invoke(IPC_CHANNELS.DIALOG_OPEN_DIRECTORY);
}
},
// ============================================
// Git 操作 API
// ============================================
git: {
/**
* 克隆 Git 仓库
* @param repoUrl - 仓库地址
* @param targetDir - 目标目录
*/
clone: (repoUrl, targetDir) => {
return electron.ipcRenderer.invoke(IPC_CHANNELS.GIT_CLONE, repoUrl, targetDir);
},
/**
* 监听 Git 进度
*/
onProgress: (callback) => {
electron.ipcRenderer.removeAllListeners("git:progress");
electron.ipcRenderer.on("git:progress", (_event, data) => {
callback(data);
});
},
/**
* 移除监听器
*/
removeListener: () => {
electron.ipcRenderer.removeAllListeners("git:progress");
}
},
// ============================================

View File

@ -2,8 +2,11 @@
* IPC
*
*/
import { ipcMain, app, BrowserWindow } from 'electron'
import { ipcMain, app, BrowserWindow, dialog } from 'electron'
import { promises as fs } from 'fs'
import * as fsSync from 'fs'
import { exec } from 'child_process'
import util from 'util'
import path from 'path'
import { getSettings, saveSettings } from './store'
import { cacheManager } from './cache-manager'
@ -29,7 +32,10 @@ export const IPC_CHANNELS = {
// 窗口操作
WINDOW_MINIMIZE: 'window:minimize',
WINDOW_MAXIMIZE: 'window:maximize',
WINDOW_CLOSE: 'window:close'
WINDOW_CLOSE: 'window:close',
// Git/System 操作
DIALOG_OPEN_DIRECTORY: 'dialog:openDirectory',
GIT_CLONE: 'git:clone'
} as const
/**
@ -447,4 +453,117 @@ export function setupIpcHandlers() {
return { success: false, message: errorMessage }
}
})
// ============================================
// Git/System 操作相关 IPC
// ============================================
/**
*
*/
ipcMain.handle(IPC_CHANNELS.DIALOG_OPEN_DIRECTORY, async (event) => {
try {
const window = BrowserWindow.fromWebContents(event.sender)
if (!window) return { success: false, message: '无法找到窗口' }
const result = await dialog.showOpenDialog(window, {
properties: ['openDirectory', 'createDirectory']
})
if (result.canceled) {
return { success: false, canceled: true }
}
return {
success: true,
path: result.filePaths[0],
canceled: false
}
} catch (error) {
const errorMessage = error instanceof Error ? error.message : '未知错误'
console.error('打开目录失败:', errorMessage)
return { success: false, message: errorMessage }
}
})
/**
* Git Clone (使 spawn )
*/
ipcMain.handle(IPC_CHANNELS.GIT_CLONE, async (event, repoUrl: string, targetDir: string) => {
return new Promise((resolve) => {
const window = BrowserWindow.fromWebContents(event.sender)
// 检查目录是否存在
try {
if (!fsSync.existsSync(targetDir)) { // 使用同步检查避免 async/await 复杂性
fsSync.mkdirSync(targetDir, { recursive: true })
} else {
// 如果目录存在,必须为空
const files = fsSync.readdirSync(targetDir)
if (files.length > 0) {
return resolve({
success: false,
message: `目标文件夹不为空。请选择一个空文件夹。`
})
}
}
} catch (err) {
return resolve({ success: false, message: `检查目录失败: ${err}` })
}
console.log(`开始 Git Clone (spawn): ${repoUrl} -> ${targetDir}`)
// 这里的 spawn 命令不需要 util.promisify
const { spawn } = require('child_process')
const child = spawn('git', ['clone', '--progress', repoUrl, '.'], { cwd: targetDir })
let stdoutData = ''
let stderrData = ''
child.stdout.on('data', (data: any) => {
const text = data.toString()
stdoutData += text
// git 的标准输出通常是静默的,除非有错误或特定配置
console.log('Git Stdout:', text)
})
child.stderr.on('data', (data: any) => {
const text = data.toString()
stderrData += text
// 发送进度事件到前端
if (window) {
window.webContents.send('git:progress', {
repoUrl,
raw: text
})
}
})
child.on('close', (code: number) => {
console.log(`Git clone process exited with code ${code}`)
if (code === 0) {
resolve({
success: true,
message: '项目克隆成功',
stdout: stdoutData
})
} else {
resolve({
success: false,
message: `Git clone 失败 (Code ${code})`,
stdout: stderrData // git 错误通常在 stderr
})
}
})
child.on('error', (err: any) => {
console.error('Git spawn error:', err)
resolve({
success: false,
message: `启动 Git 进程失败: ${err.message}`
})
})
})
})
}

View File

@ -30,7 +30,10 @@ const IPC_CHANNELS = {
// 窗口操作
WINDOW_MINIMIZE: 'window:minimize',
WINDOW_MAXIMIZE: 'window:maximize',
WINDOW_CLOSE: 'window:close'
WINDOW_CLOSE: 'window:close',
// Git/System 操作
DIALOG_OPEN_DIRECTORY: 'dialog:openDirectory',
GIT_CLONE: 'git:clone'
} as const
// API 响应类型定义
@ -39,6 +42,13 @@ interface FileOperationResult {
message: string
path?: string | null
content?: string | null
canceled?: boolean
}
interface GitOperationResult {
success: boolean
message: string
stdout?: string
}
interface SettingsOperationResult {
@ -66,6 +76,13 @@ interface ElectronAPI {
file: {
save: (content: string, filename?: string) => Promise<FileOperationResult>
read: (filename?: string) => Promise<FileOperationResult>
openDirectory: () => Promise<FileOperationResult>
}
// Git 操作
git: {
clone: (repoUrl: string, targetDir: string) => Promise<GitOperationResult>
onProgress: (callback: (data: { repoUrl: string; raw: string }) => void) => void
removeListener: () => void
}
// 设置操作
settings: {
@ -113,6 +130,44 @@ const electronAPI: ElectronAPI = {
*/
read: (filename?: string) => {
return ipcRenderer.invoke(IPC_CHANNELS.READ_FILE, filename)
},
/**
*
*/
openDirectory: () => {
return ipcRenderer.invoke(IPC_CHANNELS.DIALOG_OPEN_DIRECTORY)
}
},
// ============================================
// Git 操作 API
// ============================================
git: {
/**
* Git
* @param repoUrl -
* @param targetDir -
*/
clone: (repoUrl: string, targetDir: string) => {
return ipcRenderer.invoke(IPC_CHANNELS.GIT_CLONE, repoUrl, targetDir)
},
/**
* Git
*/
onProgress: (callback: (data: { repoUrl: string; raw: string }) => void) => {
// 移除旧的监听器以防止重复 (简化处理,实际应该支持多监听器或由调用者管理)
ipcRenderer.removeAllListeners('git:progress')
ipcRenderer.on('git:progress', (_event, data) => {
callback(data)
})
},
/**
*
*/
removeListener: () => {
ipcRenderer.removeAllListeners('git:progress')
}
},

View File

@ -7,6 +7,8 @@ import ProjectLibrary from '@/pages/projects/ProjectLibrary'
import NewProject from '@/pages/projects/NewProject'
import { ToastProvider } from '@/components/ui/toast-provider'
import { DownloadManagerProvider } from '@/components/DownloadManager'
// 其他页面的占位符,以避免演示期间出现 404
const PlaceholderPage = ({ title }: { title: string }) => (
<div className="flex items-center justify-center h-full text-slate-400">
@ -20,32 +22,35 @@ const PlaceholderPage = ({ title }: { title: string }) => (
function App() {
return (
<ToastProvider>
<HashRouter>
<Routes>
<Route path="/" element={<Login />} />
<Route path="/upload" element={<UploadResource />} />
<Route path="/app/projects/new" element={<NewProject />} />
<DownloadManagerProvider>
<HashRouter>
<Routes>
<Route path="/" element={<Login />} />
<Route path="/upload" element={<UploadResource />} />
<Route path="/app/projects/new" element={<NewProject />} />
{/* Main Application Routes */}
<Route path="/app" element={<MainLayout />}>
<Route index element={<Navigate to="/app/plugins" replace />} />
<Route path="plugins" element={<AssetLibrary />} />
<Route path="components" element={<PlaceholderPage title="组件库" />} />
<Route path="tools" element={<PlaceholderPage title="工具库" />} />
<Route path="prototypes" element={<PlaceholderPage title="原型库" />} />
<Route path="design" element={<PlaceholderPage title="设计库" />} />
<Route path="projects" element={<ProjectLibrary />} />
<Route path="files" element={<PlaceholderPage title="文件库" />} />
<Route path="cases" element={<PlaceholderPage title="案例库" />} />
<Route path="pending" element={<PlaceholderPage title="待审核" />} />
</Route>
{/* Main Application Routes */}
<Route path="/app" element={<MainLayout />}>
<Route index element={<Navigate to="/app/plugins" replace />} />
<Route path="plugins" element={<AssetLibrary />} />
<Route path="components" element={<PlaceholderPage title="组件库" />} />
<Route path="tools" element={<PlaceholderPage title="工具库" />} />
<Route path="prototypes" element={<PlaceholderPage title="原型库" />} />
<Route path="design" element={<PlaceholderPage title="设计库" />} />
<Route path="projects" element={<ProjectLibrary />} />
<Route path="files" element={<PlaceholderPage title="文件库" />} />
<Route path="cases" element={<PlaceholderPage title="案例库" />} />
<Route path="pending" element={<PlaceholderPage title="待审核" />} />
</Route>
{/* Legacy/Redirects */}
<Route path="/dashboard" element={<Navigate to="/app" replace />} />
</Routes>
</HashRouter>
{/* Legacy/Redirects */}
<Route path="/dashboard" element={<Navigate to="/app" replace />} />
</Routes>
</HashRouter>
</DownloadManagerProvider>
</ToastProvider>
)
}
export default App

View File

@ -2,16 +2,16 @@ import axios from 'axios';
// 配置
// 在开发环境中使用代理路径在生产环境Electron中使用完整 URL
export const API_BASE_URL = import.meta.env.DEV
export const API_BASE_URL = import.meta.env.DEV
? '/api/v1' // 开发环境:使用 Vite 代理
: 'http://172.16.1.12/api/v1'; // 生产环境直接访问Electron 不受 CORS 限制)
// 如果需要,可以将其更改为真实令牌,或实现登录系统
export const API_TOKEN = '';
export const API_TOKEN = '';
const apiClient = axios.create({
baseURL: API_BASE_URL,
timeout: 10000,
timeout: 60000,
headers: {
'Content-Type': 'application/json',
...(API_TOKEN ? { Authorization: `token ${API_TOKEN}` } : {}),

View File

@ -74,6 +74,22 @@ export const giteaApi = {
}
},
/**
* Get Repository Archive (Download)
* @param format - archive format (zip, tar.gz, etc)
*/
getRepoArchive: async (owner: string, repo: string, ref: string = 'HEAD', format: string = 'zip'): Promise<Blob> => {
try {
const response = await apiClient.get(`/repos/${owner}/${repo}/archive/${ref}.${format}`, {
responseType: 'blob'
});
return response.data;
} catch (error) {
console.error(`Failed to download archive for ${owner}/${repo}`, error);
throw error;
}
},
/**
*
* 使 Git Tree API

View File

@ -0,0 +1,154 @@
import React, { createContext, useContext, useState, useEffect, useRef } from 'react'
import { DownloadProgress } from './DownloadProgress'
import { useToast } from '@/hooks/use-toast'
interface DownloadItem {
id: string
repoUrl: string
targetDir: string
status: 'pending' | 'cloning' | 'success' | 'error'
progress: number
phase: string
logs: string[]
error?: string
}
interface DownloadContextType {
startDownload: (repoUrl: string, targetDir: string) => Promise<void>
activeDownloads: DownloadItem[]
}
const DownloadContext = createContext<DownloadContextType | undefined>(undefined)
export const useDownloadManager = () => {
const context = useContext(DownloadContext)
if (!context) {
throw new Error('useDownloadManager must be used within a DownloadManagerProvider')
}
return context
}
export function DownloadManagerProvider({ children }: { children: React.ReactNode }) {
const [activeDownloads, setActiveDownloads] = useState<DownloadItem[]>([])
const { toast } = useToast()
// 用于在回调中访问最新状态
const activeDownloadsRef = useRef<DownloadItem[]>([])
useEffect(() => {
activeDownloadsRef.current = activeDownloads
}, [activeDownloads])
useEffect(() => {
// 监听 Git 进度
window.electronAPI.git.onProgress((data) => {
const { repoUrl, raw } = data
// 解析进度信息
// Git progress output examples:
// "Receiving objects: 12% (3/25)"
// "Resolving deltas: 100% (5/5), done."
let phase = ''
let progress = 0
if (raw.includes('Receiving objects')) {
phase = '正在下载对象'
const match = raw.match(/(\d+)%/)
if (match) progress = parseInt(match[1])
} else if (raw.includes('Resolving deltas')) {
phase = '正在处理增量'
const match = raw.match(/(\d+)%/)
if (match) progress = 50 + Math.floor(parseInt(match[1]) * 0.5) // 处理增量算后半程
} else if (raw.includes('Cloning into')) {
phase = '开始克隆...'
}
setActiveDownloads(prev => prev.map(item => {
if (item.repoUrl === repoUrl && item.status === 'cloning') {
const newLogs = [...item.logs, raw]
// 保持日志长度不过大
if (newLogs.length > 200) newLogs.shift()
return {
...item,
phase: phase || item.phase,
progress: progress > item.progress ? progress : item.progress,
logs: newLogs
}
}
return item
}))
})
return () => {
window.electronAPI.git.removeListener()
}
}, [])
const startDownload = async (repoUrl: string, targetDir: string) => {
const id = Math.random().toString(36).substring(7)
const newItem: DownloadItem = {
id,
repoUrl,
targetDir,
status: 'cloning',
progress: 0,
phase: '准备中...',
logs: []
}
setActiveDownloads(prev => [...prev, newItem])
try {
const result = await window.electronAPI.git.clone(repoUrl, targetDir)
if (result.success) {
setActiveDownloads(prev => prev.map(item =>
item.id === id ? { ...item, status: 'success', progress: 100, phase: '完成' } : item
))
// 3秒后移除成功的下载
setTimeout(() => {
setActiveDownloads(prev => prev.filter(item => item.id !== id))
toast(`下载完成: 项目已成功下载到 ${targetDir}`, { type: 'success' })
}, 3000)
} else {
setActiveDownloads(prev => prev.map(item =>
item.id === id ? {
...item,
status: 'error',
error: result.message,
phase: '失败',
// 将 Git 错误详情添加到日志中
logs: [...item.logs, `\n[错误信息]: ${result.message}\n${result.stdout ? `[详情]:\n${result.stdout}` : ''}`]
} : item
))
}
} catch (err) {
const msg = err instanceof Error ? err.message : 'Unknown error'
setActiveDownloads(prev => prev.map(item =>
item.id === id ? {
...item,
status: 'error',
error: msg,
phase: '出错',
logs: [...item.logs, `\n[系统错误]: ${msg}`]
} : item
))
}
}
return (
<DownloadContext.Provider value={{ startDownload, activeDownloads }}>
{children}
<div className="fixed bottom-6 right-6 z-50 flex flex-col gap-4 w-[380px]">
{activeDownloads.map(item => (
<DownloadProgress key={item.id} item={item} onClose={() => {
setActiveDownloads(prev => prev.filter(i => i.id !== item.id))
}} />
))}
</div>
</DownloadContext.Provider>
)
}

View File

@ -0,0 +1,110 @@
import { useState, useRef, useEffect } from 'react'
import { X, ChevronDown, ChevronUp, Terminal, CheckCircle2, AlertCircle, Loader2 } from 'lucide-react'
import { Button } from './ui/button'
import { Progress } from './ui/progress'
import { cn } from '@/lib/utils'
interface DownloadItem {
id: string
repoUrl: string
targetDir: string
status: 'pending' | 'cloning' | 'success' | 'error'
progress: number
phase: string
logs: string[]
error?: string
}
interface DownloadProgressProps {
item: DownloadItem
onClose: () => void
}
export function DownloadProgress({ item, onClose }: DownloadProgressProps) {
const [expanded, setExpanded] = useState(false)
const logsEndRef = useRef<HTMLDivElement>(null)
// Auto-scroll logs
useEffect(() => {
if (expanded && logsEndRef.current) {
logsEndRef.current.scrollIntoView({ behavior: "smooth" })
}
}, [item.logs, expanded])
const repoName = item.repoUrl.split('/').pop()?.replace('.git', '') || 'Unknown Repo'
return (
<div className={cn(
"bg-white rounded-lg shadow-xl border border-slate-200 overflow-hidden transition-all duration-300",
expanded ? "h-[320px]" : "h-[80px]"
)}>
{/* Header */}
<div className="h-[80px] p-4 flex items-center gap-4">
{/* Icon Status */}
<div className={cn(
"w-10 h-10 rounded-full flex items-center justify-center shrink-0",
item.status === 'cloning' && "bg-blue-50 text-blue-500",
item.status === 'success' && "bg-green-50 text-green-500",
item.status === 'error' && "bg-red-50 text-red-500",
)}>
{item.status === 'cloning' && <Loader2 className="w-5 h-5 animate-spin" />}
{item.status === 'success' && <CheckCircle2 className="w-5 h-5" />}
{item.status === 'error' && <AlertCircle className="w-5 h-5" />}
</div>
{/* Info */}
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between mb-1">
<h4 className="font-medium text-slate-800 truncate" title={item.targetDir}>
{repoName}
</h4>
<span className="text-xs text-slate-500 font-mono">{item.progress}%</span>
</div>
<Progress value={item.progress} className="h-1.5 mb-1.5"
indicatorClassName={cn(
item.status === 'success' && "bg-green-500",
item.status === 'error' && "bg-red-500",
item.status === 'cloning' && "bg-blue-500"
)}
/>
<div className="flex items-center justify-between text-xs text-slate-500">
<span className="truncate max-w-[180px]">{item.phase}</span>
<Button variant="ghost" size="icon" className="h-4 w-4 text-slate-400 hover:text-slate-600"
onClick={() => setExpanded(!expanded)}
>
{expanded ? <ChevronUp className="w-3 h-3" /> : <ChevronDown className="w-3 h-3" />}
</Button>
</div>
</div>
{/* Close Button */}
{(item.status === 'success' || item.status === 'error') && (
<Button variant="ghost" size="icon" className="h-6 w-6 -mr-2 text-slate-400 hover:text-slate-600"
onClick={onClose}
>
<X className="w-4 h-4" />
</Button>
)}
</div>
{/* Expanded Details (Logs) */}
<div className="bg-slate-900 h-[240px] overflow-hidden flex flex-col">
<div className="h-8 bg-slate-800 flex items-center px-3 gap-2 border-b border-slate-700">
<Terminal className="w-3.5 h-3.5 text-slate-400" />
<span className="text-xs text-slate-300">Git Output</span>
</div>
<div className="flex-1 overflow-y-auto p-3 font-mono text-xs text-slate-300 space-y-1">
{item.logs.length === 0 && <span className="text-slate-600 italic">No output yet...</span>}
{item.logs.map((log, idx) => (
<div key={idx} className="whitespace-pre-wrap break-all border-l-2 border-transparent pl-1 hover:bg-white/5 hover:border-slate-500">
{log}
</div>
))}
<div ref={logsEndRef} />
</div>
</div>
</div>
)
}

View File

@ -0,0 +1,29 @@
"use client"
import * as React from "react"
import { cn } from "@/lib/utils"
const Progress = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement> & {
value?: number | null
indicatorClassName?: string
}
>(({ className, value, indicatorClassName, ...props }, ref) => (
<div
ref={ref}
className={cn(
"relative h-2 w-full overflow-hidden rounded-full bg-slate-100 dark:bg-slate-800",
className
)}
{...props}
>
<div
className={cn("h-full w-full flex-1 bg-slate-900 transition-all dark:bg-slate-50", indicatorClassName)}
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
/>
</div>
))
Progress.displayName = "Progress"
export { Progress }

View File

@ -28,6 +28,7 @@ import { giteaApi } from '@/api/gitea'
import { GiteaRepository, GiteaFile, GiteaTreeNode, RepoTreeCache, TreeDiffResult, GiteaCommit } from '@/api/types'
import { useCache, cacheUtils } from '@/hooks/useCache'
import { useToast } from '@/hooks/use-toast'
import { useDownloadManager } from '@/components/DownloadManager'
// 格式化字节大小的辅助函数
function formatBytes(bytes: number, decimals = 2) {
@ -73,6 +74,7 @@ interface TreeItem {
export default function ProjectLibrary() {
const { success, error: showError } = useToast()
const { startDownload } = useDownloadManager()
// 使用缓存Hook获取仓库列表
const fetchRepos = useCallback(() => giteaApi.getRepositories(), [])
@ -81,7 +83,6 @@ export default function ProjectLibrary() {
const {
data: repos,
loading: repoLoading,
error: repoError,
refresh: refreshRepos
} = useCache<GiteaRepository[]>(
'project-library-repos', // 缓存键
@ -500,6 +501,28 @@ export default function ProjectLibrary() {
}
}
/**
*
*/
const handleDownloadProject = async () => {
if (!selectedRepo) return
try {
// 1. 选择保存目录
const result = await window.electronAPI.file.openDirectory()
if (result.canceled || !result.path) return
const targetPath = result.path
// 2. 委托给 DownloadManager 开始下载(支持排队和进度显示)
startDownload(selectedRepo.clone_url, targetPath)
} catch (error) {
console.error('打开下载目录失败:', error)
showError('无法启动下载')
}
}
const handleTreeSelect = (item: TreeItem) => {
setSelectedTreeId(item.id)
@ -712,7 +735,9 @@ export default function ProjectLibrary() {
<Plus className="w-4 h-4" />
</Button>
<Button variant="ghost" className="gap-2 h-9 text-slate-600 hover:text-[#0e7490] hover:bg-[#e0f2fe]">
<Button variant="ghost" className="gap-2 h-9 text-slate-600 hover:text-[#0e7490] hover:bg-[#e0f2fe]"
onClick={handleDownloadProject}
>
<ArrowDownToLine className="w-4 h-4" />
</Button>

15
src/vite-env.d.ts vendored
View File

@ -9,18 +9,25 @@ interface FileOperationResult {
message: string
path?: string | null
content?: string | null
canceled?: boolean
}
interface SettingsOperationResult {
interface GitOperationResult {
success: boolean
message?: string
data?: unknown
message: string
stdout?: string
}
interface ElectronAPI {
file: {
save: (content: string, filename?: string) => Promise<FileOperationResult>
read: (filename?: string) => Promise<FileOperationResult>
openDirectory: () => Promise<FileOperationResult>
}
git: {
clone: (repoUrl: string, targetDir: string) => Promise<GitOperationResult>
onProgress: (callback: (data: { repoUrl: string; raw: string }) => void) => void
removeListener: () => void
}
settings: {
get: (key?: string) => Promise<SettingsOperationResult>
@ -34,4 +41,4 @@ declare global {
}
}
export {}
export { }