From de6d3b8c51a66c5c705e3f3d1a79cf19fa96a64a Mon Sep 17 00:00:00 2001 From: Ethanfly Date: Fri, 26 Dec 2025 11:03:04 +0800 Subject: [PATCH] Implement Git and Python management features in Electron app, including version retrieval, installation, and configuration management. Enhance site configuration to support reverse proxy settings, updating UI components accordingly for better user experience. --- electron/main.ts | 43 +++ electron/preload.ts | 23 ++ electron/services/ConfigStore.ts | 3 + electron/services/GitManager.ts | 458 +++++++++++++++++++++++ electron/services/NginxManager.ts | 91 ++++- electron/services/PythonManager.ts | 547 ++++++++++++++++++++++++++++ electron/services/ServiceManager.ts | 53 ++- package-lock.json | 65 ++-- src/App.vue | 2 + src/router/index.ts | 12 + src/views/GitManager.vue | 544 +++++++++++++++++++++++++++ src/views/PythonManager.vue | 527 +++++++++++++++++++++++++++ src/views/SitesManager.vue | 116 +++++- 13 files changed, 2399 insertions(+), 85 deletions(-) create mode 100644 electron/services/GitManager.ts create mode 100644 electron/services/PythonManager.ts create mode 100644 src/views/GitManager.vue create mode 100644 src/views/PythonManager.vue diff --git a/electron/main.ts b/electron/main.ts index 4febe1d..b4a9883 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -15,6 +15,8 @@ import { RedisManager } from "./services/RedisManager"; import { NodeManager } from "./services/NodeManager"; import { ServiceManager } from "./services/ServiceManager"; import { HostsManager } from "./services/HostsManager"; +import { GitManager } from "./services/GitManager"; +import { PythonManager } from "./services/PythonManager"; import { ConfigStore } from "./services/ConfigStore"; // 获取图标路径 @@ -116,6 +118,8 @@ const redisManager = new RedisManager(configStore); const nodeManager = new NodeManager(configStore); const serviceManager = new ServiceManager(configStore); const hostsManager = new HostsManager(); +const gitManager = new GitManager(configStore); +const pythonManager = new PythonManager(configStore); function createWindow() { const appIcon = createWindowIcon(); @@ -521,6 +525,45 @@ ipcMain.handle("hosts:remove", (_, domain: string) => hostsManager.removeHost(domain) ); +// ==================== Git 管理 ==================== +ipcMain.handle("git:getVersions", () => gitManager.getInstalledVersions()); +ipcMain.handle("git:getAvailableVersions", () => + gitManager.getAvailableVersions() +); +ipcMain.handle("git:install", (_, version: string) => + gitManager.install(version) +); +ipcMain.handle("git:uninstall", () => gitManager.uninstall()); +ipcMain.handle("git:checkSystem", () => gitManager.checkSystemGit()); +ipcMain.handle("git:getConfig", () => gitManager.getGitConfig()); +ipcMain.handle("git:setConfig", (_, name: string, email: string) => + gitManager.setGitConfig(name, email) +); + +// ==================== Python 管理 ==================== +ipcMain.handle("python:getVersions", () => pythonManager.getInstalledVersions()); +ipcMain.handle("python:getAvailableVersions", () => + pythonManager.getAvailableVersions() +); +ipcMain.handle("python:install", (_, version: string) => + pythonManager.install(version) +); +ipcMain.handle("python:uninstall", (_, version: string) => + pythonManager.uninstall(version) +); +ipcMain.handle("python:setActive", (_, version: string) => + pythonManager.setActive(version) +); +ipcMain.handle("python:checkSystem", () => pythonManager.checkSystemPython()); +ipcMain.handle("python:getPipInfo", (_, version: string) => + pythonManager.getPipInfo(version) +); +ipcMain.handle( + "python:installPackage", + (_, version: string, packageName: string) => + pythonManager.installPackage(version, packageName) +); + // ==================== 配置管理 ==================== ipcMain.handle("config:get", (_, key: string) => configStore.get(key)); ipcMain.handle("config:set", (_, key: string, value: any) => diff --git a/electron/preload.ts b/electron/preload.ts index 089e522..13883bc 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -110,6 +110,29 @@ contextBridge.exposeInMainWorld('electronAPI', { getInfo: (version: string) => ipcRenderer.invoke('node:getInfo', version) }, + // Git 管理 + git: { + getVersions: () => ipcRenderer.invoke('git:getVersions'), + getAvailableVersions: () => ipcRenderer.invoke('git:getAvailableVersions'), + install: (version: string) => ipcRenderer.invoke('git:install', version), + uninstall: () => ipcRenderer.invoke('git:uninstall'), + checkSystem: () => ipcRenderer.invoke('git:checkSystem'), + getConfig: () => ipcRenderer.invoke('git:getConfig'), + setConfig: (name: string, email: string) => ipcRenderer.invoke('git:setConfig', name, email) + }, + + // Python 管理 + python: { + getVersions: () => ipcRenderer.invoke('python:getVersions'), + getAvailableVersions: () => ipcRenderer.invoke('python:getAvailableVersions'), + install: (version: string) => ipcRenderer.invoke('python:install', version), + uninstall: (version: string) => ipcRenderer.invoke('python:uninstall', version), + setActive: (version: string) => ipcRenderer.invoke('python:setActive', version), + checkSystem: () => ipcRenderer.invoke('python:checkSystem'), + getPipInfo: (version: string) => ipcRenderer.invoke('python:getPipInfo', version), + installPackage: (version: string, packageName: string) => ipcRenderer.invoke('python:installPackage', version, packageName) + }, + // 服务管理 service: { getAll: () => ipcRenderer.invoke('service:getAll'), diff --git a/electron/services/ConfigStore.ts b/electron/services/ConfigStore.ts index b8e4747..0130997 100644 --- a/electron/services/ConfigStore.ts +++ b/electron/services/ConfigStore.ts @@ -31,6 +31,9 @@ export interface SiteConfig { isLaravel: boolean; ssl: boolean; enabled: boolean; + // 反向代理配置 + isProxy?: boolean; + proxyTarget?: string; // 代理目标地址,如 http://127.0.0.1:3000 } // 获取应用安装目录下的 data 路径 diff --git a/electron/services/GitManager.ts b/electron/services/GitManager.ts new file mode 100644 index 0000000..ae7505f --- /dev/null +++ b/electron/services/GitManager.ts @@ -0,0 +1,458 @@ +import { ConfigStore } from './ConfigStore' +import { exec } from 'child_process' +import { promisify } from 'util' +import { existsSync, writeFileSync, mkdirSync, unlinkSync, readdirSync, rmdirSync } from 'fs' +import { join } from 'path' +import https from 'https' +import http from 'http' +import { createWriteStream } from 'fs' +import { sendDownloadProgress } from '../main' + +const execAsync = promisify(exec) + +interface GitVersion { + version: string + path: string + isActive: boolean +} + +interface AvailableGitVersion { + version: string + downloadUrl: string + type: 'portable' | 'installer' +} + +export class GitManager { + private configStore: ConfigStore + + constructor(configStore: ConfigStore) { + this.configStore = configStore + } + + /** + * 获取 Git 安装路径 + */ + getGitPath(): string { + return join(this.configStore.getBasePath(), 'git') + } + + /** + * 获取已安装的 Git 版本 + */ + async getInstalledVersions(): Promise { + const versions: GitVersion[] = [] + const gitPath = this.getGitPath() + + if (!existsSync(gitPath)) { + return versions + } + + // 检查是否存在 git.exe + const gitExe = join(gitPath, 'cmd', 'git.exe') + const gitExeAlt = join(gitPath, 'bin', 'git.exe') + + if (existsSync(gitExe) || existsSync(gitExeAlt)) { + try { + const exePath = existsSync(gitExe) ? gitExe : gitExeAlt + const { stdout } = await execAsync(`"${exePath}" --version`, { + windowsHide: true, + timeout: 10000 + }) + const match = stdout.match(/git version (\d+\.\d+\.\d+)/) + if (match) { + versions.push({ + version: match[1], + path: gitPath, + isActive: true + }) + } + } catch (error: any) { + console.error('获取 Git 版本失败:', error) + } + } + + return versions + } + + /** + * 获取可用的 Git 版本列表 + */ + async getAvailableVersions(): Promise { + // Git for Windows 便携版下载地址 + // https://github.com/git-for-windows/git/releases + const versions: AvailableGitVersion[] = [ + { + version: '2.47.1', + downloadUrl: 'https://github.com/git-for-windows/git/releases/download/v2.47.1.windows.1/PortableGit-2.47.1-64-bit.7z.exe', + type: 'portable' + }, + { + version: '2.46.2', + downloadUrl: 'https://github.com/git-for-windows/git/releases/download/v2.46.2.windows.1/PortableGit-2.46.2-64-bit.7z.exe', + type: 'portable' + }, + { + version: '2.45.2', + downloadUrl: 'https://github.com/git-for-windows/git/releases/download/v2.45.2.windows.1/PortableGit-2.45.2-64-bit.7z.exe', + type: 'portable' + } + ] + + // 过滤掉已安装的版本 + const installed = await this.getInstalledVersions() + const installedVersions = installed.map(v => v.version) + + return versions.filter(v => !installedVersions.includes(v.version)) + } + + /** + * 安装 Git + */ + async install(version: string): Promise<{ success: boolean; message: string }> { + try { + const available = await this.getAvailableVersions() + const versionInfo = available.find(v => v.version === version) + + if (!versionInfo) { + return { success: false, message: `未找到 Git ${version} 版本` } + } + + const gitPath = this.getGitPath() + const tempPath = this.configStore.getTempPath() + const downloadPath = join(tempPath, `PortableGit-${version}.7z.exe`) + + // 确保目录存在 + if (!existsSync(tempPath)) { + mkdirSync(tempPath, { recursive: true }) + } + if (!existsSync(gitPath)) { + mkdirSync(gitPath, { recursive: true }) + } + + console.log(`开始下载 Git ${version} 从 ${versionInfo.downloadUrl}`) + + // 下载 Git + await this.downloadFile(versionInfo.downloadUrl, downloadPath) + + console.log('下载完成,开始解压...') + + // 解压便携版 Git(自解压 7z) + // 使用命令行运行自解压程序 + try { + await execAsync(`"${downloadPath}" -o"${gitPath}" -y`, { + windowsHide: true, + timeout: 300000 // 5分钟超时 + }) + } catch (error: any) { + // 自解压可能不返回正确的退出码,检查文件是否存在 + const gitExe = join(gitPath, 'cmd', 'git.exe') + const gitExeAlt = join(gitPath, 'bin', 'git.exe') + if (!existsSync(gitExe) && !existsSync(gitExeAlt)) { + throw new Error(`解压失败: ${error.message}`) + } + } + + console.log('解压完成') + + // 删除临时文件 + if (existsSync(downloadPath)) { + unlinkSync(downloadPath) + } + + // 添加到环境变量 + await this.addToPath() + + return { success: true, message: `Git ${version} 安装成功` } + } catch (error: any) { + console.error('Git 安装失败:', error) + return { success: false, message: `安装失败: ${error.message}` } + } + } + + /** + * 卸载 Git + */ + async uninstall(): Promise<{ success: boolean; message: string }> { + try { + const gitPath = this.getGitPath() + + if (!existsSync(gitPath)) { + return { success: false, message: 'Git 未安装' } + } + + // 从环境变量移除 + await this.removeFromPath() + + // 删除目录 + this.removeDirectory(gitPath) + + return { success: true, message: 'Git 已卸载' } + } catch (error: any) { + return { success: false, message: `卸载失败: ${error.message}` } + } + } + + /** + * 检查系统是否已安装 Git + */ + async checkSystemGit(): Promise<{ installed: boolean; version?: string; path?: string }> { + try { + const { stdout } = await execAsync('git --version', { + windowsHide: true, + timeout: 10000 + }) + const match = stdout.match(/git version (\d+\.\d+\.\d+)/) + + // 获取 git 路径 + try { + const { stdout: wherePath } = await execAsync('where git', { + windowsHide: true, + timeout: 5000 + }) + const gitExePath = wherePath.trim().split('\n')[0] + return { + installed: true, + version: match ? match[1] : 'unknown', + path: gitExePath + } + } catch { + return { + installed: true, + version: match ? match[1] : 'unknown' + } + } + } catch { + return { installed: false } + } + } + + /** + * 获取 Git 配置 + */ + async getGitConfig(): Promise<{ name?: string; email?: string }> { + try { + let name: string | undefined + let email: string | undefined + + try { + const { stdout: nameOut } = await execAsync('git config --global user.name', { + windowsHide: true, + timeout: 5000 + }) + name = nameOut.trim() + } catch {} + + try { + const { stdout: emailOut } = await execAsync('git config --global user.email', { + windowsHide: true, + timeout: 5000 + }) + email = emailOut.trim() + } catch {} + + return { name, email } + } catch { + return {} + } + } + + /** + * 设置 Git 配置 + */ + async setGitConfig(name: string, email: string): Promise<{ success: boolean; message: string }> { + try { + if (name) { + await execAsync(`git config --global user.name "${name}"`, { + windowsHide: true, + timeout: 5000 + }) + } + + if (email) { + await execAsync(`git config --global user.email "${email}"`, { + windowsHide: true, + timeout: 5000 + }) + } + + return { success: true, message: 'Git 配置已保存' } + } catch (error: any) { + return { success: false, message: `设置失败: ${error.message}` } + } + } + + // ==================== 私有方法 ==================== + + private async downloadFile(url: string, dest: string): Promise { + return new Promise((resolve, reject) => { + const file = createWriteStream(dest) + const protocol = url.startsWith('https') ? https : http + + const request = protocol.get(url, { + headers: { + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36' + } + }, (response) => { + // 处理重定向 + if (response.statusCode === 301 || response.statusCode === 302) { + const redirectUrl = response.headers.location + if (redirectUrl) { + file.close() + if (existsSync(dest)) unlinkSync(dest) + this.downloadFile(redirectUrl, dest).then(resolve).catch(reject) + return + } + } + + if (response.statusCode !== 200) { + file.close() + if (existsSync(dest)) unlinkSync(dest) + reject(new Error(`下载失败,状态码: ${response.statusCode}`)) + return + } + + const totalSize = parseInt(response.headers['content-length'] || '0', 10) + let downloadedSize = 0 + let lastProgressTime = Date.now() + + response.on('data', (chunk) => { + downloadedSize += chunk.length + const now = Date.now() + if (now - lastProgressTime > 500) { + const progress = totalSize > 0 ? Math.round((downloadedSize / totalSize) * 100) : 0 + sendDownloadProgress('git', progress, downloadedSize, totalSize) + lastProgressTime = now + } + }) + + response.pipe(file) + file.on('finish', () => { + file.close() + sendDownloadProgress('git', 100, totalSize, totalSize) + resolve() + }) + file.on('error', (err) => { + file.close() + if (existsSync(dest)) unlinkSync(dest) + reject(err) + }) + }) + + request.on('error', (err) => { + file.close() + if (existsSync(dest)) unlinkSync(dest) + reject(new Error(`网络错误: ${err.message}`)) + }) + + request.setTimeout(600000, () => { + request.destroy() + file.close() + if (existsSync(dest)) unlinkSync(dest) + reject(new Error('下载超时(10分钟)')) + }) + }) + } + + private async addToPath(): Promise { + try { + const gitPath = this.getGitPath() + const cmdPath = join(gitPath, 'cmd') + const binPath = join(gitPath, 'bin') + + const tempScriptPath = join(this.configStore.getTempPath(), 'add_git_path.ps1') + mkdirSync(this.configStore.getTempPath(), { recursive: true }) + + const psScript = ` +param([string]$CmdPath, [string]$BinPath) + +$userPath = [Environment]::GetEnvironmentVariable('PATH', 'User') +if ($userPath -eq $null) { $userPath = '' } + +$paths = $userPath -split ';' | Where-Object { $_ -ne '' -and $_.Trim() -ne '' } + +# 移除旧的 Git 路径 +$filteredPaths = @() +foreach ($p in $paths) { + $pathLower = $p.ToLower() + if (-not ($pathLower -like '*\\git\\*' -or $pathLower -like '*\\git-*')) { + $filteredPaths += $p + } +} + +# 添加新路径 +$allPaths = @($CmdPath, $BinPath) + $filteredPaths +$newPath = $allPaths -join ';' + +[Environment]::SetEnvironmentVariable('PATH', $newPath, 'User') +Write-Host "SUCCESS: Git path added" +` + + writeFileSync(tempScriptPath, psScript, 'utf-8') + + await execAsync( + `powershell -ExecutionPolicy Bypass -File "${tempScriptPath}" -CmdPath "${cmdPath}" -BinPath "${binPath}"`, + { windowsHide: true, timeout: 30000 } + ) + + if (existsSync(tempScriptPath)) { + unlinkSync(tempScriptPath) + } + } catch (error: any) { + console.error('添加 Git 到 PATH 失败:', error) + } + } + + private async removeFromPath(): Promise { + try { + const gitPath = this.getGitPath() + + const tempScriptPath = join(this.configStore.getTempPath(), 'remove_git_path.ps1') + mkdirSync(this.configStore.getTempPath(), { recursive: true }) + + const psScript = ` +param([string]$GitBasePath) + +$userPath = [Environment]::GetEnvironmentVariable('PATH', 'User') +if ($userPath -eq $null) { exit 0 } + +$gitPathLower = $GitBasePath.ToLower() +$paths = $userPath -split ';' | Where-Object { + $_ -ne '' -and -not $_.ToLower().StartsWith($gitPathLower) +} +$newPath = $paths -join ';' + +[Environment]::SetEnvironmentVariable('PATH', $newPath, 'User') +Write-Host "SUCCESS: Git path removed" +` + + writeFileSync(tempScriptPath, psScript, 'utf-8') + + await execAsync( + `powershell -ExecutionPolicy Bypass -File "${tempScriptPath}" -GitBasePath "${gitPath}"`, + { windowsHide: true, timeout: 30000 } + ) + + if (existsSync(tempScriptPath)) { + unlinkSync(tempScriptPath) + } + } catch (error: any) { + console.error('从 PATH 移除 Git 失败:', error) + } + } + + private removeDirectory(dir: string): void { + if (existsSync(dir)) { + const files = readdirSync(dir, { withFileTypes: true }) + for (const file of files) { + const fullPath = join(dir, file.name) + if (file.isDirectory()) { + this.removeDirectory(fullPath) + } else { + unlinkSync(fullPath) + } + } + rmdirSync(dir) + } + } +} + diff --git a/electron/services/NginxManager.ts b/electron/services/NginxManager.ts index d968f06..53b8c0c 100644 --- a/electron/services/NginxManager.ts +++ b/electron/services/NginxManager.ts @@ -389,9 +389,14 @@ export class NginxManager { async addSite(site: SiteConfig): Promise<{ success: boolean; message: string }> { try { // 生成配置文件 - const config = site.isLaravel - ? this.generateLaravelSiteConfig(site) - : this.generateSiteConfig(site) + let config: string + if (site.isProxy && site.proxyTarget) { + config = this.generateProxySiteConfig(site) + } else if (site.isLaravel) { + config = this.generateLaravelSiteConfig(site) + } else { + config = this.generateSiteConfig(site) + } const sitesAvailable = this.configStore.getSitesAvailablePath() const configPath = join(sitesAvailable, `${site.name}.conf`) @@ -450,9 +455,14 @@ export class NginxManager { const wasEnabled = existsSync(enabledPath) // 生成新的配置内容 - const config = site.isLaravel - ? this.generateLaravelSiteConfig(site) - : this.generateSiteConfig(site) + let config: string + if (site.isProxy && site.proxyTarget) { + config = this.generateProxySiteConfig(site) + } else if (site.isLaravel) { + config = this.generateLaravelSiteConfig(site) + } else { + config = this.generateSiteConfig(site) + } // 写入配置文件 writeFileSync(configPath, config) @@ -694,6 +704,75 @@ server { return config } + /** + * 生成反向代理站点配置 + */ + private generateProxySiteConfig(site: SiteConfig): string { + const logsPath = this.configStore.getLogsPath() + const proxyTarget = site.proxyTarget || 'http://127.0.0.1:3000' + + let config = ` +# Reverse Proxy Site +server { + listen 80; + server_name ${site.domain}; + + access_log "${logsPath.replace(/\\/g, '/')}/${site.name}-access.log"; + error_log "${logsPath.replace(/\\/g, '/')}/${site.name}-error.log"; + + location / { + proxy_pass ${proxyTarget}; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection 'upgrade'; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_cache_bypass $http_upgrade; + + # WebSocket 支持 + proxy_read_timeout 86400; + } +} +` + + if (site.ssl) { + const sslPath = join(this.configStore.getSSLPath(), site.domain) + config += ` +server { + listen 443 ssl http2; + server_name ${site.domain}; + + ssl_certificate "${sslPath.replace(/\\/g, '/')}/${site.domain}-chain.pem"; + ssl_certificate_key "${sslPath.replace(/\\/g, '/')}/${site.domain}-key.pem"; + ssl_protocols TLSv1.2 TLSv1.3; + ssl_ciphers HIGH:!aNULL:!MD5; + + access_log "${logsPath.replace(/\\/g, '/')}/${site.name}-ssl-access.log"; + error_log "${logsPath.replace(/\\/g, '/')}/${site.name}-ssl-error.log"; + + location / { + proxy_pass ${proxyTarget}; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection 'upgrade'; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_cache_bypass $http_upgrade; + + # WebSocket 支持 + proxy_read_timeout 86400; + } +} +` + } + + return config + } + private generateLaravelSiteConfig(site: SiteConfig): string { const logsPath = this.configStore.getLogsPath() // Laravel 项目 public 目录 diff --git a/electron/services/PythonManager.ts b/electron/services/PythonManager.ts new file mode 100644 index 0000000..f0e363f --- /dev/null +++ b/electron/services/PythonManager.ts @@ -0,0 +1,547 @@ +import { ConfigStore } from './ConfigStore' +import { exec } from 'child_process' +import { promisify } from 'util' +import { existsSync, writeFileSync, mkdirSync, unlinkSync, readdirSync, rmdirSync } from 'fs' +import { join } from 'path' +import https from 'https' +import http from 'http' +import { createWriteStream } from 'fs' +import { sendDownloadProgress } from '../main' + +const execAsync = promisify(exec) + +interface PythonVersion { + version: string + path: string + isActive: boolean +} + +interface AvailablePythonVersion { + version: string + downloadUrl: string + type: 'embed' | 'installer' +} + +export class PythonManager { + private configStore: ConfigStore + + constructor(configStore: ConfigStore) { + this.configStore = configStore + } + + /** + * 获取 Python 基础安装路径 + */ + getPythonBasePath(): string { + return join(this.configStore.getBasePath(), 'python') + } + + /** + * 获取指定版本的 Python 路径 + */ + getPythonPath(version: string): string { + return join(this.getPythonBasePath(), `python-${version}`) + } + + /** + * 获取已安装的 Python 版本 + */ + async getInstalledVersions(): Promise { + const versions: PythonVersion[] = [] + const pythonBasePath = this.getPythonBasePath() + const activeVersion = this.configStore.get('activePythonVersion') as string || '' + + if (!existsSync(pythonBasePath)) { + return versions + } + + const dirs = readdirSync(pythonBasePath, { withFileTypes: true }) + + for (const dir of dirs) { + if (dir.isDirectory() && dir.name.startsWith('python-')) { + const version = dir.name.replace('python-', '') + const pythonPath = join(pythonBasePath, dir.name) + const pythonExe = join(pythonPath, 'python.exe') + + if (existsSync(pythonExe)) { + versions.push({ + version, + path: pythonPath, + isActive: version === activeVersion + }) + } + } + } + + return versions.sort((a, b) => b.version.localeCompare(a.version, undefined, { numeric: true })) + } + + /** + * 获取可用的 Python 版本列表 + * 使用 Python 嵌入式版本(免安装) + */ + async getAvailableVersions(): Promise { + // Python 嵌入式版本下载地址 + // https://www.python.org/downloads/windows/ + const versions: AvailablePythonVersion[] = [ + { + version: '3.13.1', + downloadUrl: 'https://www.python.org/ftp/python/3.13.1/python-3.13.1-embed-amd64.zip', + type: 'embed' + }, + { + version: '3.12.8', + downloadUrl: 'https://www.python.org/ftp/python/3.12.8/python-3.12.8-embed-amd64.zip', + type: 'embed' + }, + { + version: '3.11.11', + downloadUrl: 'https://www.python.org/ftp/python/3.11.11/python-3.11.11-embed-amd64.zip', + type: 'embed' + }, + { + version: '3.10.16', + downloadUrl: 'https://www.python.org/ftp/python/3.10.16/python-3.10.16-embed-amd64.zip', + type: 'embed' + }, + { + version: '3.9.21', + downloadUrl: 'https://www.python.org/ftp/python/3.9.21/python-3.9.21-embed-amd64.zip', + type: 'embed' + } + ] + + // 过滤掉已安装的版本 + const installed = await this.getInstalledVersions() + const installedVersions = installed.map(v => v.version) + + return versions.filter(v => !installedVersions.includes(v.version)) + } + + /** + * 安装 Python + */ + async install(version: string): Promise<{ success: boolean; message: string }> { + try { + const available = await this.getAvailableVersions() + const versionInfo = available.find(v => v.version === version) + + if (!versionInfo) { + return { success: false, message: `未找到 Python ${version} 版本` } + } + + const pythonPath = this.getPythonPath(version) + const tempPath = this.configStore.getTempPath() + const zipPath = join(tempPath, `python-${version}.zip`) + + // 确保目录存在 + if (!existsSync(tempPath)) { + mkdirSync(tempPath, { recursive: true }) + } + if (!existsSync(pythonPath)) { + mkdirSync(pythonPath, { recursive: true }) + } + + console.log(`开始下载 Python ${version} 从 ${versionInfo.downloadUrl}`) + + // 下载 Python + await this.downloadFile(versionInfo.downloadUrl, zipPath) + + console.log('下载完成,开始解压...') + + // 解压 + await this.unzip(zipPath, pythonPath) + + console.log('解压完成') + + // 删除临时文件 + if (existsSync(zipPath)) { + unlinkSync(zipPath) + } + + // 配置 pip(嵌入式版本需要额外配置) + await this.setupPip(pythonPath, version) + + // 如果是第一个安装的版本,设为默认 + const installed = await this.getInstalledVersions() + if (installed.length === 1) { + await this.setActive(version) + } + + return { success: true, message: `Python ${version} 安装成功` } + } catch (error: any) { + console.error('Python 安装失败:', error) + return { success: false, message: `安装失败: ${error.message}` } + } + } + + /** + * 卸载 Python + */ + async uninstall(version: string): Promise<{ success: boolean; message: string }> { + try { + const pythonPath = this.getPythonPath(version) + + if (!existsSync(pythonPath)) { + return { success: false, message: `Python ${version} 未安装` } + } + + // 如果是当前活动版本,清除 + const activeVersion = this.configStore.get('activePythonVersion') + if (activeVersion === version) { + await this.removeFromPath(pythonPath) + this.configStore.set('activePythonVersion' as any, '') + } + + // 删除目录 + this.removeDirectory(pythonPath) + + return { success: true, message: `Python ${version} 已卸载` } + } catch (error: any) { + return { success: false, message: `卸载失败: ${error.message}` } + } + } + + /** + * 设置活动的 Python 版本 + */ + async setActive(version: string): Promise<{ success: boolean; message: string }> { + try { + const pythonPath = this.getPythonPath(version) + + if (!existsSync(pythonPath)) { + return { success: false, message: `Python ${version} 未安装` } + } + + const pythonExe = join(pythonPath, 'python.exe') + if (!existsSync(pythonExe)) { + return { success: false, message: `Python ${version} 安装不完整` } + } + + // 添加到环境变量 + await this.addToPath(pythonPath) + + // 更新配置 + this.configStore.set('activePythonVersion' as any, version) + + return { + success: true, + message: `Python ${version} 已设置为默认版本\n\n环境变量已更新,新开的终端窗口中将生效。` + } + } catch (error: any) { + return { success: false, message: `设置失败: ${error.message}` } + } + } + + /** + * 检查系统是否已安装 Python + */ + async checkSystemPython(): Promise<{ installed: boolean; version?: string; path?: string }> { + try { + const { stdout } = await execAsync('python --version', { + windowsHide: true, + timeout: 10000 + }) + const match = stdout.match(/Python (\d+\.\d+\.\d+)/) + + try { + const { stdout: wherePath } = await execAsync('where python', { + windowsHide: true, + timeout: 5000 + }) + const pythonExePath = wherePath.trim().split('\n')[0] + return { + installed: true, + version: match ? match[1] : 'unknown', + path: pythonExePath + } + } catch { + return { + installed: true, + version: match ? match[1] : 'unknown' + } + } + } catch { + return { installed: false } + } + } + + /** + * 获取 pip 信息 + */ + async getPipInfo(version: string): Promise<{ installed: boolean; version?: string }> { + try { + const pythonPath = this.getPythonPath(version) + const pythonExe = join(pythonPath, 'python.exe') + + const { stdout } = await execAsync(`"${pythonExe}" -m pip --version`, { + windowsHide: true, + timeout: 10000 + }) + const match = stdout.match(/pip (\d+\.\d+(?:\.\d+)?)/) + + return { + installed: true, + version: match ? match[1] : 'unknown' + } + } catch { + return { installed: false } + } + } + + /** + * 安装 pip 包 + */ + async installPackage(version: string, packageName: string): Promise<{ success: boolean; message: string }> { + try { + const pythonPath = this.getPythonPath(version) + const pythonExe = join(pythonPath, 'python.exe') + + const { stdout, stderr } = await execAsync( + `"${pythonExe}" -m pip install ${packageName}`, + { + windowsHide: true, + timeout: 300000 // 5分钟 + } + ) + + console.log('pip install output:', stdout) + if (stderr) console.log('pip install stderr:', stderr) + + return { success: true, message: `${packageName} 安装成功` } + } catch (error: any) { + return { success: false, message: `安装失败: ${error.message}` } + } + } + + // ==================== 私有方法 ==================== + + /** + * 配置 pip(嵌入式版本需要额外配置) + */ + private async setupPip(pythonPath: string, version: string): Promise { + try { + // 修改 python*._pth 文件以启用 pip + const majorMinor = version.split('.').slice(0, 2).join('') + const pthFile = join(pythonPath, `python${majorMinor}._pth`) + + if (existsSync(pthFile)) { + const { readFileSync } = require('fs') + let content = readFileSync(pthFile, 'utf-8') + // 取消注释 import site + content = content.replace(/^#import site/m, 'import site') + writeFileSync(pthFile, content) + console.log('已启用 site 模块') + } + + // 下载并安装 pip + const pythonExe = join(pythonPath, 'python.exe') + const getPipUrl = 'https://bootstrap.pypa.io/get-pip.py' + const getPipPath = join(pythonPath, 'get-pip.py') + + console.log('下载 get-pip.py...') + await this.downloadFile(getPipUrl, getPipPath) + + console.log('安装 pip...') + try { + await execAsync(`"${pythonExe}" "${getPipPath}"`, { + cwd: pythonPath, + windowsHide: true, + timeout: 300000 + }) + console.log('pip 安装成功') + } catch (e: any) { + console.log('pip 安装提示:', e.message) + // pip 可能已经安装成功,忽略某些错误 + } + + // 清理 + if (existsSync(getPipPath)) { + unlinkSync(getPipPath) + } + } catch (error: any) { + console.error('pip 配置失败:', error) + // 不抛出错误,pip 配置失败不影响 Python 使用 + } + } + + private async downloadFile(url: string, dest: string): Promise { + return new Promise((resolve, reject) => { + const file = createWriteStream(dest) + const protocol = url.startsWith('https') ? https : http + + const request = protocol.get(url, { + headers: { + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36' + } + }, (response) => { + if (response.statusCode === 301 || response.statusCode === 302) { + const redirectUrl = response.headers.location + if (redirectUrl) { + file.close() + if (existsSync(dest)) unlinkSync(dest) + this.downloadFile(redirectUrl, dest).then(resolve).catch(reject) + return + } + } + + if (response.statusCode !== 200) { + file.close() + if (existsSync(dest)) unlinkSync(dest) + reject(new Error(`下载失败,状态码: ${response.statusCode}`)) + return + } + + const totalSize = parseInt(response.headers['content-length'] || '0', 10) + let downloadedSize = 0 + let lastProgressTime = Date.now() + + response.on('data', (chunk) => { + downloadedSize += chunk.length + const now = Date.now() + if (now - lastProgressTime > 500) { + const progress = totalSize > 0 ? Math.round((downloadedSize / totalSize) * 100) : 0 + sendDownloadProgress('python', progress, downloadedSize, totalSize) + lastProgressTime = now + } + }) + + response.pipe(file) + file.on('finish', () => { + file.close() + sendDownloadProgress('python', 100, totalSize, totalSize) + resolve() + }) + file.on('error', (err) => { + file.close() + if (existsSync(dest)) unlinkSync(dest) + reject(err) + }) + }) + + request.on('error', (err) => { + file.close() + if (existsSync(dest)) unlinkSync(dest) + reject(new Error(`网络错误: ${err.message}`)) + }) + + request.setTimeout(600000, () => { + request.destroy() + file.close() + if (existsSync(dest)) unlinkSync(dest) + reject(new Error('下载超时')) + }) + }) + } + + private async unzip(zipPath: string, destPath: string): Promise { + const { createReadStream } = await import('fs') + const unzipper = await import('unzipper') + + return new Promise((resolve, reject) => { + createReadStream(zipPath) + .pipe(unzipper.Extract({ path: destPath })) + .on('close', resolve) + .on('error', reject) + }) + } + + private async addToPath(pythonPath: string): Promise { + try { + const scriptsPath = join(pythonPath, 'Scripts') + + const tempScriptPath = join(this.configStore.getTempPath(), 'add_python_path.ps1') + mkdirSync(this.configStore.getTempPath(), { recursive: true }) + + const psScript = ` +param([string]$PythonPath, [string]$ScriptsPath) + +$userPath = [Environment]::GetEnvironmentVariable('PATH', 'User') +if ($userPath -eq $null) { $userPath = '' } + +$paths = $userPath -split ';' | Where-Object { $_ -ne '' -and $_.Trim() -ne '' } + +# 移除旧的 Python 路径 +$filteredPaths = @() +foreach ($p in $paths) { + $pathLower = $p.ToLower() + if (-not ($pathLower -like '*\\python\\python-*' -or $pathLower -like '*\\python-*\\*')) { + $filteredPaths += $p + } +} + +# 添加新路径 +$allPaths = @($PythonPath, $ScriptsPath) + $filteredPaths +$newPath = $allPaths -join ';' + +[Environment]::SetEnvironmentVariable('PATH', $newPath, 'User') +Write-Host "SUCCESS: Python path added" +` + + writeFileSync(tempScriptPath, psScript, 'utf-8') + + await execAsync( + `powershell -ExecutionPolicy Bypass -File "${tempScriptPath}" -PythonPath "${pythonPath}" -ScriptsPath "${scriptsPath}"`, + { windowsHide: true, timeout: 30000 } + ) + + if (existsSync(tempScriptPath)) { + unlinkSync(tempScriptPath) + } + } catch (error: any) { + console.error('添加 Python 到 PATH 失败:', error) + } + } + + private async removeFromPath(pythonPath: string): Promise { + try { + const tempScriptPath = join(this.configStore.getTempPath(), 'remove_python_path.ps1') + mkdirSync(this.configStore.getTempPath(), { recursive: true }) + + const psScript = ` +param([string]$PythonBasePath) + +$userPath = [Environment]::GetEnvironmentVariable('PATH', 'User') +if ($userPath -eq $null) { exit 0 } + +$pythonPathLower = $PythonBasePath.ToLower() +$paths = $userPath -split ';' | Where-Object { + $_ -ne '' -and -not $_.ToLower().StartsWith($pythonPathLower) +} +$newPath = $paths -join ';' + +[Environment]::SetEnvironmentVariable('PATH', $newPath, 'User') +Write-Host "SUCCESS: Python path removed" +` + + writeFileSync(tempScriptPath, psScript, 'utf-8') + + await execAsync( + `powershell -ExecutionPolicy Bypass -File "${tempScriptPath}" -PythonBasePath "${pythonPath}"`, + { windowsHide: true, timeout: 30000 } + ) + + if (existsSync(tempScriptPath)) { + unlinkSync(tempScriptPath) + } + } catch (error: any) { + console.error('从 PATH 移除 Python 失败:', error) + } + } + + private removeDirectory(dir: string): void { + if (existsSync(dir)) { + const files = readdirSync(dir, { withFileTypes: true }) + for (const file of files) { + const fullPath = join(dir, file.name) + if (file.isDirectory()) { + this.removeDirectory(fullPath) + } else { + unlinkSync(fullPath) + } + } + rmdirSync(dir) + } + } +} + diff --git a/electron/services/ServiceManager.ts b/electron/services/ServiceManager.ts index f85b31b..826fc04 100644 --- a/electron/services/ServiceManager.ts +++ b/electron/services/ServiceManager.ts @@ -1,7 +1,7 @@ import { ConfigStore } from './ConfigStore' import { exec, spawn } from 'child_process' import { promisify } from 'util' -import { existsSync, writeFileSync, readFileSync, mkdirSync } from 'fs' +import { existsSync, writeFileSync, readFileSync, mkdirSync, readdirSync } from 'fs' import { join } from 'path' const execAsync = promisify(exec) @@ -28,7 +28,7 @@ export class ServiceManager { */ async getAllServices(): Promise { const services: ServiceStatus[] = [] - + // 检查 Nginx const nginxPath = this.configStore.getNginxPath() if (existsSync(join(nginxPath, 'nginx.exe'))) { @@ -121,7 +121,7 @@ export class ServiceManager { } writeFileSync(batPath, script) - + // 更新配置 const autoStart = this.configStore.get('autoStart') if (service === 'nginx') autoStart.nginx = true @@ -136,7 +136,7 @@ export class ServiceManager { const { unlinkSync } = await import('fs') unlinkSync(batPath) } - + // 更新配置 const autoStart = this.configStore.get('autoStart') if (service === 'nginx') autoStart.nginx = false @@ -330,7 +330,7 @@ export class ServiceManager { try { // 停止 PHP-CGI if (await this.checkProcess('php-cgi.exe')) { - await execAsync('taskkill /F /IM php-cgi.exe', { timeout: 5000 }).catch(() => {}) + await execAsync('taskkill /F /IM php-cgi.exe', { timeout: 5000 }).catch(() => { }) details.push('PHP-CGI 已停止') } @@ -340,14 +340,14 @@ export class ServiceManager { try { await execAsync(`"${join(nginxPath, 'nginx.exe')}" -s stop`, { cwd: nginxPath, timeout: 5000 }) } catch (e) { - await execAsync('taskkill /F /IM nginx.exe', { timeout: 5000 }).catch(() => {}) + await execAsync('taskkill /F /IM nginx.exe', { timeout: 5000 }).catch(() => { }) } details.push('Nginx 已停止') } // 停止 MySQL if (await this.checkProcess('mysqld.exe')) { - await execAsync('taskkill /F /IM mysqld.exe', { timeout: 5000 }).catch(() => {}) + await execAsync('taskkill /F /IM mysqld.exe', { timeout: 5000 }).catch(() => { }) details.push('MySQL 已停止') } @@ -359,10 +359,10 @@ export class ServiceManager { try { await execAsync(`"${redisCli}" shutdown`, { timeout: 5000 }) } catch (e) { - await execAsync('taskkill /F /IM redis-server.exe', { timeout: 5000 }).catch(() => {}) + await execAsync('taskkill /F /IM redis-server.exe', { timeout: 5000 }).catch(() => { }) } } else { - await execAsync('taskkill /F /IM redis-server.exe', { timeout: 5000 }).catch(() => {}) + await execAsync('taskkill /F /IM redis-server.exe', { timeout: 5000 }).catch(() => { }) } details.push('Redis 已停止') } @@ -439,7 +439,7 @@ export class ServiceManager { const parts = line.trim().split(/\s+/) const pid = parts[parts.length - 1] if (pid && /^\d+$/.test(pid)) { - await execAsync(`taskkill /F /PID ${pid}`, { windowsHide: true, timeout: 5000 }).catch(() => {}) + await execAsync(`taskkill /F /PID ${pid}`, { windowsHide: true, timeout: 5000 }).catch(() => { }) } } } catch (e) { @@ -471,7 +471,7 @@ export class ServiceManager { */ async stopAllPhpCgi(): Promise<{ success: boolean; message: string }> { try { - await execAsync('taskkill /F /IM php-cgi.exe', { timeout: 5000 }).catch(() => {}) + await execAsync('taskkill /F /IM php-cgi.exe', { timeout: 5000 }).catch(() => { }) return { success: true, message: '所有 PHP-CGI 已停止' } } catch (error: any) { return { success: true, message: 'PHP-CGI 未运行' } @@ -510,17 +510,38 @@ export class ServiceManager { /** * 获取所有 PHP-CGI 状态 + * 只返回实际安装的 PHP 版本(php-cgi.exe 存在的版本) */ async getPhpCgiStatus(): Promise<{ version: string; port: number; running: boolean }[]> { - const phpVersions = this.configStore.get('phpVersions') const status: { version: string; port: number; running: boolean }[] = [] + const phpDir = join(this.configStore.getBasePath(), 'php') - for (const version of phpVersions) { - const port = this.getPhpCgiPort(version) - const running = await this.checkPort(port) - status.push({ version, port, running }) + // 检查 PHP 目录是否存在 + if (!existsSync(phpDir)) { + return status } + // 扫描实际安装的 PHP 版本 + const dirs = readdirSync(phpDir, { withFileTypes: true }) + + for (const dir of dirs) { + if (dir.isDirectory() && dir.name.startsWith('php-')) { + const version = dir.name.replace('php-', '') + const phpPath = join(phpDir, dir.name) + const phpCgiExe = join(phpPath, 'php-cgi.exe') + + // 只有当 php-cgi.exe 存在时才添加到列表 + if (existsSync(phpCgiExe)) { + const port = this.getPhpCgiPort(version) + const running = await this.checkPort(port) + status.push({ version, port, running }) + } + } + } + + // 按版本号排序(降序) + status.sort((a, b) => b.version.localeCompare(a.version, undefined, { numeric: true })) + return status } diff --git a/package-lock.json b/package-lock.json index c09156c..4956f65 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1796,6 +1796,7 @@ "resolved": "https://registry.npmjs.org/@types/lodash-es/-/lodash-es-4.17.12.tgz", "integrity": "sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ==", "license": "MIT", + "peer": true, "dependencies": { "@types/lodash": "*" } @@ -2129,6 +2130,7 @@ "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -2320,7 +2322,6 @@ "integrity": "sha512-+25nxyyznAXF7Nef3y0EbBeqmGZgeN/BxHX29Rs39djAfaFalmQ89SE6CWyDCHzGL0yt/ycBtNOmGTW0FyGWNw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "archiver-utils": "^2.1.0", "async": "^3.2.4", @@ -2340,7 +2341,6 @@ "integrity": "sha512-bEL/yUb/fNNiNTuUz979Z0Yg5L+LzLxGJz8x79lYmR54fmTIb6ob/hNQgkQnIUDWIFjZVQwl9Xs356I6BAMHfw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "glob": "^7.1.4", "graceful-fs": "^4.2.0", @@ -2363,7 +2363,6 @@ "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.3", @@ -2379,8 +2378,7 @@ "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/archiver-utils/node_modules/string_decoder": { "version": "1.1.1", @@ -2388,7 +2386,6 @@ "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "safe-buffer": "~5.1.0" } @@ -2515,7 +2512,6 @@ "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "buffer": "^5.5.0", "inherits": "^2.0.4", @@ -2925,7 +2921,6 @@ "integrity": "sha512-D3uMHtGc/fcO1Gt1/L7i1e33VOvD4A9hfQLP+6ewd+BvG/gQ84Yh4oftEhAdjSMgBgwGL+jsppT7JYNpo6MHHg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "buffer-crc32": "^0.2.13", "crc32-stream": "^4.0.2", @@ -3117,7 +3112,6 @@ "integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "crc32": "bin/crc32.njs" }, @@ -3131,7 +3125,6 @@ "integrity": "sha512-NT7w2JVU7DFroFdYkeq8cywxrgjPHWkdX1wjpRQXPX5Asews3tA+Ght6lddQO5Mkumffp3X7GEqku3epj2toIw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "crc-32": "^1.2.0", "readable-stream": "^3.4.0" @@ -3373,6 +3366,7 @@ "integrity": "sha512-rcJUkMfnJpfCboZoOOPf4L29TRtEieHNOeAbYPWPxlaBw/Z1RKrRA86dOI9rwaI4tQSc/RD82zTNHprfUHXsoQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "app-builder-lib": "24.13.3", "builder-util": "24.13.1", @@ -3610,7 +3604,6 @@ "integrity": "sha512-oHkV0iogWfyK+ah9ZIvMDpei1m9ZRpdXcvde1wTpra2U8AFDNNpqJdnin5z+PM1GbQ5BoaKCWas2HSjtR0HwMg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "app-builder-lib": "24.13.3", "archiver": "^5.3.1", @@ -3624,7 +3617,6 @@ "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", @@ -3640,7 +3632,6 @@ "integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "universalify": "^2.0.0" }, @@ -3654,7 +3645,6 @@ "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">= 10.0.0" } @@ -4136,8 +4126,7 @@ "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/fs-extra": { "version": "8.1.0", @@ -4855,7 +4844,6 @@ "integrity": "sha512-b94GiNHQNy6JNTrt5w6zNyffMrNkXZb3KTkCZJb2V1xaEGCk093vkZ2jk3tpaeP33/OiXC+WvK9AxUebnf5nbw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "readable-stream": "^2.0.5" }, @@ -4869,7 +4857,6 @@ "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.3", @@ -4885,8 +4872,7 @@ "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/lazystream/node_modules/string_decoder": { "version": "1.1.1", @@ -4894,7 +4880,6 @@ "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "safe-buffer": "~5.1.0" } @@ -4916,13 +4901,15 @@ "version": "4.17.21", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/lodash-es": { "version": "4.17.22", "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.22.tgz", "integrity": "sha512-XEawp1t0gxSi9x01glktRZ5HDy0HXqrM0x5pXQM98EaI0NxO6jVM7omDOxsuEo5UIASAnm2bRp1Jt/e0a2XU8Q==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/lodash-unified": { "version": "1.0.3", @@ -4940,40 +4927,35 @@ "resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz", "integrity": "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/lodash.difference": { "version": "4.5.0", "resolved": "https://registry.npmjs.org/lodash.difference/-/lodash.difference-4.5.0.tgz", "integrity": "sha512-dS2j+W26TQ7taQBGN8Lbbq04ssV3emRw4NY58WErlTO29pIqS0HmoT5aJ9+TUQ1N3G+JOZSji4eugsWwGp9yPA==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/lodash.flatten": { "version": "4.4.0", "resolved": "https://registry.npmjs.org/lodash.flatten/-/lodash.flatten-4.4.0.tgz", "integrity": "sha512-C5N2Z3DgnnKr0LOpv/hKCgKdb7ZZwafIrsesve6lmzvZIRZRGaZ/l6Q8+2W7NaT+ZwO3fFlSCzCzrDCFdJfZ4g==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/lodash.isplainobject": { "version": "4.0.6", "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/lodash.union": { "version": "4.6.0", "resolved": "https://registry.npmjs.org/lodash.union/-/lodash.union-4.6.0.tgz", "integrity": "sha512-c4pB2CdGrGdjMKYLA+XiRDO7Y0PRQbm/Gzg8qMj+QH+pFVAoTp5sBpO0odL3FjoPCGjK96p6qsP+yQoiLoOBcw==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/lowercase-keys": { "version": "2.0.0", @@ -5239,7 +5221,6 @@ "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -5619,7 +5600,6 @@ "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", @@ -5635,7 +5615,6 @@ "integrity": "sha512-v05I2k7xN8zXvPD9N+z/uhXPaj0sUFCe2rcWZIpBsqxfP7xXFQ0tipAd/wjj1YxWyWtUS5IDJpOG82JKt2EAVA==", "dev": true, "license": "Apache-2.0", - "peer": true, "dependencies": { "minimatch": "^5.1.0" } @@ -5792,8 +5771,7 @@ "url": "https://feross.org/support" } ], - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/safer-buffer": { "version": "2.1.2", @@ -5818,6 +5796,7 @@ "integrity": "sha512-uf6HoO8fy6ClsrShvMgaKUn14f2EHQLQRtpsZZLeU/Mv0Q1K5P0+x2uvH6Cub39TVVbWNSrraUhDAoFph6vh0A==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "chokidar": "^4.0.0", "immutable": "^5.0.2", @@ -6052,7 +6031,6 @@ "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "safe-buffer": "~5.2.0" } @@ -6173,7 +6151,6 @@ "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "bl": "^4.0.3", "end-of-stream": "^1.4.1", @@ -6313,6 +6290,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "devOptional": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -6431,6 +6409,7 @@ "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.21.3", "postcss": "^8.4.43", @@ -6509,13 +6488,15 @@ "resolved": "https://registry.npmjs.org/vite-plugin-electron-renderer/-/vite-plugin-electron-renderer-0.14.6.tgz", "integrity": "sha512-oqkWFa7kQIkvHXG7+Mnl1RTroA4sP0yesKatmAy0gjZC4VwUqlvF9IvOpHd1fpLWsqYX/eZlVxlhULNtaQ78Jw==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/vue": { "version": "3.5.26", "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.26.tgz", "integrity": "sha512-SJ/NTccVyAoNUJmkM9KUqPcYlY+u8OVL1X5EW9RIs3ch5H2uERxyyIUI4MRxVCSOiEcupX9xNGde1tL9ZKpimA==", "license": "MIT", + "peer": true, "dependencies": { "@vue/compiler-dom": "3.5.26", "@vue/compiler-sfc": "3.5.26", @@ -6770,7 +6751,6 @@ "integrity": "sha512-9qv4rlDiopXg4E69k+vMHjNN63YFMe9sZMrdlvKnCjlCRWeCBswPPMPUfx+ipsAWq1LXHe70RcbaHdJJpS6hyQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "archiver-utils": "^3.0.4", "compress-commons": "^4.1.2", @@ -6786,7 +6766,6 @@ "integrity": "sha512-KVgf4XQVrTjhyWmx6cte4RxonPLR9onExufI1jhvw/MQ4BB6IsZD5gT8Lq+u/+pRkWna/6JoHpiQioaqFP5Rzw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "glob": "^7.2.3", "graceful-fs": "^4.2.0", diff --git a/src/App.vue b/src/App.vue index cd706c3..1eec99d 100644 --- a/src/App.vue +++ b/src/App.vue @@ -97,6 +97,8 @@ const menuItems = [ { path: '/nginx', label: 'Nginx 管理', icon: 'Connection', service: 'nginx' }, { path: '/redis', label: 'Redis 管理', icon: 'Grid', service: 'redis' }, { path: '/nodejs', label: 'Node.js 管理', icon: 'Promotion', service: null }, + { path: '/python', label: 'Python 管理', icon: 'Platform', service: null }, + { path: '/git', label: 'Git 管理', icon: 'Share', service: null }, { path: '/sites', label: '站点管理', icon: 'Monitor', service: null }, { path: '/hosts', label: 'Hosts 管理', icon: 'Document', service: null }, { path: '/settings', label: '设置', icon: 'Setting', service: null } diff --git a/src/router/index.ts b/src/router/index.ts index 1e62ff0..0b31765 100644 --- a/src/router/index.ts +++ b/src/router/index.ts @@ -51,6 +51,18 @@ const router = createRouter({ component: () => import('@/views/HostsManager.vue'), meta: { title: 'Hosts 管理' } }, + { + path: '/git', + name: 'git', + component: () => import('@/views/GitManager.vue'), + meta: { title: 'Git 管理' } + }, + { + path: '/python', + name: 'python', + component: () => import('@/views/PythonManager.vue'), + meta: { title: 'Python 管理' } + }, { path: '/settings', name: 'settings', diff --git a/src/views/GitManager.vue b/src/views/GitManager.vue new file mode 100644 index 0000000..19eb543 --- /dev/null +++ b/src/views/GitManager.vue @@ -0,0 +1,544 @@ + + + + + + diff --git a/src/views/PythonManager.vue b/src/views/PythonManager.vue new file mode 100644 index 0000000..ea18d5a --- /dev/null +++ b/src/views/PythonManager.vue @@ -0,0 +1,527 @@ + + + + + + diff --git a/src/views/SitesManager.vue b/src/views/SitesManager.vue index ea3a7f1..274416c 100644 --- a/src/views/SitesManager.vue +++ b/src/views/SitesManager.vue @@ -56,16 +56,21 @@
- + + + {{ site.proxyTarget }} + + {{ site.rootPath }} - + PHP {{ site.phpVersion }} (端口 {{ getPhpCgiPort(site.phpVersion) }})
+ 反向代理 Laravel SSL @@ -126,7 +131,7 @@ 可选,默认使用域名 - +
@@ -134,7 +139,7 @@
- + 每个 PHP 版本使用独立端口的 FastCGI 进程 - + + + 开启后将作为反向代理服务器(用于 Node.js、Go 等应用) + + + + 后端服务地址,支持 WebSocket + + 开启后将自动配置 Laravel 伪静态规则 @@ -208,7 +221,15 @@ 站点名称不可修改 - + + + 开启后将作为反向代理服务器 + + + + 后端服务地址,支持 WebSocket + +
@@ -216,7 +237,7 @@
- + 修改后需重新加载 Nginx 配置 - + 开启后将自动配置 Laravel 伪静态规则 @@ -326,6 +347,8 @@ interface SiteConfig { isLaravel: boolean ssl: boolean enabled: boolean + isProxy?: boolean + proxyTarget?: string } const loading = ref(false) @@ -353,7 +376,9 @@ const siteForm = reactive({ phpVersion: '', isLaravel: false, ssl: false, - enabled: true + enabled: true, + isProxy: false, + proxyTarget: '' }) const showSSLDialogVisible = ref(false) @@ -374,7 +399,9 @@ const editForm = reactive({ phpVersion: '', isLaravel: false, ssl: false, - enabled: true + enabled: true, + isProxy: false, + proxyTarget: '' }) // 创建 Laravel 项目 @@ -423,6 +450,31 @@ const selectDirectory = async () => { } } +// 反向代理开关变化 +const onProxyChange = (value: boolean) => { + if (value) { + // 开启反向代理时,清空不需要的字段 + siteForm.rootPath = '' + siteForm.phpVersion = '' + siteForm.isLaravel = false + // 设置默认代理目标 + if (!siteForm.proxyTarget) { + siteForm.proxyTarget = 'http://127.0.0.1:3000' + } + } +} + +const onEditProxyChange = (value: boolean) => { + if (value) { + editForm.rootPath = '' + editForm.phpVersion = '' + editForm.isLaravel = false + if (!editForm.proxyTarget) { + editForm.proxyTarget = 'http://127.0.0.1:3000' + } + } +} + // 自动填充站点名称(当域名输入完成后) const autoFillName = () => { if (!siteForm.name && siteForm.domain) { @@ -437,9 +489,17 @@ const addSite = async () => { siteForm.name = siteForm.domain.replace(/\.(test|local|dev|localhost)$/i, '') } - if (!siteForm.domain || !siteForm.rootPath || !siteForm.phpVersion) { - ElMessage.warning('请填写所有必填字段(域名、根目录、PHP版本)') - return + // 根据站点类型验证必填字段 + if (siteForm.isProxy) { + if (!siteForm.domain || !siteForm.proxyTarget) { + ElMessage.warning('请填写所有必填字段(域名、代理目标)') + return + } + } else { + if (!siteForm.domain || !siteForm.rootPath || !siteForm.phpVersion) { + ElMessage.warning('请填写所有必填字段(域名、根目录、PHP版本)') + return + } } // 最终确保有站点名称 @@ -457,7 +517,9 @@ const addSite = async () => { phpVersion: siteForm.phpVersion, isLaravel: siteForm.isLaravel, ssl: siteForm.ssl, - enabled: siteForm.enabled + enabled: siteForm.enabled, + isProxy: siteForm.isProxy, + proxyTarget: siteForm.proxyTarget } const result = await window.electronAPI?.nginx.addSite(siteData) if (result?.success) { @@ -477,7 +539,9 @@ const addSite = async () => { phpVersion: phpVersions.value[0]?.version || '', isLaravel: false, ssl: false, - enabled: true + enabled: true, + isProxy: false, + proxyTarget: '' }) // 重新加载 Nginx 配置 @@ -568,7 +632,9 @@ const showEditDialog = (site: SiteConfig) => { phpVersion: site.phpVersion, isLaravel: site.isLaravel, ssl: site.ssl, - enabled: site.enabled + enabled: site.enabled, + isProxy: site.isProxy || false, + proxyTarget: site.proxyTarget || '' }) showEditSiteDialog.value = true } @@ -587,9 +653,17 @@ const selectEditDirectory = async () => { // 更新站点 const updateSite = async () => { - if (!editForm.domain || !editForm.rootPath || !editForm.phpVersion) { - ElMessage.warning('请填写所有必填字段') - return + // 根据站点类型验证必填字段 + if (editForm.isProxy) { + if (!editForm.domain || !editForm.proxyTarget) { + ElMessage.warning('请填写所有必填字段(域名、代理目标)') + return + } + } else { + if (!editForm.domain || !editForm.rootPath || !editForm.phpVersion) { + ElMessage.warning('请填写所有必填字段') + return + } } updating.value = true @@ -602,7 +676,9 @@ const updateSite = async () => { phpVersion: editForm.phpVersion, isLaravel: editForm.isLaravel, ssl: editForm.ssl, - enabled: editForm.enabled + enabled: editForm.enabled, + isProxy: editForm.isProxy, + proxyTarget: editForm.proxyTarget } const result = await window.electronAPI?.nginx.updateSite(editingOriginalName.value, siteData)