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)