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.

This commit is contained in:
Ethanfly 2025-12-26 11:03:04 +08:00
parent a91146c4e9
commit de6d3b8c51
13 changed files with 2399 additions and 85 deletions

View File

@ -15,6 +15,8 @@ import { RedisManager } from "./services/RedisManager";
import { NodeManager } from "./services/NodeManager"; import { NodeManager } from "./services/NodeManager";
import { ServiceManager } from "./services/ServiceManager"; import { ServiceManager } from "./services/ServiceManager";
import { HostsManager } from "./services/HostsManager"; import { HostsManager } from "./services/HostsManager";
import { GitManager } from "./services/GitManager";
import { PythonManager } from "./services/PythonManager";
import { ConfigStore } from "./services/ConfigStore"; import { ConfigStore } from "./services/ConfigStore";
// 获取图标路径 // 获取图标路径
@ -116,6 +118,8 @@ const redisManager = new RedisManager(configStore);
const nodeManager = new NodeManager(configStore); const nodeManager = new NodeManager(configStore);
const serviceManager = new ServiceManager(configStore); const serviceManager = new ServiceManager(configStore);
const hostsManager = new HostsManager(); const hostsManager = new HostsManager();
const gitManager = new GitManager(configStore);
const pythonManager = new PythonManager(configStore);
function createWindow() { function createWindow() {
const appIcon = createWindowIcon(); const appIcon = createWindowIcon();
@ -521,6 +525,45 @@ ipcMain.handle("hosts:remove", (_, domain: string) =>
hostsManager.removeHost(domain) 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:get", (_, key: string) => configStore.get(key));
ipcMain.handle("config:set", (_, key: string, value: any) => ipcMain.handle("config:set", (_, key: string, value: any) =>

View File

@ -110,6 +110,29 @@ contextBridge.exposeInMainWorld('electronAPI', {
getInfo: (version: string) => ipcRenderer.invoke('node:getInfo', version) 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: { service: {
getAll: () => ipcRenderer.invoke('service:getAll'), getAll: () => ipcRenderer.invoke('service:getAll'),

View File

@ -31,6 +31,9 @@ export interface SiteConfig {
isLaravel: boolean; isLaravel: boolean;
ssl: boolean; ssl: boolean;
enabled: boolean; enabled: boolean;
// 反向代理配置
isProxy?: boolean;
proxyTarget?: string; // 代理目标地址,如 http://127.0.0.1:3000
} }
// 获取应用安装目录下的 data 路径 // 获取应用安装目录下的 data 路径

View File

@ -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<GitVersion[]> {
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<AvailableGitVersion[]> {
// 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<void> {
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<void> {
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<void> {
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)
}
}
}

View File

@ -389,9 +389,14 @@ export class NginxManager {
async addSite(site: SiteConfig): Promise<{ success: boolean; message: string }> { async addSite(site: SiteConfig): Promise<{ success: boolean; message: string }> {
try { try {
// 生成配置文件 // 生成配置文件
const config = site.isLaravel let config: string
? this.generateLaravelSiteConfig(site) if (site.isProxy && site.proxyTarget) {
: this.generateSiteConfig(site) config = this.generateProxySiteConfig(site)
} else if (site.isLaravel) {
config = this.generateLaravelSiteConfig(site)
} else {
config = this.generateSiteConfig(site)
}
const sitesAvailable = this.configStore.getSitesAvailablePath() const sitesAvailable = this.configStore.getSitesAvailablePath()
const configPath = join(sitesAvailable, `${site.name}.conf`) const configPath = join(sitesAvailable, `${site.name}.conf`)
@ -450,9 +455,14 @@ export class NginxManager {
const wasEnabled = existsSync(enabledPath) const wasEnabled = existsSync(enabledPath)
// 生成新的配置内容 // 生成新的配置内容
const config = site.isLaravel let config: string
? this.generateLaravelSiteConfig(site) if (site.isProxy && site.proxyTarget) {
: this.generateSiteConfig(site) config = this.generateProxySiteConfig(site)
} else if (site.isLaravel) {
config = this.generateLaravelSiteConfig(site)
} else {
config = this.generateSiteConfig(site)
}
// 写入配置文件 // 写入配置文件
writeFileSync(configPath, config) writeFileSync(configPath, config)
@ -694,6 +704,75 @@ server {
return config 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 { private generateLaravelSiteConfig(site: SiteConfig): string {
const logsPath = this.configStore.getLogsPath() const logsPath = this.configStore.getLogsPath()
// Laravel 项目 public 目录 // Laravel 项目 public 目录

View File

@ -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<PythonVersion[]> {
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<AvailablePythonVersion[]> {
// 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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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)
}
}
}

View File

@ -1,7 +1,7 @@
import { ConfigStore } from './ConfigStore' import { ConfigStore } from './ConfigStore'
import { exec, spawn } from 'child_process' import { exec, spawn } from 'child_process'
import { promisify } from 'util' import { promisify } from 'util'
import { existsSync, writeFileSync, readFileSync, mkdirSync } from 'fs' import { existsSync, writeFileSync, readFileSync, mkdirSync, readdirSync } from 'fs'
import { join } from 'path' import { join } from 'path'
const execAsync = promisify(exec) const execAsync = promisify(exec)
@ -330,7 +330,7 @@ export class ServiceManager {
try { try {
// 停止 PHP-CGI // 停止 PHP-CGI
if (await this.checkProcess('php-cgi.exe')) { 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 已停止') details.push('PHP-CGI 已停止')
} }
@ -340,14 +340,14 @@ export class ServiceManager {
try { try {
await execAsync(`"${join(nginxPath, 'nginx.exe')}" -s stop`, { cwd: nginxPath, timeout: 5000 }) await execAsync(`"${join(nginxPath, 'nginx.exe')}" -s stop`, { cwd: nginxPath, timeout: 5000 })
} catch (e) { } 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 已停止') details.push('Nginx 已停止')
} }
// 停止 MySQL // 停止 MySQL
if (await this.checkProcess('mysqld.exe')) { 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 已停止') details.push('MySQL 已停止')
} }
@ -359,10 +359,10 @@ export class ServiceManager {
try { try {
await execAsync(`"${redisCli}" shutdown`, { timeout: 5000 }) await execAsync(`"${redisCli}" shutdown`, { timeout: 5000 })
} catch (e) { } 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 { } 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 已停止') details.push('Redis 已停止')
} }
@ -439,7 +439,7 @@ export class ServiceManager {
const parts = line.trim().split(/\s+/) const parts = line.trim().split(/\s+/)
const pid = parts[parts.length - 1] const pid = parts[parts.length - 1]
if (pid && /^\d+$/.test(pid)) { 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) { } catch (e) {
@ -471,7 +471,7 @@ export class ServiceManager {
*/ */
async stopAllPhpCgi(): Promise<{ success: boolean; message: string }> { async stopAllPhpCgi(): Promise<{ success: boolean; message: string }> {
try { 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 已停止' } return { success: true, message: '所有 PHP-CGI 已停止' }
} catch (error: any) { } catch (error: any) {
return { success: true, message: 'PHP-CGI 未运行' } return { success: true, message: 'PHP-CGI 未运行' }
@ -510,16 +510,37 @@ export class ServiceManager {
/** /**
* PHP-CGI * PHP-CGI
* PHP php-cgi.exe
*/ */
async getPhpCgiStatus(): Promise<{ version: string; port: number; running: boolean }[]> { async getPhpCgiStatus(): Promise<{ version: string; port: number; running: boolean }[]> {
const phpVersions = this.configStore.get('phpVersions')
const status: { version: string; port: number; running: boolean }[] = [] const status: { version: string; port: number; running: boolean }[] = []
const phpDir = join(this.configStore.getBasePath(), 'php')
for (const version of phpVersions) { // 检查 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 port = this.getPhpCgiPort(version)
const running = await this.checkPort(port) const running = await this.checkPort(port)
status.push({ version, port, running }) status.push({ version, port, running })
} }
}
}
// 按版本号排序(降序)
status.sort((a, b) => b.version.localeCompare(a.version, undefined, { numeric: true }))
return status return status
} }

65
package-lock.json generated
View File

@ -1796,6 +1796,7 @@
"resolved": "https://registry.npmjs.org/@types/lodash-es/-/lodash-es-4.17.12.tgz", "resolved": "https://registry.npmjs.org/@types/lodash-es/-/lodash-es-4.17.12.tgz",
"integrity": "sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ==", "integrity": "sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@types/lodash": "*" "@types/lodash": "*"
} }
@ -2129,6 +2130,7 @@
"integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"fast-deep-equal": "^3.1.1", "fast-deep-equal": "^3.1.1",
"fast-json-stable-stringify": "^2.0.0", "fast-json-stable-stringify": "^2.0.0",
@ -2320,7 +2322,6 @@
"integrity": "sha512-+25nxyyznAXF7Nef3y0EbBeqmGZgeN/BxHX29Rs39djAfaFalmQ89SE6CWyDCHzGL0yt/ycBtNOmGTW0FyGWNw==", "integrity": "sha512-+25nxyyznAXF7Nef3y0EbBeqmGZgeN/BxHX29Rs39djAfaFalmQ89SE6CWyDCHzGL0yt/ycBtNOmGTW0FyGWNw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"archiver-utils": "^2.1.0", "archiver-utils": "^2.1.0",
"async": "^3.2.4", "async": "^3.2.4",
@ -2340,7 +2341,6 @@
"integrity": "sha512-bEL/yUb/fNNiNTuUz979Z0Yg5L+LzLxGJz8x79lYmR54fmTIb6ob/hNQgkQnIUDWIFjZVQwl9Xs356I6BAMHfw==", "integrity": "sha512-bEL/yUb/fNNiNTuUz979Z0Yg5L+LzLxGJz8x79lYmR54fmTIb6ob/hNQgkQnIUDWIFjZVQwl9Xs356I6BAMHfw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"glob": "^7.1.4", "glob": "^7.1.4",
"graceful-fs": "^4.2.0", "graceful-fs": "^4.2.0",
@ -2363,7 +2363,6 @@
"integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"core-util-is": "~1.0.0", "core-util-is": "~1.0.0",
"inherits": "~2.0.3", "inherits": "~2.0.3",
@ -2379,8 +2378,7 @@
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT"
"peer": true
}, },
"node_modules/archiver-utils/node_modules/string_decoder": { "node_modules/archiver-utils/node_modules/string_decoder": {
"version": "1.1.1", "version": "1.1.1",
@ -2388,7 +2386,6 @@
"integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"safe-buffer": "~5.1.0" "safe-buffer": "~5.1.0"
} }
@ -2515,7 +2512,6 @@
"integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"buffer": "^5.5.0", "buffer": "^5.5.0",
"inherits": "^2.0.4", "inherits": "^2.0.4",
@ -2925,7 +2921,6 @@
"integrity": "sha512-D3uMHtGc/fcO1Gt1/L7i1e33VOvD4A9hfQLP+6ewd+BvG/gQ84Yh4oftEhAdjSMgBgwGL+jsppT7JYNpo6MHHg==", "integrity": "sha512-D3uMHtGc/fcO1Gt1/L7i1e33VOvD4A9hfQLP+6ewd+BvG/gQ84Yh4oftEhAdjSMgBgwGL+jsppT7JYNpo6MHHg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"buffer-crc32": "^0.2.13", "buffer-crc32": "^0.2.13",
"crc32-stream": "^4.0.2", "crc32-stream": "^4.0.2",
@ -3117,7 +3112,6 @@
"integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==", "integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==",
"dev": true, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"peer": true,
"bin": { "bin": {
"crc32": "bin/crc32.njs" "crc32": "bin/crc32.njs"
}, },
@ -3131,7 +3125,6 @@
"integrity": "sha512-NT7w2JVU7DFroFdYkeq8cywxrgjPHWkdX1wjpRQXPX5Asews3tA+Ght6lddQO5Mkumffp3X7GEqku3epj2toIw==", "integrity": "sha512-NT7w2JVU7DFroFdYkeq8cywxrgjPHWkdX1wjpRQXPX5Asews3tA+Ght6lddQO5Mkumffp3X7GEqku3epj2toIw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"crc-32": "^1.2.0", "crc-32": "^1.2.0",
"readable-stream": "^3.4.0" "readable-stream": "^3.4.0"
@ -3373,6 +3366,7 @@
"integrity": "sha512-rcJUkMfnJpfCboZoOOPf4L29TRtEieHNOeAbYPWPxlaBw/Z1RKrRA86dOI9rwaI4tQSc/RD82zTNHprfUHXsoQ==", "integrity": "sha512-rcJUkMfnJpfCboZoOOPf4L29TRtEieHNOeAbYPWPxlaBw/Z1RKrRA86dOI9rwaI4tQSc/RD82zTNHprfUHXsoQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"app-builder-lib": "24.13.3", "app-builder-lib": "24.13.3",
"builder-util": "24.13.1", "builder-util": "24.13.1",
@ -3610,7 +3604,6 @@
"integrity": "sha512-oHkV0iogWfyK+ah9ZIvMDpei1m9ZRpdXcvde1wTpra2U8AFDNNpqJdnin5z+PM1GbQ5BoaKCWas2HSjtR0HwMg==", "integrity": "sha512-oHkV0iogWfyK+ah9ZIvMDpei1m9ZRpdXcvde1wTpra2U8AFDNNpqJdnin5z+PM1GbQ5BoaKCWas2HSjtR0HwMg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"app-builder-lib": "24.13.3", "app-builder-lib": "24.13.3",
"archiver": "^5.3.1", "archiver": "^5.3.1",
@ -3624,7 +3617,6 @@
"integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"graceful-fs": "^4.2.0", "graceful-fs": "^4.2.0",
"jsonfile": "^6.0.1", "jsonfile": "^6.0.1",
@ -3640,7 +3632,6 @@
"integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==", "integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"universalify": "^2.0.0" "universalify": "^2.0.0"
}, },
@ -3654,7 +3645,6 @@
"integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">= 10.0.0" "node": ">= 10.0.0"
} }
@ -4136,8 +4126,7 @@
"resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz",
"integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT"
"peer": true
}, },
"node_modules/fs-extra": { "node_modules/fs-extra": {
"version": "8.1.0", "version": "8.1.0",
@ -4855,7 +4844,6 @@
"integrity": "sha512-b94GiNHQNy6JNTrt5w6zNyffMrNkXZb3KTkCZJb2V1xaEGCk093vkZ2jk3tpaeP33/OiXC+WvK9AxUebnf5nbw==", "integrity": "sha512-b94GiNHQNy6JNTrt5w6zNyffMrNkXZb3KTkCZJb2V1xaEGCk093vkZ2jk3tpaeP33/OiXC+WvK9AxUebnf5nbw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"readable-stream": "^2.0.5" "readable-stream": "^2.0.5"
}, },
@ -4869,7 +4857,6 @@
"integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"core-util-is": "~1.0.0", "core-util-is": "~1.0.0",
"inherits": "~2.0.3", "inherits": "~2.0.3",
@ -4885,8 +4872,7 @@
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT"
"peer": true
}, },
"node_modules/lazystream/node_modules/string_decoder": { "node_modules/lazystream/node_modules/string_decoder": {
"version": "1.1.1", "version": "1.1.1",
@ -4894,7 +4880,6 @@
"integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"safe-buffer": "~5.1.0" "safe-buffer": "~5.1.0"
} }
@ -4916,13 +4901,15 @@
"version": "4.17.21", "version": "4.17.21",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
"license": "MIT" "license": "MIT",
"peer": true
}, },
"node_modules/lodash-es": { "node_modules/lodash-es": {
"version": "4.17.22", "version": "4.17.22",
"resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.22.tgz", "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.22.tgz",
"integrity": "sha512-XEawp1t0gxSi9x01glktRZ5HDy0HXqrM0x5pXQM98EaI0NxO6jVM7omDOxsuEo5UIASAnm2bRp1Jt/e0a2XU8Q==", "integrity": "sha512-XEawp1t0gxSi9x01glktRZ5HDy0HXqrM0x5pXQM98EaI0NxO6jVM7omDOxsuEo5UIASAnm2bRp1Jt/e0a2XU8Q==",
"license": "MIT" "license": "MIT",
"peer": true
}, },
"node_modules/lodash-unified": { "node_modules/lodash-unified": {
"version": "1.0.3", "version": "1.0.3",
@ -4940,40 +4927,35 @@
"resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz", "resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz",
"integrity": "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==", "integrity": "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT"
"peer": true
}, },
"node_modules/lodash.difference": { "node_modules/lodash.difference": {
"version": "4.5.0", "version": "4.5.0",
"resolved": "https://registry.npmjs.org/lodash.difference/-/lodash.difference-4.5.0.tgz", "resolved": "https://registry.npmjs.org/lodash.difference/-/lodash.difference-4.5.0.tgz",
"integrity": "sha512-dS2j+W26TQ7taQBGN8Lbbq04ssV3emRw4NY58WErlTO29pIqS0HmoT5aJ9+TUQ1N3G+JOZSji4eugsWwGp9yPA==", "integrity": "sha512-dS2j+W26TQ7taQBGN8Lbbq04ssV3emRw4NY58WErlTO29pIqS0HmoT5aJ9+TUQ1N3G+JOZSji4eugsWwGp9yPA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT"
"peer": true
}, },
"node_modules/lodash.flatten": { "node_modules/lodash.flatten": {
"version": "4.4.0", "version": "4.4.0",
"resolved": "https://registry.npmjs.org/lodash.flatten/-/lodash.flatten-4.4.0.tgz", "resolved": "https://registry.npmjs.org/lodash.flatten/-/lodash.flatten-4.4.0.tgz",
"integrity": "sha512-C5N2Z3DgnnKr0LOpv/hKCgKdb7ZZwafIrsesve6lmzvZIRZRGaZ/l6Q8+2W7NaT+ZwO3fFlSCzCzrDCFdJfZ4g==", "integrity": "sha512-C5N2Z3DgnnKr0LOpv/hKCgKdb7ZZwafIrsesve6lmzvZIRZRGaZ/l6Q8+2W7NaT+ZwO3fFlSCzCzrDCFdJfZ4g==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT"
"peer": true
}, },
"node_modules/lodash.isplainobject": { "node_modules/lodash.isplainobject": {
"version": "4.0.6", "version": "4.0.6",
"resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz",
"integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT"
"peer": true
}, },
"node_modules/lodash.union": { "node_modules/lodash.union": {
"version": "4.6.0", "version": "4.6.0",
"resolved": "https://registry.npmjs.org/lodash.union/-/lodash.union-4.6.0.tgz", "resolved": "https://registry.npmjs.org/lodash.union/-/lodash.union-4.6.0.tgz",
"integrity": "sha512-c4pB2CdGrGdjMKYLA+XiRDO7Y0PRQbm/Gzg8qMj+QH+pFVAoTp5sBpO0odL3FjoPCGjK96p6qsP+yQoiLoOBcw==", "integrity": "sha512-c4pB2CdGrGdjMKYLA+XiRDO7Y0PRQbm/Gzg8qMj+QH+pFVAoTp5sBpO0odL3FjoPCGjK96p6qsP+yQoiLoOBcw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT"
"peer": true
}, },
"node_modules/lowercase-keys": { "node_modules/lowercase-keys": {
"version": "2.0.0", "version": "2.0.0",
@ -5239,7 +5221,6 @@
"integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=0.10.0" "node": ">=0.10.0"
} }
@ -5619,7 +5600,6 @@
"integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"inherits": "^2.0.3", "inherits": "^2.0.3",
"string_decoder": "^1.1.1", "string_decoder": "^1.1.1",
@ -5635,7 +5615,6 @@
"integrity": "sha512-v05I2k7xN8zXvPD9N+z/uhXPaj0sUFCe2rcWZIpBsqxfP7xXFQ0tipAd/wjj1YxWyWtUS5IDJpOG82JKt2EAVA==", "integrity": "sha512-v05I2k7xN8zXvPD9N+z/uhXPaj0sUFCe2rcWZIpBsqxfP7xXFQ0tipAd/wjj1YxWyWtUS5IDJpOG82JKt2EAVA==",
"dev": true, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"peer": true,
"dependencies": { "dependencies": {
"minimatch": "^5.1.0" "minimatch": "^5.1.0"
} }
@ -5792,8 +5771,7 @@
"url": "https://feross.org/support" "url": "https://feross.org/support"
} }
], ],
"license": "MIT", "license": "MIT"
"peer": true
}, },
"node_modules/safer-buffer": { "node_modules/safer-buffer": {
"version": "2.1.2", "version": "2.1.2",
@ -5818,6 +5796,7 @@
"integrity": "sha512-uf6HoO8fy6ClsrShvMgaKUn14f2EHQLQRtpsZZLeU/Mv0Q1K5P0+x2uvH6Cub39TVVbWNSrraUhDAoFph6vh0A==", "integrity": "sha512-uf6HoO8fy6ClsrShvMgaKUn14f2EHQLQRtpsZZLeU/Mv0Q1K5P0+x2uvH6Cub39TVVbWNSrraUhDAoFph6vh0A==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"chokidar": "^4.0.0", "chokidar": "^4.0.0",
"immutable": "^5.0.2", "immutable": "^5.0.2",
@ -6052,7 +6031,6 @@
"integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"safe-buffer": "~5.2.0" "safe-buffer": "~5.2.0"
} }
@ -6173,7 +6151,6 @@
"integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"bl": "^4.0.3", "bl": "^4.0.3",
"end-of-stream": "^1.4.1", "end-of-stream": "^1.4.1",
@ -6313,6 +6290,7 @@
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"devOptional": true, "devOptional": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"peer": true,
"bin": { "bin": {
"tsc": "bin/tsc", "tsc": "bin/tsc",
"tsserver": "bin/tsserver" "tsserver": "bin/tsserver"
@ -6431,6 +6409,7 @@
"integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"esbuild": "^0.21.3", "esbuild": "^0.21.3",
"postcss": "^8.4.43", "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", "resolved": "https://registry.npmjs.org/vite-plugin-electron-renderer/-/vite-plugin-electron-renderer-0.14.6.tgz",
"integrity": "sha512-oqkWFa7kQIkvHXG7+Mnl1RTroA4sP0yesKatmAy0gjZC4VwUqlvF9IvOpHd1fpLWsqYX/eZlVxlhULNtaQ78Jw==", "integrity": "sha512-oqkWFa7kQIkvHXG7+Mnl1RTroA4sP0yesKatmAy0gjZC4VwUqlvF9IvOpHd1fpLWsqYX/eZlVxlhULNtaQ78Jw==",
"dev": true, "dev": true,
"license": "MIT" "license": "MIT",
"peer": true
}, },
"node_modules/vue": { "node_modules/vue": {
"version": "3.5.26", "version": "3.5.26",
"resolved": "https://registry.npmjs.org/vue/-/vue-3.5.26.tgz", "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.26.tgz",
"integrity": "sha512-SJ/NTccVyAoNUJmkM9KUqPcYlY+u8OVL1X5EW9RIs3ch5H2uERxyyIUI4MRxVCSOiEcupX9xNGde1tL9ZKpimA==", "integrity": "sha512-SJ/NTccVyAoNUJmkM9KUqPcYlY+u8OVL1X5EW9RIs3ch5H2uERxyyIUI4MRxVCSOiEcupX9xNGde1tL9ZKpimA==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@vue/compiler-dom": "3.5.26", "@vue/compiler-dom": "3.5.26",
"@vue/compiler-sfc": "3.5.26", "@vue/compiler-sfc": "3.5.26",
@ -6770,7 +6751,6 @@
"integrity": "sha512-9qv4rlDiopXg4E69k+vMHjNN63YFMe9sZMrdlvKnCjlCRWeCBswPPMPUfx+ipsAWq1LXHe70RcbaHdJJpS6hyQ==", "integrity": "sha512-9qv4rlDiopXg4E69k+vMHjNN63YFMe9sZMrdlvKnCjlCRWeCBswPPMPUfx+ipsAWq1LXHe70RcbaHdJJpS6hyQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"archiver-utils": "^3.0.4", "archiver-utils": "^3.0.4",
"compress-commons": "^4.1.2", "compress-commons": "^4.1.2",
@ -6786,7 +6766,6 @@
"integrity": "sha512-KVgf4XQVrTjhyWmx6cte4RxonPLR9onExufI1jhvw/MQ4BB6IsZD5gT8Lq+u/+pRkWna/6JoHpiQioaqFP5Rzw==", "integrity": "sha512-KVgf4XQVrTjhyWmx6cte4RxonPLR9onExufI1jhvw/MQ4BB6IsZD5gT8Lq+u/+pRkWna/6JoHpiQioaqFP5Rzw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"glob": "^7.2.3", "glob": "^7.2.3",
"graceful-fs": "^4.2.0", "graceful-fs": "^4.2.0",

View File

@ -97,6 +97,8 @@ const menuItems = [
{ path: '/nginx', label: 'Nginx 管理', icon: 'Connection', service: 'nginx' }, { path: '/nginx', label: 'Nginx 管理', icon: 'Connection', service: 'nginx' },
{ path: '/redis', label: 'Redis 管理', icon: 'Grid', service: 'redis' }, { path: '/redis', label: 'Redis 管理', icon: 'Grid', service: 'redis' },
{ path: '/nodejs', label: 'Node.js 管理', icon: 'Promotion', service: null }, { 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: '/sites', label: '站点管理', icon: 'Monitor', service: null },
{ path: '/hosts', label: 'Hosts 管理', icon: 'Document', service: null }, { path: '/hosts', label: 'Hosts 管理', icon: 'Document', service: null },
{ path: '/settings', label: '设置', icon: 'Setting', service: null } { path: '/settings', label: '设置', icon: 'Setting', service: null }

View File

@ -51,6 +51,18 @@ const router = createRouter({
component: () => import('@/views/HostsManager.vue'), component: () => import('@/views/HostsManager.vue'),
meta: { title: 'Hosts 管理' } 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', path: '/settings',
name: 'settings', name: 'settings',

544
src/views/GitManager.vue Normal file
View File

@ -0,0 +1,544 @@
<template>
<div class="page-container">
<div class="page-header">
<h1 class="page-title">
<span class="title-icon"><el-icon><Share /></el-icon></span>
Git 管理
</h1>
<p class="page-description">安装和管理 Git 版本控制工具</p>
</div>
<!-- 系统 Git 状态 -->
<div class="card">
<div class="card-header">
<span class="card-title">
<el-icon><InfoFilled /></el-icon>
Git 状态
</span>
<el-button size="small" @click="refreshStatus" :loading="loading">
<el-icon><Refresh /></el-icon>
刷新
</el-button>
</div>
<div class="card-content">
<div v-if="loading" class="loading-state">
<el-icon class="is-loading"><Loading /></el-icon>
<span>加载中...</span>
</div>
<div v-else class="git-status">
<div class="status-info">
<div class="status-icon" :class="{ installed: gitStatus.installed }">
<el-icon v-if="gitStatus.installed"><Check /></el-icon>
<el-icon v-else><Close /></el-icon>
</div>
<div class="status-details">
<div class="status-title">
{{ gitStatus.installed ? 'Git 已安装' : 'Git 未安装' }}
<el-tag v-if="gitStatus.version" type="success" size="small" class="ml-2">
v{{ gitStatus.version }}
</el-tag>
</div>
<div class="status-path" v-if="gitStatus.path">
{{ gitStatus.path }}
</div>
</div>
</div>
<div class="status-actions">
<el-button
v-if="!gitStatus.installed && !localInstalled"
type="primary"
@click="showInstallDialog = true"
>
<el-icon><Download /></el-icon>
安装 Git
</el-button>
<el-button
v-if="localInstalled"
type="danger"
@click="uninstallGit"
:loading="uninstalling"
>
<el-icon><Delete /></el-icon>
卸载
</el-button>
</div>
</div>
</div>
</div>
<!-- Git 配置 -->
<div class="card" v-if="gitStatus.installed">
<div class="card-header">
<span class="card-title">
<el-icon><Setting /></el-icon>
Git 全局配置
</span>
</div>
<div class="card-content">
<el-form :model="gitConfig" label-width="100px" class="config-form">
<el-form-item label="用户名">
<el-input v-model="gitConfig.name" placeholder="请输入 Git 用户名" />
</el-form-item>
<el-form-item label="邮箱">
<el-input v-model="gitConfig.email" placeholder="请输入 Git 邮箱" />
</el-form-item>
<el-form-item>
<el-button type="primary" @click="saveConfig" :loading="savingConfig">
保存配置
</el-button>
</el-form-item>
</el-form>
</div>
</div>
<!-- 本地安装的 Git 版本 -->
<div class="card" v-if="installedVersions.length > 0">
<div class="card-header">
<span class="card-title">
<el-icon><Box /></el-icon>
本地安装版本
</span>
</div>
<div class="card-content">
<div
v-for="version in installedVersions"
:key="version.version"
class="version-card active"
>
<div class="version-info">
<div class="version-icon">
<el-icon><Share /></el-icon>
</div>
<div class="version-details">
<div class="version-name">
Git {{ version.version }}
<el-tag type="success" size="small" class="ml-2">已安装</el-tag>
</div>
<div class="version-path">{{ version.path }}</div>
</div>
</div>
</div>
</div>
</div>
<!-- 安装对话框 -->
<el-dialog
v-model="showInstallDialog"
title="安装 Git"
width="600px"
>
<el-alert type="info" :closable="false" class="mb-4">
<template #title>安装说明</template>
将下载 Git for Windows 便携版无需管理员权限即可使用
</el-alert>
<div v-if="loadingVersions" class="loading-state">
<el-icon class="is-loading"><Loading /></el-icon>
<span>加载可用版本...</span>
</div>
<div v-else class="available-versions">
<div
v-for="version in availableVersions"
:key="version.version"
class="available-version-item"
:class="{ selected: selectedVersion === version.version }"
@click="selectedVersion = version.version"
>
<div class="version-select-info">
<span class="version-number">Git {{ version.version }}</span>
<span class="version-type">{{ version.type === 'portable' ? '便携版' : '安装版' }}</span>
</div>
<el-icon v-if="selectedVersion === version.version" class="check-icon"><Check /></el-icon>
</div>
</div>
<!-- 下载进度条 -->
<div v-if="installing && downloadProgress.total > 0" class="download-progress">
<div class="progress-info">
<span>下载中...</span>
<span>{{ formatSize(downloadProgress.downloaded) }} / {{ formatSize(downloadProgress.total) }}</span>
</div>
<el-progress :percentage="downloadProgress.progress" :stroke-width="10" />
</div>
<template #footer>
<el-button @click="showInstallDialog = false" :disabled="installing">取消</el-button>
<el-button
type="primary"
@click="installGit"
:loading="installing"
:disabled="!selectedVersion"
>
{{ installing ? '安装中...' : '安装' }}
</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted, onUnmounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
interface GitVersion {
version: string
path: string
isActive: boolean
}
interface AvailableVersion {
version: string
downloadUrl: string
type: string
}
const loading = ref(false)
const loadingVersions = ref(false)
const installing = ref(false)
const uninstalling = ref(false)
const savingConfig = ref(false)
const showInstallDialog = ref(false)
const selectedVersion = ref('')
const gitStatus = ref<{
installed: boolean
version?: string
path?: string
}>({ installed: false })
const localInstalled = ref(false)
const installedVersions = ref<GitVersion[]>([])
const availableVersions = ref<AvailableVersion[]>([])
const gitConfig = reactive({
name: '',
email: ''
})
const downloadProgress = reactive({
progress: 0,
downloaded: 0,
total: 0
})
const refreshStatus = async () => {
loading.value = true
try {
// Git
gitStatus.value = await window.electronAPI?.git?.checkSystem() || { installed: false }
//
installedVersions.value = await window.electronAPI?.git?.getVersions() || []
localInstalled.value = installedVersions.value.length > 0
// Git
if (gitStatus.value.installed) {
const config = await window.electronAPI?.git?.getConfig() || {}
gitConfig.name = config.name || ''
gitConfig.email = config.email || ''
}
} catch (error: any) {
console.error('加载状态失败:', error)
} finally {
loading.value = false
}
}
const loadAvailableVersions = async () => {
loadingVersions.value = true
try {
availableVersions.value = await window.electronAPI?.git?.getAvailableVersions() || []
if (availableVersions.value.length > 0) {
selectedVersion.value = availableVersions.value[0].version
}
} catch (error: any) {
ElMessage.error('加载可用版本失败')
} finally {
loadingVersions.value = false
}
}
const installGit = async () => {
if (!selectedVersion.value) return
downloadProgress.progress = 0
downloadProgress.downloaded = 0
downloadProgress.total = 0
installing.value = true
try {
const result = await window.electronAPI?.git?.install(selectedVersion.value)
if (result?.success) {
ElMessage.success(result.message)
showInstallDialog.value = false
await refreshStatus()
} else {
ElMessage.error(result?.message || '安装失败')
}
} catch (error: any) {
ElMessage.error(error.message)
} finally {
installing.value = false
downloadProgress.progress = 0
downloadProgress.downloaded = 0
downloadProgress.total = 0
}
}
const uninstallGit = async () => {
try {
await ElMessageBox.confirm(
'确定要卸载本地安装的 Git 吗?',
'确认卸载',
{ type: 'warning' }
)
uninstalling.value = true
const result = await window.electronAPI?.git?.uninstall()
if (result?.success) {
ElMessage.success(result.message)
await refreshStatus()
} else {
ElMessage.error(result?.message || '卸载失败')
}
} catch (error: any) {
if (error !== 'cancel') {
ElMessage.error(error.message)
}
} finally {
uninstalling.value = false
}
}
const saveConfig = async () => {
savingConfig.value = true
try {
const result = await window.electronAPI?.git?.setConfig(gitConfig.name, gitConfig.email)
if (result?.success) {
ElMessage.success(result.message)
} else {
ElMessage.error(result?.message || '保存失败')
}
} catch (error: any) {
ElMessage.error(error.message)
} finally {
savingConfig.value = false
}
}
const formatSize = (bytes: number) => {
if (bytes === 0) return '0 B'
const k = 1024
const sizes = ['B', 'KB', 'MB', 'GB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i]
}
onMounted(() => {
refreshStatus()
loadAvailableVersions()
//
window.electronAPI?.onDownloadProgress((data: any) => {
if (data.type === 'git') {
downloadProgress.progress = data.progress
downloadProgress.downloaded = data.downloaded
downloadProgress.total = data.total
}
})
})
onUnmounted(() => {
window.electronAPI?.removeDownloadProgressListener()
})
</script>
<style lang="scss" scoped>
.loading-state {
display: flex;
align-items: center;
justify-content: center;
gap: 12px;
padding: 40px;
color: var(--text-secondary);
.is-loading {
font-size: 24px;
animation: spin 1s linear infinite;
}
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.ml-2 {
margin-left: 8px;
}
.mb-4 {
margin-bottom: 16px;
}
.git-status {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 0;
.status-info {
display: flex;
align-items: center;
gap: 16px;
}
.status-icon {
width: 48px;
height: 48px;
border-radius: 12px;
background: var(--bg-card);
display: flex;
align-items: center;
justify-content: center;
font-size: 24px;
color: var(--text-muted);
&.installed {
background: rgba(103, 194, 58, 0.1);
color: var(--success-color);
}
}
.status-details {
.status-title {
font-size: 16px;
font-weight: 600;
margin-bottom: 4px;
display: flex;
align-items: center;
}
.status-path {
font-size: 12px;
color: var(--text-muted);
font-family: 'Fira Code', monospace;
}
}
.status-actions {
display: flex;
gap: 8px;
}
}
.config-form {
max-width: 500px;
}
.version-card {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 20px;
border: 1px solid var(--border-color);
border-radius: 12px;
margin-bottom: 12px;
&.active {
border-color: var(--success-color);
background: rgba(103, 194, 58, 0.05);
}
.version-info {
display: flex;
align-items: center;
gap: 16px;
}
.version-icon {
width: 48px;
height: 48px;
border-radius: 12px;
background: linear-gradient(135deg, #f05033 0%, #ff6b6b 100%);
display: flex;
align-items: center;
justify-content: center;
color: white;
font-size: 24px;
}
.version-details {
.version-name {
font-size: 16px;
font-weight: 600;
margin-bottom: 4px;
display: flex;
align-items: center;
}
.version-path {
font-size: 12px;
color: var(--text-muted);
font-family: 'Fira Code', monospace;
}
}
}
.available-versions {
max-height: 300px;
overflow-y: auto;
}
.available-version-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 20px;
border: 1px solid var(--border-color);
border-radius: 10px;
margin-bottom: 12px;
cursor: pointer;
transition: all 0.2s;
&:hover {
border-color: var(--accent-light);
background: var(--bg-hover);
}
&.selected {
border-color: var(--accent-color);
background: rgba(124, 58, 237, 0.05);
}
.version-select-info {
display: flex;
flex-direction: column;
gap: 4px;
.version-number {
font-weight: 600;
font-size: 16px;
}
.version-type {
font-size: 12px;
color: var(--text-muted);
}
}
.check-icon {
color: var(--accent-color);
font-size: 20px;
}
}
.download-progress {
margin-top: 16px;
.progress-info {
display: flex;
justify-content: space-between;
margin-bottom: 8px;
font-size: 13px;
color: var(--text-secondary);
}
}
</style>

527
src/views/PythonManager.vue Normal file
View File

@ -0,0 +1,527 @@
<template>
<div class="page-container">
<div class="page-header">
<h1 class="page-title">
<span class="title-icon"><el-icon><Platform /></el-icon></span>
Python 管理
</h1>
<p class="page-description">安装切换和管理 Python 版本</p>
</div>
<!-- 已安装版本 -->
<div class="card">
<div class="card-header">
<span class="card-title">
<el-icon><Box /></el-icon>
已安装版本
</span>
<el-button type="primary" @click="showInstallDialog = true">
<el-icon><Plus /></el-icon>
安装新版本
</el-button>
</div>
<div class="card-content">
<div v-if="loading" class="loading-state">
<el-icon class="is-loading"><Loading /></el-icon>
<span>加载中...</span>
</div>
<div v-else-if="installedVersions.length === 0" class="empty-state">
<el-icon class="empty-icon"><Platform /></el-icon>
<h3 class="empty-title">暂未安装 Python</h3>
<p class="empty-description">点击上方按钮安装第一个 Python 版本</p>
</div>
<div v-else>
<div
v-for="version in installedVersions"
:key="version.version"
class="version-card"
:class="{ active: version.isActive }"
>
<div class="version-info">
<div class="version-icon">
<el-icon><Platform /></el-icon>
</div>
<div class="version-details">
<div class="version-name">
Python {{ version.version }}
<el-tag v-if="version.isActive" type="success" size="small" class="ml-2">当前使用</el-tag>
</div>
<div class="version-path">{{ version.path }}</div>
<div class="pip-info" v-if="pipInfo[version.version]">
<el-tag type="info" size="small">
pip {{ pipInfo[version.version] }}
</el-tag>
</div>
</div>
</div>
<div class="version-actions">
<el-button
v-if="!version.isActive"
type="primary"
size="small"
@click="setActive(version.version)"
:loading="settingActive === version.version"
>
设为默认
</el-button>
<el-button
type="danger"
size="small"
@click="uninstall(version.version)"
:disabled="version.isActive"
>
<el-icon><Delete /></el-icon>
卸载
</el-button>
</div>
</div>
</div>
</div>
</div>
<!-- pip 包管理 -->
<div class="card" v-if="installedVersions.length > 0">
<div class="card-header">
<span class="card-title">
<el-icon><Box /></el-icon>
pip 包管理
</span>
</div>
<div class="card-content">
<el-form inline class="pip-form">
<el-form-item label="Python 版本">
<el-select v-model="selectedPythonVersion" placeholder="选择版本">
<el-option
v-for="v in installedVersions"
:key="v.version"
:label="`Python ${v.version}`"
:value="v.version"
/>
</el-select>
</el-form-item>
<el-form-item label="包名">
<el-input v-model="packageName" placeholder="输入包名,如 requests" />
</el-form-item>
<el-form-item>
<el-button
type="primary"
@click="installPackage"
:loading="installingPackage"
:disabled="!selectedPythonVersion || !packageName"
>
安装包
</el-button>
</el-form-item>
</el-form>
<div class="pip-hint">
常用包requests, flask, django, numpy, pandas, pillow
</div>
</div>
</div>
<!-- 安装对话框 -->
<el-dialog
v-model="showInstallDialog"
title="安装 Python 版本"
width="600px"
>
<el-alert type="info" :closable="false" class="mb-4">
<template #title>安装说明</template>
将下载 Python 嵌入式版本免安装自动配置 pip
</el-alert>
<div v-if="availableVersions.length === 0" class="loading-state">
<el-icon class="is-loading"><Loading /></el-icon>
<span>加载可用版本...</span>
</div>
<div v-else class="available-versions">
<div
v-for="version in availableVersions"
:key="version.version"
class="available-version-item"
:class="{ selected: selectedVersion === version.version }"
@click="selectedVersion = version.version"
>
<div class="version-select-info">
<span class="version-number">Python {{ version.version }}</span>
<span class="version-type">{{ version.type === 'embed' ? '嵌入式版本' : '安装版' }}</span>
</div>
<el-icon v-if="selectedVersion === version.version" class="check-icon"><Check /></el-icon>
</div>
</div>
<!-- 下载进度条 -->
<div v-if="installing && downloadProgress.total > 0" class="download-progress">
<div class="progress-info">
<span>下载中...</span>
<span>{{ formatSize(downloadProgress.downloaded) }} / {{ formatSize(downloadProgress.total) }}</span>
</div>
<el-progress :percentage="downloadProgress.progress" :stroke-width="10" />
</div>
<template #footer>
<el-button @click="showInstallDialog = false" :disabled="installing">取消</el-button>
<el-button
type="primary"
@click="install"
:loading="installing"
:disabled="!selectedVersion"
>
{{ installing ? '安装中...' : '安装' }}
</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted, onUnmounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
interface PythonVersion {
version: string
path: string
isActive: boolean
}
interface AvailableVersion {
version: string
downloadUrl: string
type: string
}
const loading = ref(false)
const installedVersions = ref<PythonVersion[]>([])
const availableVersions = ref<AvailableVersion[]>([])
const showInstallDialog = ref(false)
const selectedVersion = ref('')
const installing = ref(false)
const settingActive = ref('')
const pipInfo = ref<Record<string, string>>({})
// pip
const selectedPythonVersion = ref('')
const packageName = ref('')
const installingPackage = ref(false)
const downloadProgress = reactive({
progress: 0,
downloaded: 0,
total: 0
})
const loadVersions = async () => {
loading.value = true
try {
installedVersions.value = await window.electronAPI?.python?.getVersions() || []
// pip
for (const v of installedVersions.value) {
const info = await window.electronAPI?.python?.getPipInfo(v.version)
if (info?.installed) {
pipInfo.value[v.version] = info.version || 'installed'
}
}
// pip
if (installedVersions.value.length > 0 && !selectedPythonVersion.value) {
selectedPythonVersion.value = installedVersions.value[0].version
}
} catch (error: any) {
console.error('加载版本失败:', error)
} finally {
loading.value = false
}
}
const loadAvailableVersions = async () => {
try {
availableVersions.value = await window.electronAPI?.python?.getAvailableVersions() || []
if (availableVersions.value.length > 0) {
selectedVersion.value = availableVersions.value[0].version
}
} catch (error: any) {
ElMessage.error('加载可用版本失败: ' + error.message)
}
}
const install = async () => {
if (!selectedVersion.value) return
downloadProgress.progress = 0
downloadProgress.downloaded = 0
downloadProgress.total = 0
installing.value = true
try {
const result = await window.electronAPI?.python?.install(selectedVersion.value)
if (result?.success) {
ElMessage.success(result.message)
showInstallDialog.value = false
selectedVersion.value = ''
await loadVersions()
await loadAvailableVersions()
} else {
ElMessage.error(result?.message || '安装失败')
}
} catch (error: any) {
ElMessage.error(error.message)
} finally {
installing.value = false
downloadProgress.progress = 0
downloadProgress.downloaded = 0
downloadProgress.total = 0
}
}
const uninstall = async (version: string) => {
try {
await ElMessageBox.confirm(
`确定要卸载 Python ${version} 吗?此操作不可恢复。`,
'确认卸载',
{ type: 'warning' }
)
const result = await window.electronAPI?.python?.uninstall(version)
if (result?.success) {
ElMessage.success(result.message)
await loadVersions()
await loadAvailableVersions()
} else {
ElMessage.error(result?.message || '卸载失败')
}
} catch (error: any) {
if (error !== 'cancel') {
ElMessage.error(error.message)
}
}
}
const setActive = async (version: string) => {
settingActive.value = version
try {
const result = await window.electronAPI?.python?.setActive(version)
if (result?.success) {
ElMessage.success(result.message)
await loadVersions()
} else {
ElMessage.error(result?.message || '设置失败')
}
} catch (error: any) {
ElMessage.error(error.message)
} finally {
settingActive.value = ''
}
}
const installPackage = async () => {
if (!selectedPythonVersion.value || !packageName.value) return
installingPackage.value = true
try {
const result = await window.electronAPI?.python?.installPackage(
selectedPythonVersion.value,
packageName.value
)
if (result?.success) {
ElMessage.success(result.message)
packageName.value = ''
} else {
ElMessage.error(result?.message || '安装失败')
}
} catch (error: any) {
ElMessage.error(error.message)
} finally {
installingPackage.value = false
}
}
const formatSize = (bytes: number) => {
if (bytes === 0) return '0 B'
const k = 1024
const sizes = ['B', 'KB', 'MB', 'GB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i]
}
onMounted(() => {
loadVersions()
loadAvailableVersions()
//
window.electronAPI?.onDownloadProgress((data: any) => {
if (data.type === 'python') {
downloadProgress.progress = data.progress
downloadProgress.downloaded = data.downloaded
downloadProgress.total = data.total
}
})
})
onUnmounted(() => {
window.electronAPI?.removeDownloadProgressListener()
})
</script>
<style lang="scss" scoped>
.loading-state {
display: flex;
align-items: center;
justify-content: center;
gap: 12px;
padding: 40px;
color: var(--text-secondary);
.is-loading {
font-size: 24px;
animation: spin 1s linear infinite;
}
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.ml-2 {
margin-left: 8px;
}
.mb-4 {
margin-bottom: 16px;
}
.version-card {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 20px;
border: 1px solid var(--border-color);
border-radius: 12px;
margin-bottom: 12px;
transition: all 0.2s;
&:hover {
border-color: var(--accent-light);
box-shadow: var(--shadow-sm);
}
&.active {
border-color: var(--success-color);
background: rgba(103, 194, 58, 0.05);
}
.version-info {
display: flex;
align-items: center;
gap: 16px;
}
.version-icon {
width: 48px;
height: 48px;
border-radius: 12px;
background: linear-gradient(135deg, #3776ab 0%, #ffd43b 100%);
display: flex;
align-items: center;
justify-content: center;
color: white;
font-size: 24px;
}
.version-details {
.version-name {
font-size: 16px;
font-weight: 600;
margin-bottom: 4px;
display: flex;
align-items: center;
}
.version-path {
font-size: 12px;
color: var(--text-muted);
font-family: 'Fira Code', monospace;
margin-bottom: 4px;
}
.pip-info {
margin-top: 4px;
}
}
.version-actions {
display: flex;
gap: 8px;
}
}
.available-versions {
max-height: 300px;
overflow-y: auto;
}
.available-version-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 20px;
border: 1px solid var(--border-color);
border-radius: 10px;
margin-bottom: 12px;
cursor: pointer;
transition: all 0.2s;
&:hover {
border-color: var(--accent-light);
background: var(--bg-hover);
}
&.selected {
border-color: var(--accent-color);
background: rgba(124, 58, 237, 0.05);
}
.version-select-info {
display: flex;
flex-direction: column;
gap: 4px;
.version-number {
font-weight: 600;
font-size: 16px;
}
.version-type {
font-size: 12px;
color: var(--text-muted);
}
}
.check-icon {
color: var(--accent-color);
font-size: 20px;
}
}
.download-progress {
margin-top: 16px;
.progress-info {
display: flex;
justify-content: space-between;
margin-bottom: 8px;
font-size: 13px;
color: var(--text-secondary);
}
}
.pip-form {
.el-form-item {
margin-bottom: 16px;
}
}
.pip-hint {
font-size: 12px;
color: var(--text-muted);
margin-top: 8px;
}
</style>

View File

@ -56,16 +56,21 @@
</div> </div>
</div> </div>
<div class="site-meta"> <div class="site-meta">
<span class="meta-item"> <span class="meta-item" v-if="site.isProxy">
<el-icon><Link /></el-icon>
{{ site.proxyTarget }}
</span>
<span class="meta-item" v-else>
<el-icon><Folder /></el-icon> <el-icon><Folder /></el-icon>
{{ site.rootPath }} {{ site.rootPath }}
</span> </span>
<span class="meta-item"> <span class="meta-item" v-if="!site.isProxy">
<el-icon><Files /></el-icon> <el-icon><Files /></el-icon>
PHP {{ site.phpVersion }} (端口 {{ getPhpCgiPort(site.phpVersion) }}) PHP {{ site.phpVersion }} (端口 {{ getPhpCgiPort(site.phpVersion) }})
</span> </span>
</div> </div>
<div class="site-tags"> <div class="site-tags">
<el-tag v-if="site.isProxy" type="primary" size="small">反向代理</el-tag>
<el-tag v-if="site.isLaravel" type="warning" size="small">Laravel</el-tag> <el-tag v-if="site.isLaravel" type="warning" size="small">Laravel</el-tag>
<el-tag v-if="site.ssl" type="success" size="small">SSL</el-tag> <el-tag v-if="site.ssl" type="success" size="small">SSL</el-tag>
<el-tag :type="site.enabled ? 'success' : 'info'" size="small"> <el-tag :type="site.enabled ? 'success' : 'info'" size="small">
@ -126,7 +131,7 @@
<el-input v-model="siteForm.name" placeholder="留空则使用域名作为名称" /> <el-input v-model="siteForm.name" placeholder="留空则使用域名作为名称" />
<span class="form-hint">可选默认使用域名</span> <span class="form-hint">可选默认使用域名</span>
</el-form-item> </el-form-item>
<el-form-item label="根目录" required> <el-form-item label="根目录" required v-if="!siteForm.isProxy">
<div class="directory-input"> <div class="directory-input">
<el-input v-model="siteForm.rootPath" placeholder="点击右侧按钮选择目录" readonly /> <el-input v-model="siteForm.rootPath" placeholder="点击右侧按钮选择目录" readonly />
<el-button type="primary" @click="selectDirectory" :icon="FolderOpened"> <el-button type="primary" @click="selectDirectory" :icon="FolderOpened">
@ -134,7 +139,7 @@
</el-button> </el-button>
</div> </div>
</el-form-item> </el-form-item>
<el-form-item label="PHP 版本" required> <el-form-item label="PHP 版本" required v-if="!siteForm.isProxy">
<el-select v-model="siteForm.phpVersion" placeholder="选择 PHP 版本"> <el-select v-model="siteForm.phpVersion" placeholder="选择 PHP 版本">
<el-option <el-option
v-for="v in phpVersions" v-for="v in phpVersions"
@ -145,7 +150,15 @@
</el-select> </el-select>
<span class="form-hint">每个 PHP 版本使用独立端口的 FastCGI 进程</span> <span class="form-hint">每个 PHP 版本使用独立端口的 FastCGI 进程</span>
</el-form-item> </el-form-item>
<el-form-item label="Laravel 项目"> <el-form-item label="反向代理">
<el-switch v-model="siteForm.isProxy" @change="onProxyChange" />
<span class="form-hint">开启后将作为反向代理服务器用于 Node.jsGo 等应用</span>
</el-form-item>
<el-form-item label="代理目标" v-if="siteForm.isProxy" required>
<el-input v-model="siteForm.proxyTarget" placeholder="例如: http://127.0.0.1:3000" />
<span class="form-hint">后端服务地址支持 WebSocket</span>
</el-form-item>
<el-form-item label="Laravel 项目" v-if="!siteForm.isProxy">
<el-switch v-model="siteForm.isLaravel" /> <el-switch v-model="siteForm.isLaravel" />
<span class="form-hint">开启后将自动配置 Laravel 伪静态规则</span> <span class="form-hint">开启后将自动配置 Laravel 伪静态规则</span>
</el-form-item> </el-form-item>
@ -208,7 +221,15 @@
<el-input v-model="editForm.name" disabled /> <el-input v-model="editForm.name" disabled />
<span class="form-hint">站点名称不可修改</span> <span class="form-hint">站点名称不可修改</span>
</el-form-item> </el-form-item>
<el-form-item label="根目录" required> <el-form-item label="反向代理">
<el-switch v-model="editForm.isProxy" @change="onEditProxyChange" />
<span class="form-hint">开启后将作为反向代理服务器</span>
</el-form-item>
<el-form-item label="代理目标" v-if="editForm.isProxy" required>
<el-input v-model="editForm.proxyTarget" placeholder="例如: http://127.0.0.1:3000" />
<span class="form-hint">后端服务地址支持 WebSocket</span>
</el-form-item>
<el-form-item label="根目录" required v-if="!editForm.isProxy">
<div class="directory-input"> <div class="directory-input">
<el-input v-model="editForm.rootPath" placeholder="点击右侧按钮选择目录" readonly /> <el-input v-model="editForm.rootPath" placeholder="点击右侧按钮选择目录" readonly />
<el-button type="primary" @click="selectEditDirectory" :icon="FolderOpened"> <el-button type="primary" @click="selectEditDirectory" :icon="FolderOpened">
@ -216,7 +237,7 @@
</el-button> </el-button>
</div> </div>
</el-form-item> </el-form-item>
<el-form-item label="PHP 版本" required> <el-form-item label="PHP 版本" required v-if="!editForm.isProxy">
<el-select v-model="editForm.phpVersion" placeholder="选择 PHP 版本"> <el-select v-model="editForm.phpVersion" placeholder="选择 PHP 版本">
<el-option <el-option
v-for="v in phpVersions" v-for="v in phpVersions"
@ -227,7 +248,7 @@
</el-select> </el-select>
<span class="form-hint">修改后需重新加载 Nginx 配置</span> <span class="form-hint">修改后需重新加载 Nginx 配置</span>
</el-form-item> </el-form-item>
<el-form-item label="Laravel 项目"> <el-form-item label="Laravel 项目" v-if="!editForm.isProxy">
<el-switch v-model="editForm.isLaravel" /> <el-switch v-model="editForm.isLaravel" />
<span class="form-hint">开启后将自动配置 Laravel 伪静态规则</span> <span class="form-hint">开启后将自动配置 Laravel 伪静态规则</span>
</el-form-item> </el-form-item>
@ -326,6 +347,8 @@ interface SiteConfig {
isLaravel: boolean isLaravel: boolean
ssl: boolean ssl: boolean
enabled: boolean enabled: boolean
isProxy?: boolean
proxyTarget?: string
} }
const loading = ref(false) const loading = ref(false)
@ -353,7 +376,9 @@ const siteForm = reactive<SiteConfig>({
phpVersion: '', phpVersion: '',
isLaravel: false, isLaravel: false,
ssl: false, ssl: false,
enabled: true enabled: true,
isProxy: false,
proxyTarget: ''
}) })
const showSSLDialogVisible = ref(false) const showSSLDialogVisible = ref(false)
@ -374,7 +399,9 @@ const editForm = reactive<SiteConfig>({
phpVersion: '', phpVersion: '',
isLaravel: false, isLaravel: false,
ssl: false, ssl: false,
enabled: true enabled: true,
isProxy: false,
proxyTarget: ''
}) })
// Laravel // 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 = () => { const autoFillName = () => {
if (!siteForm.name && siteForm.domain) { if (!siteForm.name && siteForm.domain) {
@ -437,10 +489,18 @@ const addSite = async () => {
siteForm.name = siteForm.domain.replace(/\.(test|local|dev|localhost)$/i, '') siteForm.name = siteForm.domain.replace(/\.(test|local|dev|localhost)$/i, '')
} }
//
if (siteForm.isProxy) {
if (!siteForm.domain || !siteForm.proxyTarget) {
ElMessage.warning('请填写所有必填字段(域名、代理目标)')
return
}
} else {
if (!siteForm.domain || !siteForm.rootPath || !siteForm.phpVersion) { if (!siteForm.domain || !siteForm.rootPath || !siteForm.phpVersion) {
ElMessage.warning('请填写所有必填字段域名、根目录、PHP版本') ElMessage.warning('请填写所有必填字段域名、根目录、PHP版本')
return return
} }
}
// //
if (!siteForm.name) { if (!siteForm.name) {
@ -457,7 +517,9 @@ const addSite = async () => {
phpVersion: siteForm.phpVersion, phpVersion: siteForm.phpVersion,
isLaravel: siteForm.isLaravel, isLaravel: siteForm.isLaravel,
ssl: siteForm.ssl, ssl: siteForm.ssl,
enabled: siteForm.enabled enabled: siteForm.enabled,
isProxy: siteForm.isProxy,
proxyTarget: siteForm.proxyTarget
} }
const result = await window.electronAPI?.nginx.addSite(siteData) const result = await window.electronAPI?.nginx.addSite(siteData)
if (result?.success) { if (result?.success) {
@ -477,7 +539,9 @@ const addSite = async () => {
phpVersion: phpVersions.value[0]?.version || '', phpVersion: phpVersions.value[0]?.version || '',
isLaravel: false, isLaravel: false,
ssl: false, ssl: false,
enabled: true enabled: true,
isProxy: false,
proxyTarget: ''
}) })
// Nginx // Nginx
@ -568,7 +632,9 @@ const showEditDialog = (site: SiteConfig) => {
phpVersion: site.phpVersion, phpVersion: site.phpVersion,
isLaravel: site.isLaravel, isLaravel: site.isLaravel,
ssl: site.ssl, ssl: site.ssl,
enabled: site.enabled enabled: site.enabled,
isProxy: site.isProxy || false,
proxyTarget: site.proxyTarget || ''
}) })
showEditSiteDialog.value = true showEditSiteDialog.value = true
} }
@ -587,10 +653,18 @@ const selectEditDirectory = async () => {
// //
const updateSite = async () => { const updateSite = async () => {
//
if (editForm.isProxy) {
if (!editForm.domain || !editForm.proxyTarget) {
ElMessage.warning('请填写所有必填字段(域名、代理目标)')
return
}
} else {
if (!editForm.domain || !editForm.rootPath || !editForm.phpVersion) { if (!editForm.domain || !editForm.rootPath || !editForm.phpVersion) {
ElMessage.warning('请填写所有必填字段') ElMessage.warning('请填写所有必填字段')
return return
} }
}
updating.value = true updating.value = true
try { try {
@ -602,7 +676,9 @@ const updateSite = async () => {
phpVersion: editForm.phpVersion, phpVersion: editForm.phpVersion,
isLaravel: editForm.isLaravel, isLaravel: editForm.isLaravel,
ssl: editForm.ssl, 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) const result = await window.electronAPI?.nginx.updateSite(editingOriginalName.value, siteData)