This commit is contained in:
ethanfly 2025-12-26 03:35:54 +08:00
commit 24dc4c739b
39 changed files with 24229 additions and 0 deletions

35
.gitignore vendored Normal file
View File

@ -0,0 +1,35 @@
# Dependencies
node_modules/
# Build output
dist/
dist-electron/
release/
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
# Local env files
.env.local
.env.*.local
# Log files
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
# OS files
.DS_Store
Thumbs.db
# Electron
*.asar

283
README.md Normal file
View File

@ -0,0 +1,283 @@
# PHPer 开发环境管理器
<p align="center">
<img src="public/favicon.svg" alt="PHPer Logo" width="120" height="120">
</p>
<p align="center">
<strong>一款功能强大的 Windows PHP 开发环境管理工具</strong>
</p>
<p align="center">
轻松管理 PHP、MySQL、Nginx、Redis 等服务,告别繁琐的手动配置
</p>
<p align="center">
<a href="#功能特性">功能特性</a>
<a href="#安装使用">安装使用</a>
<a href="#使用指南">使用指南</a>
<a href="#常见问题">常见问题</a>
</p>
---
## 📸 界面预览
<table>
<tr>
<td><img src="docs/dashboard.png" alt="仪表盘" /></td>
<td><img src="docs/php-manager.png" alt="PHP管理" /></td>
</tr>
<tr>
<td align="center">仪表盘</td>
<td align="center">PHP 版本管理</td>
</tr>
</table>
## ✨ 功能特性
### 🐘 PHP 版本管理
| 功能 | 说明 |
| ---------- | ---------------------------------------------------------- |
| 多版本管理 | 支持同时安装 PHP 8.1、8.2、8.3、8.4、8.5 等多个版本 |
| 一键切换 | 点击即可切换 PHP 版本,自动配置系统环境变量 |
| 扩展管理 | 可视化管理 PHP 扩展,一键启用/禁用 |
| 配置编辑 | 在线编辑 php.ini无需手动查找配置文件 |
| 自动配置 | 安装时自动启用常用扩展curl、gd、mbstring、pdo_mysql 等) |
### 🐬 MySQL 管理
| 功能 | 说明 |
| ---------- | -------------------------------- |
| 版本支持 | 支持 MySQL 5.7.x 和 8.0.x 系列 |
| 服务控制 | 启动、停止、重启 MySQL 服务 |
| 密码管理 | 一键修改 root 密码 |
| 配置编辑 | 在线编辑 my.ini 配置文件 |
| 自动初始化 | 安装时自动初始化数据库,开箱即用 |
### 🌐 Nginx 管理
| 功能 | 说明 |
| ------------ | ------------------------------------ |
| 版本管理 | 支持多个 Nginx 版本,可随时切换 |
| 服务控制 | 启动、停止、重启、热重载配置 |
| 站点管理 | 可视化添加、删除、启用、禁用虚拟主机 |
| Laravel 支持 | 自动生成 Laravel 项目的伪静态配置 |
| SSL 证书 | 支持申请 Let's Encrypt 免费 SSL 证书 |
| 配置编辑 | 在线编辑 nginx.conf 主配置文件 |
### 🔴 Redis 管理
| 功能 | 说明 |
| ------------ | -------------------------------- |
| Windows 版本 | 使用 Windows 原生编译版 Redis |
| 服务控制 | 启动、停止、重启 Redis 服务 |
| 状态监控 | 实时查看运行状态、内存使用情况 |
| 配置编辑 | 在线编辑 redis.windows.conf 配置 |
### 🌍 站点管理
- **快速创建站点** - 填写域名和路径即可创建虚拟主机
- 🎯 **Laravel 一键配置** - 自动配置 public 目录和伪静态规则
- 🔒 **SSL 证书申请** - 集成 Let's Encrypt 自动申请
- 📝 **Hosts 自动配置** - 自动添加域名到系统 hosts 文件
### ⚙️ 其他功能
- 🚀 **开机自启动** - 可配置各服务开机自动启动
- 📋 **Hosts 管理** - 可视化管理系统 hosts 文件
- 🌙 **深色/浅色主题** - 支持主题切换
- 📊 **服务状态监控** - 实时显示各服务运行状态
## 🛠️ 技术栈
| 技术 | 说明 |
| --------------------------------------------- | ------------ |
| [Vue 3](https://vuejs.org/) | 前端框架 |
| [TypeScript](https://www.typescriptlang.org/) | 类型安全 |
| [Electron](https://www.electronjs.org/) | 桌面应用框架 |
| [Element Plus](https://element-plus.org/) | UI 组件库 |
| [Vite](https://vitejs.dev/) | 构建工具 |
| [Pinia](https://pinia.vuejs.org/) | 状态管理 |
## 📦 安装使用
### 系统要求
- ✅ Windows 10/11 (64 位)
- ✅ Node.js 18.0 或更高版本
- ✅ 管理员权限(用于管理服务和修改 hosts 文件)
- ✅ [Visual C++ Redistributable 2015-2022](https://aka.ms/vs/17/release/vc_redist.x64.exe)
### 开发环境
```bash
# 克隆项目
git clone https://github.com/your-username/phper.git
cd phper
# 安装依赖
npm install
# 启动开发服务器
npm run electron:dev
```
### 构建生产版本
```bash
# 构建 Windows 安装包
npm run electron:build
```
构建完成后,安装包将生成在 `release` 目录中。
## 📁 项目结构
```
phper/
├── electron/ # Electron 主进程
│ ├── main.ts # 主进程入口
│ ├── preload.ts # 预加载脚本IPC 通信)
│ └── services/ # 服务管理模块
│ ├── ConfigStore.ts # 配置存储(使用 electron-store
│ ├── PhpManager.ts # PHP 版本管理器
│ ├── MysqlManager.ts # MySQL 服务管理器
│ ├── NginxManager.ts # Nginx 服务管理器
│ ├── RedisManager.ts # Redis 服务管理器
│ ├── ServiceManager.ts # 开机自启服务管理器
│ └── HostsManager.ts # Hosts 文件管理器
├── src/ # Vue 前端源码
│ ├── App.vue # 根组件
│ ├── main.ts # 入口文件
│ ├── vite-env.d.ts # 类型声明
│ ├── router/ # 路由配置
│ │ └── index.ts
│ ├── styles/ # 样式文件
│ │ └── main.scss # 全局样式(含主题变量)
│ └── views/ # 页面视图
│ ├── Dashboard.vue # 仪表盘
│ ├── PhpManager.vue # PHP 管理
│ ├── MysqlManager.vue # MySQL 管理
│ ├── NginxManager.vue # Nginx 管理
│ ├── RedisManager.vue # Redis 管理
│ ├── SitesManager.vue # 站点管理
│ ├── HostsManager.vue # Hosts 管理
│ └── Settings.vue # 设置
├── public/ # 静态资源
│ └── favicon.svg # 应用图标
├── index.html # HTML 模板
├── package.json # 项目配置
├── vite.config.ts # Vite 配置
├── tsconfig.json # TypeScript 配置
└── README.md # 项目说明
```
## 📖 使用指南
### 首次使用
1. **安装运行时依赖**
- 确保已安装 [Visual C++ Redistributable 2015-2022](https://aka.ms/vs/17/release/vc_redist.x64.exe)
2. **以管理员身份运行**
- 右键点击应用图标,选择"以管理员身份运行"
- 这是管理服务和修改 hosts 文件所必需的
3. **安装服务**
- 首次使用需要安装 PHP、MySQL、Nginx、Redis
- 进入对应管理页面,点击"安装"按钮
### 创建第一个站点
1. 安装并启动 Nginx 和 PHP
2. 进入"站点管理"页面
3. 点击"添加站点"
4. 填写站点信息:
- 站点名称:如 `myproject`
- 域名:如 `myproject.test`
- 根目录:如 `C:\Projects\myproject`Laravel 项目无需指定 public
- 选择 PHP 版本
- 如果是 Laravel 项目,开启"Laravel 项目"选项
5. 点击确认,站点即创建完成
6. 在浏览器访问 http://myproject.test
### 配置开机自启动
1. 进入"设置"页面
2. 在"开机自启动"部分,开启需要自启的服务
3. 应用会在 Windows 启动目录创建启动脚本
## ❓ 常见问题
### Q: 为什么需要管理员权限?
A: 应用需要管理员权限来:
- 启动/停止 Windows 服务
- 修改系统 hosts 文件
- 配置系统环境变量
### Q: PHP 下载很慢怎么办?
A: PHP 从 windows.php.net 官方下载,如果速度较慢:
- 可以手动从官网下载 ZIP 文件
- 解压到 `[安装目录]/php/php-版本号` 目录
- 重新打开应用即可识别
### Q: MySQL 启动失败?
A: 常见原因:
- 3306 端口被占用,检查是否有其他 MySQL 实例
- 防火墙阻止,添加防火墙规则
- 数据目录权限问题,确保目录可写
### Q: 如何卸载服务?
A: 进入对应服务管理页面,先停止服务,然后点击"卸载"按钮。
## 🔗 相关资源
- [PHP for Windows](https://windows.php.net/download/) - PHP Windows 官方下载
- [MySQL Downloads](https://dev.mysql.com/downloads/) - MySQL 官方下载
- [Nginx](https://nginx.org/) - Nginx 官方网站
- [Redis for Windows](https://github.com/redis-windows/redis-windows) - Windows 版 Redis
## 📄 开源协议
本项目基于 [MIT License](LICENSE) 开源。
## 🤝 贡献指南
欢迎提交 Issue 和 Pull Request
1. Fork 本仓库
2. 创建功能分支 (`git checkout -b feature/AmazingFeature`)
3. 提交更改 (`git commit -m 'Add some AmazingFeature'`)
4. 推送到分支 (`git push origin feature/AmazingFeature`)
5. 提交 Pull Request
## 📮 反馈建议
如果您在使用过程中遇到问题或有任何建议,欢迎:
- 提交 [Issue](https://github.com/your-username/phper/issues)
- 发送邮件至 your-email@example.com
---
<p align="center">
Made with ❤️ for PHP Developers
</p>
<p align="center">
⭐ 如果这个项目对您有帮助,请给我们一个 Star
</p>

13
build/installer.nsh Normal file
View File

@ -0,0 +1,13 @@
!macro customInit
; 设置默认安装目录为 D 盘
StrCpy $INSTDIR "D:\PHPer"
!macroend
!macro customInstall
; 安装完成后的自定义操作
!macroend
!macro customUnInstall
; 卸载时的自定义操作
!macroend

5
docs/.gitkeep Normal file
View File

@ -0,0 +1,5 @@
# 此目录用于存放文档截图
# 请将以下截图放入此目录:
# - dashboard.png 仪表盘截图
# - php-manager.png PHP管理页面截图

196
electron/main.ts Normal file
View File

@ -0,0 +1,196 @@
import { app, BrowserWindow, ipcMain, shell } from 'electron'
import { join } from 'path'
import { PhpManager } from './services/PhpManager'
import { MysqlManager } from './services/MysqlManager'
import { NginxManager } from './services/NginxManager'
import { RedisManager } from './services/RedisManager'
import { NodeManager } from './services/NodeManager'
import { ServiceManager } from './services/ServiceManager'
import { HostsManager } from './services/HostsManager'
import { ConfigStore } from './services/ConfigStore'
let mainWindow: BrowserWindow | null = null
// 发送下载进度到渲染进程
export function sendDownloadProgress(type: string, progress: number, downloaded: number, total: number) {
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents.send('download-progress', { type, progress, downloaded, total })
}
}
// 初始化各服务管理器
const configStore = new ConfigStore()
const phpManager = new PhpManager(configStore)
const mysqlManager = new MysqlManager(configStore)
const nginxManager = new NginxManager(configStore)
const redisManager = new RedisManager(configStore)
const nodeManager = new NodeManager(configStore)
const serviceManager = new ServiceManager(configStore)
const hostsManager = new HostsManager()
function createWindow() {
mainWindow = new BrowserWindow({
width: 1400,
height: 900,
minWidth: 1200,
minHeight: 700,
webPreferences: {
preload: join(__dirname, 'preload.js'),
contextIsolation: true,
nodeIntegration: false
},
titleBarStyle: 'hidden',
titleBarOverlay: {
color: '#1a1a2e',
symbolColor: '#ffffff',
height: 40
},
frame: false,
icon: join(__dirname, '../public/icon.ico')
})
// 开发环境加载 Vite 开发服务器
const VITE_DEV_SERVER_URL = process.env.VITE_DEV_SERVER_URL
if (VITE_DEV_SERVER_URL) {
mainWindow.loadURL(VITE_DEV_SERVER_URL)
mainWindow.webContents.openDevTools()
} else {
mainWindow.loadFile(join(__dirname, '../dist/index.html'))
}
mainWindow.on('closed', () => {
mainWindow = null
})
}
app.whenReady().then(() => {
createWindow()
app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) {
createWindow()
}
})
})
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') {
app.quit()
}
})
// ==================== IPC 处理程序 ====================
// 窗口控制
ipcMain.handle('window:minimize', () => mainWindow?.minimize())
ipcMain.handle('window:maximize', () => {
if (mainWindow?.isMaximized()) {
mainWindow.unmaximize()
} else {
mainWindow?.maximize()
}
})
ipcMain.handle('window:close', () => mainWindow?.close())
// 打开外部链接
ipcMain.handle('shell:openExternal', (_, url: string) => shell.openExternal(url))
ipcMain.handle('shell:openPath', (_, path: string) => shell.openPath(path))
// 选择文件夹对话框
ipcMain.handle('dialog:selectDirectory', async () => {
const { dialog } = await import('electron')
const result = await dialog.showOpenDialog(mainWindow!, {
properties: ['openDirectory'],
title: '选择目录'
})
return result.canceled ? null : result.filePaths[0]
})
// ==================== PHP 管理 ====================
ipcMain.handle('php:getVersions', () => phpManager.getInstalledVersions())
ipcMain.handle('php:getAvailableVersions', () => phpManager.getAvailableVersions())
ipcMain.handle('php:install', (_, version: string) => phpManager.install(version))
ipcMain.handle('php:uninstall', (_, version: string) => phpManager.uninstall(version))
ipcMain.handle('php:setActive', (_, version: string) => phpManager.setActive(version))
ipcMain.handle('php:getExtensions', (_, version: string) => phpManager.getExtensions(version))
ipcMain.handle('php:openExtensionDir', (_, version: string) => phpManager.openExtensionDir(version))
ipcMain.handle('php:getAvailableExtensions', (_, version: string, searchKeyword?: string) => phpManager.getAvailableExtensions(version, searchKeyword))
ipcMain.handle('php:enableExtension', (_, version: string, ext: string) => phpManager.enableExtension(version, ext))
ipcMain.handle('php:disableExtension', (_, version: string, ext: string) => phpManager.disableExtension(version, ext))
ipcMain.handle('php:installExtension', (_, version: string, ext: string, downloadUrl?: string, packageName?: string) => phpManager.installExtension(version, ext, downloadUrl, packageName))
ipcMain.handle('php:getConfig', (_, version: string) => phpManager.getConfig(version))
ipcMain.handle('php:saveConfig', (_, version: string, config: string) => phpManager.saveConfig(version, config))
// ==================== MySQL 管理 ====================
ipcMain.handle('mysql:getVersions', () => mysqlManager.getInstalledVersions())
ipcMain.handle('mysql:getAvailableVersions', () => mysqlManager.getAvailableVersions())
ipcMain.handle('mysql:install', (_, version: string) => mysqlManager.install(version))
ipcMain.handle('mysql:uninstall', (_, version: string) => mysqlManager.uninstall(version))
ipcMain.handle('mysql:start', (_, version: string) => mysqlManager.start(version))
ipcMain.handle('mysql:stop', (_, version: string) => mysqlManager.stop(version))
ipcMain.handle('mysql:restart', (_, version: string) => mysqlManager.restart(version))
ipcMain.handle('mysql:getStatus', (_, version: string) => mysqlManager.getStatus(version))
ipcMain.handle('mysql:changePassword', (_, version: string, newPassword: string, currentPassword?: string) => mysqlManager.changeRootPassword(version, newPassword, currentPassword))
ipcMain.handle('mysql:getConfig', (_, version: string) => mysqlManager.getConfig(version))
ipcMain.handle('mysql:saveConfig', (_, version: string, config: string) => mysqlManager.saveConfig(version, config))
ipcMain.handle('mysql:reinitialize', (_, version: string) => mysqlManager.reinitialize(version))
// ==================== Nginx 管理 ====================
ipcMain.handle('nginx:getVersions', () => nginxManager.getInstalledVersions())
ipcMain.handle('nginx:getAvailableVersions', () => nginxManager.getAvailableVersions())
ipcMain.handle('nginx:install', (_, version: string) => nginxManager.install(version))
ipcMain.handle('nginx:uninstall', (_, version: string) => nginxManager.uninstall(version))
ipcMain.handle('nginx:start', () => nginxManager.start())
ipcMain.handle('nginx:stop', () => nginxManager.stop())
ipcMain.handle('nginx:restart', () => nginxManager.restart())
ipcMain.handle('nginx:reload', () => nginxManager.reload())
ipcMain.handle('nginx:getStatus', () => nginxManager.getStatus())
ipcMain.handle('nginx:getConfig', () => nginxManager.getConfig())
ipcMain.handle('nginx:saveConfig', (_, config: string) => nginxManager.saveConfig(config))
ipcMain.handle('nginx:getSites', () => nginxManager.getSites())
ipcMain.handle('nginx:addSite', (_, site: any) => nginxManager.addSite(site))
ipcMain.handle('nginx:removeSite', (_, name: string) => nginxManager.removeSite(name))
ipcMain.handle('nginx:updateSite', (_, originalName: string, site: any) => nginxManager.updateSite(originalName, site))
ipcMain.handle('nginx:enableSite', (_, name: string) => nginxManager.enableSite(name))
ipcMain.handle('nginx:disableSite', (_, name: string) => nginxManager.disableSite(name))
ipcMain.handle('nginx:generateLaravelConfig', (_, site: any) => nginxManager.generateLaravelConfig(site))
ipcMain.handle('nginx:requestSSL', (_, domain: string, email: string) => nginxManager.requestSSLCertificate(domain, email))
// ==================== Redis 管理 ====================
ipcMain.handle('redis:getVersions', () => redisManager.getInstalledVersions())
ipcMain.handle('redis:getAvailableVersions', () => redisManager.getAvailableVersions())
ipcMain.handle('redis:install', (_, version: string) => redisManager.install(version))
ipcMain.handle('redis:uninstall', (_, version: string) => redisManager.uninstall(version))
ipcMain.handle('redis:start', () => redisManager.start())
ipcMain.handle('redis:stop', () => redisManager.stop())
ipcMain.handle('redis:restart', () => redisManager.restart())
ipcMain.handle('redis:getStatus', () => redisManager.getStatus())
ipcMain.handle('redis:getConfig', () => redisManager.getConfig())
ipcMain.handle('redis:saveConfig', (_, config: string) => redisManager.saveConfig(config))
// ==================== Node.js 管理 ====================
ipcMain.handle('node:getVersions', () => nodeManager.getInstalledVersions())
ipcMain.handle('node:getAvailableVersions', () => nodeManager.getAvailableVersions())
ipcMain.handle('node:install', (_, version: string, downloadUrl: string) => nodeManager.install(version, downloadUrl))
ipcMain.handle('node:uninstall', (_, version: string) => nodeManager.uninstall(version))
ipcMain.handle('node:setActive', (_, version: string) => nodeManager.setActive(version))
ipcMain.handle('node:getInfo', (_, version: string) => nodeManager.getNodeInfo(version))
// ==================== 服务管理 ====================
ipcMain.handle('service:getAll', () => serviceManager.getAllServices())
ipcMain.handle('service:setAutoStart', (_, service: string, enabled: boolean) => serviceManager.setAutoStart(service, enabled))
ipcMain.handle('service:getAutoStart', (_, service: string) => serviceManager.getAutoStart(service))
ipcMain.handle('service:startAll', () => serviceManager.startAll())
ipcMain.handle('service:stopAll', () => serviceManager.stopAll())
// ==================== Hosts 管理 ====================
ipcMain.handle('hosts:get', () => hostsManager.getHosts())
ipcMain.handle('hosts:add', (_, domain: string, ip: string) => hostsManager.addHost(domain, ip))
ipcMain.handle('hosts:remove', (_, domain: string) => hostsManager.removeHost(domain))
// ==================== 配置管理 ====================
ipcMain.handle('config:get', (_, key: string) => configStore.get(key))
ipcMain.handle('config:set', (_, key: string, value: any) => configStore.set(key, value))
ipcMain.handle('config:getBasePath', () => configStore.getBasePath())
ipcMain.handle('config:setBasePath', (_, path: string) => configStore.setBasePath(path))

150
electron/preload.ts Normal file
View File

@ -0,0 +1,150 @@
import { contextBridge, ipcRenderer } from 'electron'
// 暴露安全的 API 到渲染进程
contextBridge.exposeInMainWorld('electronAPI', {
// 窗口控制
minimize: () => ipcRenderer.invoke('window:minimize'),
maximize: () => ipcRenderer.invoke('window:maximize'),
close: () => ipcRenderer.invoke('window:close'),
// Shell
openExternal: (url: string) => ipcRenderer.invoke('shell:openExternal', url),
openPath: (path: string) => ipcRenderer.invoke('shell:openPath', path),
// Dialog
selectDirectory: () => ipcRenderer.invoke('dialog:selectDirectory'),
// 下载进度监听
onDownloadProgress: (callback: (data: { type: string; progress: number; downloaded: number; total: number }) => void) => {
ipcRenderer.on('download-progress', (_, data) => callback(data))
},
removeDownloadProgressListener: () => {
ipcRenderer.removeAllListeners('download-progress')
},
// PHP 管理
php: {
getVersions: () => ipcRenderer.invoke('php:getVersions'),
getAvailableVersions: () => ipcRenderer.invoke('php:getAvailableVersions'),
install: (version: string) => ipcRenderer.invoke('php:install', version),
uninstall: (version: string) => ipcRenderer.invoke('php:uninstall', version),
setActive: (version: string) => ipcRenderer.invoke('php:setActive', version),
getExtensions: (version: string) => ipcRenderer.invoke('php:getExtensions', version),
openExtensionDir: (version: string) => ipcRenderer.invoke('php:openExtensionDir', version),
getAvailableExtensions: (version: string, searchKeyword?: string) => ipcRenderer.invoke('php:getAvailableExtensions', version, searchKeyword),
enableExtension: (version: string, ext: string) => ipcRenderer.invoke('php:enableExtension', version, ext),
disableExtension: (version: string, ext: string) => ipcRenderer.invoke('php:disableExtension', version, ext),
installExtension: (version: string, ext: string, downloadUrl?: string, packageName?: string) => ipcRenderer.invoke('php:installExtension', version, ext, downloadUrl, packageName),
getConfig: (version: string) => ipcRenderer.invoke('php:getConfig', version),
saveConfig: (version: string, config: string) => ipcRenderer.invoke('php:saveConfig', version, config)
},
// MySQL 管理
mysql: {
getVersions: () => ipcRenderer.invoke('mysql:getVersions'),
getAvailableVersions: () => ipcRenderer.invoke('mysql:getAvailableVersions'),
install: (version: string) => ipcRenderer.invoke('mysql:install', version),
uninstall: (version: string) => ipcRenderer.invoke('mysql:uninstall', version),
start: (version: string) => ipcRenderer.invoke('mysql:start', version),
stop: (version: string) => ipcRenderer.invoke('mysql:stop', version),
restart: (version: string) => ipcRenderer.invoke('mysql:restart', version),
getStatus: (version: string) => ipcRenderer.invoke('mysql:getStatus', version),
changePassword: (version: string, newPassword: string, currentPassword?: string) => ipcRenderer.invoke('mysql:changePassword', version, newPassword, currentPassword),
getConfig: (version: string) => ipcRenderer.invoke('mysql:getConfig', version),
saveConfig: (version: string, config: string) => ipcRenderer.invoke('mysql:saveConfig', version, config)
},
// Nginx 管理
nginx: {
getVersions: () => ipcRenderer.invoke('nginx:getVersions'),
getAvailableVersions: () => ipcRenderer.invoke('nginx:getAvailableVersions'),
install: (version: string) => ipcRenderer.invoke('nginx:install', version),
uninstall: (version: string) => ipcRenderer.invoke('nginx:uninstall', version),
start: () => ipcRenderer.invoke('nginx:start'),
stop: () => ipcRenderer.invoke('nginx:stop'),
restart: () => ipcRenderer.invoke('nginx:restart'),
reload: () => ipcRenderer.invoke('nginx:reload'),
getStatus: () => ipcRenderer.invoke('nginx:getStatus'),
getConfig: () => ipcRenderer.invoke('nginx:getConfig'),
saveConfig: (config: string) => ipcRenderer.invoke('nginx:saveConfig', config),
getSites: () => ipcRenderer.invoke('nginx:getSites'),
addSite: (site: any) => ipcRenderer.invoke('nginx:addSite', site),
removeSite: (name: string) => ipcRenderer.invoke('nginx:removeSite', name),
updateSite: (originalName: string, site: any) => ipcRenderer.invoke('nginx:updateSite', originalName, site),
enableSite: (name: string) => ipcRenderer.invoke('nginx:enableSite', name),
disableSite: (name: string) => ipcRenderer.invoke('nginx:disableSite', name),
generateLaravelConfig: (site: any) => ipcRenderer.invoke('nginx:generateLaravelConfig', site),
requestSSL: (domain: string, email: string) => ipcRenderer.invoke('nginx:requestSSL', domain, email)
},
// Redis 管理
redis: {
getVersions: () => ipcRenderer.invoke('redis:getVersions'),
getAvailableVersions: () => ipcRenderer.invoke('redis:getAvailableVersions'),
install: (version: string) => ipcRenderer.invoke('redis:install', version),
uninstall: (version: string) => ipcRenderer.invoke('redis:uninstall', version),
start: () => ipcRenderer.invoke('redis:start'),
stop: () => ipcRenderer.invoke('redis:stop'),
restart: () => ipcRenderer.invoke('redis:restart'),
getStatus: () => ipcRenderer.invoke('redis:getStatus'),
getConfig: () => ipcRenderer.invoke('redis:getConfig'),
saveConfig: (config: string) => ipcRenderer.invoke('redis:saveConfig', config)
},
// Node.js 管理
node: {
getVersions: () => ipcRenderer.invoke('node:getVersions'),
getAvailableVersions: () => ipcRenderer.invoke('node:getAvailableVersions'),
install: (version: string, downloadUrl: string) => ipcRenderer.invoke('node:install', version, downloadUrl),
uninstall: (version: string) => ipcRenderer.invoke('node:uninstall', version),
setActive: (version: string) => ipcRenderer.invoke('node:setActive', version),
getInfo: (version: string) => ipcRenderer.invoke('node:getInfo', version)
},
// 服务管理
service: {
getAll: () => ipcRenderer.invoke('service:getAll'),
setAutoStart: (service: string, enabled: boolean) => ipcRenderer.invoke('service:setAutoStart', service, enabled),
getAutoStart: (service: string) => ipcRenderer.invoke('service:getAutoStart', service),
startAll: () => ipcRenderer.invoke('service:startAll'),
stopAll: () => ipcRenderer.invoke('service:stopAll')
},
// Hosts 管理
hosts: {
get: () => ipcRenderer.invoke('hosts:get'),
add: (domain: string, ip: string) => ipcRenderer.invoke('hosts:add', domain, ip),
remove: (domain: string) => ipcRenderer.invoke('hosts:remove', domain)
},
// 配置管理
config: {
get: (key: string) => ipcRenderer.invoke('config:get', key),
set: (key: string, value: any) => ipcRenderer.invoke('config:set', key, value),
getBasePath: () => ipcRenderer.invoke('config:getBasePath'),
setBasePath: (path: string) => ipcRenderer.invoke('config:setBasePath', path)
}
})
// 声明 Window 接口扩展
declare global {
interface Window {
electronAPI: typeof api
}
}
const api = {
minimize: () => ipcRenderer.invoke('window:minimize'),
maximize: () => ipcRenderer.invoke('window:maximize'),
close: () => ipcRenderer.invoke('window:close'),
openExternal: (url: string) => ipcRenderer.invoke('shell:openExternal', url),
openPath: (path: string) => ipcRenderer.invoke('shell:openPath', path),
php: {} as any,
mysql: {} as any,
nginx: {} as any,
redis: {} as any,
service: {} as any,
hosts: {} as any,
config: {} as any
}

View File

@ -0,0 +1,212 @@
import Store from 'electron-store'
import { join } from 'path'
import { existsSync, mkdirSync } from 'fs'
import { app } from 'electron'
interface ConfigSchema {
basePath: string
phpVersions: string[]
mysqlVersions: string[]
nginxVersions: string[]
redisVersions: string[]
activePhpVersion: string
autoStart: {
nginx: boolean
mysql: boolean
redis: boolean
}
sites: SiteConfig[]
}
export interface SiteConfig {
name: string
domain: string
rootPath: string
phpVersion: string
isLaravel: boolean
ssl: boolean
enabled: boolean
}
export class ConfigStore {
private store: Store<ConfigSchema>
private basePath: string
constructor() {
this.store = new Store<ConfigSchema>({
defaults: {
basePath: join(app.getPath('userData'), 'PHPer'),
phpVersions: [],
mysqlVersions: [],
nginxVersions: [],
redisVersions: [],
activePhpVersion: '',
autoStart: {
nginx: false,
mysql: false,
redis: false
},
sites: []
}
})
this.basePath = this.store.get('basePath')
this.ensureDirectories()
}
private ensureDirectories(): void {
const dirs = [
this.basePath,
join(this.basePath, 'php'),
join(this.basePath, 'mysql'),
join(this.basePath, 'nginx'),
join(this.basePath, 'nginx', 'sites-available'),
join(this.basePath, 'nginx', 'sites-enabled'),
join(this.basePath, 'nginx', 'ssl'),
join(this.basePath, 'redis'),
join(this.basePath, 'logs'),
join(this.basePath, 'temp'),
join(this.basePath, 'www')
]
for (const dir of dirs) {
if (!existsSync(dir)) {
mkdirSync(dir, { recursive: true })
}
}
}
get<K extends keyof ConfigSchema>(key: K): ConfigSchema[K] {
return this.store.get(key)
}
set<K extends keyof ConfigSchema>(key: K, value: ConfigSchema[K]): void {
this.store.set(key, value)
}
getBasePath(): string {
return this.basePath
}
setBasePath(path: string): void {
this.basePath = path
this.store.set('basePath', path)
this.ensureDirectories()
}
getPhpPath(version: string): string {
return join(this.basePath, 'php', `php-${version}`)
}
getMysqlPath(version: string): string {
return join(this.basePath, 'mysql', `mysql-${version}`)
}
getNginxPath(): string {
return join(this.basePath, 'nginx')
}
getRedisPath(): string {
return join(this.basePath, 'redis')
}
getNodePath(): string {
return join(this.basePath, 'nodejs')
}
getLogsPath(): string {
return join(this.basePath, 'logs')
}
getTempPath(): string {
return join(this.basePath, 'temp')
}
getWwwPath(): string {
return join(this.basePath, 'www')
}
getSitesAvailablePath(): string {
return join(this.basePath, 'nginx', 'sites-available')
}
getSitesEnabledPath(): string {
return join(this.basePath, 'nginx', 'sites-enabled')
}
getSSLPath(): string {
return join(this.basePath, 'nginx', 'ssl')
}
addPhpVersion(version: string): void {
const versions = this.store.get('phpVersions')
if (!versions.includes(version)) {
versions.push(version)
this.store.set('phpVersions', versions)
}
}
removePhpVersion(version: string): void {
const versions = this.store.get('phpVersions')
const index = versions.indexOf(version)
if (index > -1) {
versions.splice(index, 1)
this.store.set('phpVersions', versions)
}
}
addMysqlVersion(version: string): void {
const versions = this.store.get('mysqlVersions')
if (!versions.includes(version)) {
versions.push(version)
this.store.set('mysqlVersions', versions)
}
}
removeMysqlVersion(version: string): void {
const versions = this.store.get('mysqlVersions')
const index = versions.indexOf(version)
if (index > -1) {
versions.splice(index, 1)
this.store.set('mysqlVersions', versions)
}
}
addSite(site: SiteConfig): void {
const sites = this.store.get('sites')
sites.push(site)
this.store.set('sites', sites)
}
removeSite(name: string): void {
const sites = this.store.get('sites')
const index = sites.findIndex(s => s.name === name)
if (index > -1) {
sites.splice(index, 1)
this.store.set('sites', sites)
}
}
updateSite(originalName: string, site: any): void {
const sites = this.store.get('sites')
const index = sites.findIndex(s => s.name === originalName)
if (index > -1) {
sites[index] = site
this.store.set('sites', sites)
}
}
updateSite(name: string, site: Partial<SiteConfig>): void {
const sites = this.store.get('sites')
const index = sites.findIndex(s => s.name === name)
if (index > -1) {
sites[index] = { ...sites[index], ...site }
this.store.set('sites', sites)
}
}
getSites(): SiteConfig[] {
return this.store.get('sites')
}
}

View File

@ -0,0 +1,224 @@
import { exec } from 'child_process'
import { promisify } from 'util'
import { existsSync, readFileSync, writeFileSync, unlinkSync } from 'fs'
import { join } from 'path'
import sudo from 'sudo-prompt'
const execAsync = promisify(exec)
const sudoExec = (command: string, name: string): Promise<{ stdout: string; stderr: string }> => {
return new Promise((resolve, reject) => {
sudo.exec(command, { name }, (error, stdout, stderr) => {
if (error) {
reject(error)
} else {
resolve({ stdout: stdout?.toString() || '', stderr: stderr?.toString() || '' })
}
})
})
}
interface HostEntry {
ip: string
domain: string
comment?: string
}
export class HostsManager {
private hostsPath: string
constructor() {
// Windows hosts 文件路径
this.hostsPath = join(process.env.SystemRoot || 'C:\\Windows', 'System32', 'drivers', 'etc', 'hosts')
}
/**
* hosts
*/
async getHosts(): Promise<HostEntry[]> {
try {
if (!existsSync(this.hostsPath)) {
return []
}
const content = readFileSync(this.hostsPath, 'utf-8')
const entries: HostEntry[] = []
const lines = content.split('\n')
for (const line of lines) {
const trimmed = line.trim()
// 跳过空行和注释
if (!trimmed || trimmed.startsWith('#')) {
continue
}
// 解析行
const match = trimmed.match(/^(\S+)\s+(\S+)(?:\s+#\s*(.*))?$/)
if (match) {
entries.push({
ip: match[1],
domain: match[2],
comment: match[3]
})
}
}
return entries
} catch (error) {
console.error('读取 hosts 文件失败:', error)
return []
}
}
/**
* hosts
*/
async addHost(domain: string, ip: string = '127.0.0.1'): Promise<{ success: boolean; message: string }> {
try {
// 读取现有内容
let content = ''
if (existsSync(this.hostsPath)) {
content = readFileSync(this.hostsPath, 'utf-8')
}
// 检查是否已存在
const regex = new RegExp(`^\\s*\\S+\\s+${this.escapeRegex(domain)}\\s*$`, 'gm')
if (regex.test(content)) {
// 更新现有条目
content = content.replace(regex, `${ip}\t${domain}`)
} else {
// 添加新条目
const newEntry = `${ip}\t${domain}\t# Added by PHPer Dev Manager`
content = content.trimEnd() + '\n' + newEntry + '\n'
}
// 写入文件(需要管理员权限)
await this.writeHostsFile(content)
return { success: true, message: `已添加 ${domain} -> ${ip}` }
} catch (error: any) {
return { success: false, message: `添加失败: ${error.message}` }
}
}
/**
* hosts
*/
async removeHost(domain: string): Promise<{ success: boolean; message: string }> {
try {
if (!existsSync(this.hostsPath)) {
return { success: false, message: 'hosts 文件不存在' }
}
let content = readFileSync(this.hostsPath, 'utf-8')
// 删除匹配的行
const lines = content.split('\n')
const newLines = lines.filter(line => {
const trimmed = line.trim()
if (!trimmed || trimmed.startsWith('#')) {
return true
}
const match = trimmed.match(/^\S+\s+(\S+)/)
return !match || match[1] !== domain
})
content = newLines.join('\n')
// 写入文件
await this.writeHostsFile(content)
return { success: true, message: `已删除 ${domain}` }
} catch (error: any) {
return { success: false, message: `删除失败: ${error.message}` }
}
}
/**
* hosts
*/
async addHosts(entries: HostEntry[]): Promise<{ success: boolean; message: string }> {
try {
let content = ''
if (existsSync(this.hostsPath)) {
content = readFileSync(this.hostsPath, 'utf-8')
}
for (const entry of entries) {
const regex = new RegExp(`^\\s*\\S+\\s+${this.escapeRegex(entry.domain)}\\s*(?:#.*)?$`, 'gm')
if (regex.test(content)) {
// 更新现有条目
content = content.replace(regex, `${entry.ip}\t${entry.domain}${entry.comment ? `\t# ${entry.comment}` : ''}`)
} else {
// 添加新条目
const newEntry = `${entry.ip}\t${entry.domain}${entry.comment ? `\t# ${entry.comment}` : ''}`
content = content.trimEnd() + '\n' + newEntry
}
}
content = content.trimEnd() + '\n'
await this.writeHostsFile(content)
return { success: true, message: `已添加 ${entries.length} 个条目` }
} catch (error: any) {
return { success: false, message: `添加失败: ${error.message}` }
}
}
/**
* DNS
*/
async flushDns(): Promise<{ success: boolean; message: string }> {
try {
await execAsync('ipconfig /flushdns')
return { success: true, message: 'DNS 缓存已刷新' }
} catch (error: any) {
return { success: false, message: `刷新失败: ${error.message}` }
}
}
// ==================== 私有方法 ====================
private async writeHostsFile(content: string): Promise<void> {
// 直接写入(需要管理员权限运行应用)
try {
writeFileSync(this.hostsPath, content, 'utf-8')
} catch (error: any) {
if (error.code === 'EPERM' || error.code === 'EACCES') {
// 尝试使用 sudo-prompt 提权写入
const tempPath = join(process.env.TEMP || 'C:\\Temp', 'hosts_phper.tmp')
writeFileSync(tempPath, content, 'utf-8')
// 使用 copy 命令复制临时文件到 hosts
const command = `copy /Y "${tempPath}" "${this.hostsPath}"`
try {
await sudoExec(command, 'PHPer Dev Manager')
// 清理临时文件
try {
unlinkSync(tempPath)
} catch (e) {
// 忽略清理错误
}
} catch (sudoError: any) {
// 清理临时文件
try {
unlinkSync(tempPath)
} catch (e) {
// 忽略清理错误
}
throw new Error(`需要管理员权限修改 hosts 文件: ${sudoError.message}`)
}
} else {
throw error
}
}
}
private escapeRegex(str: string): string {
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
}
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,848 @@
import { ConfigStore, SiteConfig } from './ConfigStore'
import { exec, spawn } from 'child_process'
import { promisify } from 'util'
import { existsSync, readFileSync, writeFileSync, readdirSync, unlinkSync, rmdirSync, mkdirSync, copyFileSync } 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 NginxVersion {
version: string
path: string
}
interface AvailableNginxVersion {
version: string
downloadUrl: string
}
interface NginxStatus {
running: boolean
pid?: number
activeConnections?: number
}
export class NginxManager {
private configStore: ConfigStore
constructor(configStore: ConfigStore) {
this.configStore = configStore
}
/**
* Nginx
*/
async getInstalledVersions(): Promise<NginxVersion[]> {
const versions: NginxVersion[] = []
const nginxDir = this.configStore.getNginxPath()
if (!existsSync(nginxDir)) {
return versions
}
// 检查是否存在 nginx.exe
if (existsSync(join(nginxDir, 'nginx.exe'))) {
// 获取版本号
try {
const { stdout } = await execAsync(`"${join(nginxDir, 'nginx.exe')}" -v 2>&1`)
const match = stdout.match(/nginx\/(\d+\.\d+\.\d+)/)
if (match) {
versions.push({
version: match[1],
path: nginxDir
})
}
} catch (error: any) {
// nginx -v 输出到 stderr
const match = error.message?.match(/nginx\/(\d+\.\d+\.\d+)/) ||
error.stderr?.match(/nginx\/(\d+\.\d+\.\d+)/)
if (match) {
versions.push({
version: match[1],
path: nginxDir
})
}
}
}
return versions
}
/**
* Nginx
*/
async getAvailableVersions(): Promise<AvailableNginxVersion[]> {
const versions: AvailableNginxVersion[] = [
{
version: '1.27.3',
downloadUrl: 'https://nginx.org/download/nginx-1.27.3.zip'
},
{
version: '1.26.2',
downloadUrl: 'https://nginx.org/download/nginx-1.26.2.zip'
},
{
version: '1.25.5',
downloadUrl: 'https://nginx.org/download/nginx-1.25.5.zip'
},
{
version: '1.24.0',
downloadUrl: 'https://nginx.org/download/nginx-1.24.0.zip'
}
]
return versions
}
/**
* Nginx
*/
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: `未找到 Nginx ${version} 版本` }
}
const nginxPath = this.configStore.getNginxPath()
const tempPath = this.configStore.getTempPath()
const zipPath = join(tempPath, `nginx-${version}.zip`)
// 如果已有 Nginx 安装,先备份配置
let oldConfig = ''
const configPath = join(nginxPath, 'conf', 'nginx.conf')
if (existsSync(configPath)) {
oldConfig = readFileSync(configPath, 'utf-8')
}
// 下载 Nginx
await this.downloadFile(versionInfo.downloadUrl, zipPath)
// 解压
const basePath = this.configStore.getBasePath()
await this.unzip(zipPath, basePath)
// 重命名目录
const extractedDir = join(basePath, `nginx-${version}`)
if (existsSync(extractedDir) && extractedDir !== nginxPath) {
// 如果目标目录已存在,先删除
if (existsSync(nginxPath)) {
this.removeDirectory(nginxPath)
}
const { rename } = await import('fs/promises')
await rename(extractedDir, nginxPath)
}
// 删除临时文件
if (existsSync(zipPath)) {
unlinkSync(zipPath)
}
// 创建必要的目录
const sitesAvailable = this.configStore.getSitesAvailablePath()
const sitesEnabled = this.configStore.getSitesEnabledPath()
const sslPath = this.configStore.getSSLPath()
if (!existsSync(sitesAvailable)) mkdirSync(sitesAvailable, { recursive: true })
if (!existsSync(sitesEnabled)) mkdirSync(sitesEnabled, { recursive: true })
if (!existsSync(sslPath)) mkdirSync(sslPath, { recursive: true })
// 恢复或创建配置
if (oldConfig) {
writeFileSync(configPath, oldConfig)
} else {
await this.createDefaultConfig()
}
return { success: true, message: `Nginx ${version} 安装成功` }
} catch (error: any) {
return { success: false, message: `安装失败: ${error.message}` }
}
}
/**
* Nginx
*/
async uninstall(version: string): Promise<{ success: boolean; message: string }> {
try {
// 先停止服务
await this.stop()
const nginxPath = this.configStore.getNginxPath()
if (!existsSync(nginxPath)) {
return { success: false, message: 'Nginx 未安装' }
}
// 递归删除目录(保留 sites 和 ssl 目录)
const items = readdirSync(nginxPath, { withFileTypes: true })
for (const item of items) {
const itemPath = join(nginxPath, item.name)
if (item.name !== 'sites-available' && item.name !== 'sites-enabled' && item.name !== 'ssl') {
if (item.isDirectory()) {
this.removeDirectory(itemPath)
} else {
unlinkSync(itemPath)
}
}
}
return { success: true, message: 'Nginx 已卸载' }
} catch (error: any) {
return { success: false, message: `卸载失败: ${error.message}` }
}
}
/**
* Nginx
*/
async start(): Promise<{ success: boolean; message: string }> {
try {
const nginxPath = this.configStore.getNginxPath()
const nginxExe = join(nginxPath, 'nginx.exe')
if (!existsSync(nginxExe)) {
return { success: false, message: 'Nginx 未安装' }
}
// 检查是否已在运行
const status = await this.getStatus()
if (status.running) {
return { success: true, message: 'Nginx 已经在运行' }
}
// 启动 Nginx
const child = spawn(nginxExe, [], {
cwd: nginxPath,
detached: true,
stdio: 'ignore',
windowsHide: true
})
child.unref()
// 等待启动
await new Promise(resolve => setTimeout(resolve, 1000))
const newStatus = await this.getStatus()
if (newStatus.running) {
return { success: true, message: 'Nginx 启动成功' }
} else {
return { success: false, message: 'Nginx 启动失败,请检查配置' }
}
} catch (error: any) {
return { success: false, message: `启动失败: ${error.message}` }
}
}
/**
* Nginx
*/
async stop(): Promise<{ success: boolean; message: string }> {
try {
const nginxPath = this.configStore.getNginxPath()
const nginxExe = join(nginxPath, 'nginx.exe')
if (existsSync(nginxExe)) {
try {
await execAsync(`"${nginxExe}" -s stop`, { cwd: nginxPath, timeout: 10000 })
} catch (e) {
// 如果 -s stop 失败,尝试强制结束
try {
await execAsync('taskkill /F /IM nginx.exe', { timeout: 5000 })
} catch (e2) {
// 进程可能不存在
}
}
}
await new Promise(resolve => setTimeout(resolve, 1000))
const status = await this.getStatus()
if (!status.running) {
return { success: true, message: 'Nginx 已停止' }
} else {
return { success: false, message: 'Nginx 停止失败' }
}
} catch (error: any) {
return { success: false, message: `停止失败: ${error.message}` }
}
}
/**
* Nginx
*/
async restart(): Promise<{ success: boolean; message: string }> {
await this.stop()
await new Promise(resolve => setTimeout(resolve, 500))
return await this.start()
}
/**
*
*/
async reload(): Promise<{ success: boolean; message: string }> {
try {
const nginxPath = this.configStore.getNginxPath()
const nginxExe = join(nginxPath, 'nginx.exe')
if (!existsSync(nginxExe)) {
return { success: false, message: 'Nginx 未安装' }
}
await execAsync(`"${nginxExe}" -s reload`, { cwd: nginxPath })
return { success: true, message: '配置已重载' }
} catch (error: any) {
return { success: false, message: `重载失败: ${error.message}` }
}
}
/**
* Nginx
*/
async getStatus(): Promise<NginxStatus> {
try {
const { stdout } = await execAsync('tasklist /FI "IMAGENAME eq nginx.exe" /FO CSV /NH')
const lines = stdout.trim().split('\n')
if (lines.length > 0 && lines[0].includes('nginx.exe')) {
const parts = lines[0].split(',')
const pid = parseInt(parts[1].replace(/"/g, ''))
return { running: true, pid }
}
} catch (e) {
// 忽略错误
}
return { running: false }
}
/**
* nginx.conf
*/
async getConfig(): Promise<string> {
const configPath = join(this.configStore.getNginxPath(), 'conf', 'nginx.conf')
if (!existsSync(configPath)) {
return ''
}
return readFileSync(configPath, 'utf-8')
}
/**
* nginx.conf
*/
async saveConfig(config: string): Promise<{ success: boolean; message: string }> {
try {
const configPath = join(this.configStore.getNginxPath(), 'conf', 'nginx.conf')
// 先测试配置
const tempPath = join(this.configStore.getTempPath(), 'nginx-test.conf')
writeFileSync(tempPath, config)
const nginxExe = join(this.configStore.getNginxPath(), 'nginx.exe')
try {
await execAsync(`"${nginxExe}" -t -c "${tempPath}"`, { cwd: this.configStore.getNginxPath() })
} catch (testError: any) {
unlinkSync(tempPath)
return { success: false, message: `配置验证失败: ${testError.stderr || testError.message}` }
}
unlinkSync(tempPath)
writeFileSync(configPath, config)
return { success: true, message: 'nginx.conf 保存成功' }
} catch (error: any) {
return { success: false, message: `保存失败: ${error.message}` }
}
}
/**
*
*/
async getSites(): Promise<SiteConfig[]> {
return this.configStore.getSites()
}
/**
*
*/
async addSite(site: SiteConfig): Promise<{ success: boolean; message: string }> {
try {
// 生成配置文件
const config = site.isLaravel
? this.generateLaravelSiteConfig(site)
: this.generateSiteConfig(site)
const sitesAvailable = this.configStore.getSitesAvailablePath()
const configPath = join(sitesAvailable, `${site.name}.conf`)
writeFileSync(configPath, config)
// 启用站点
if (site.enabled) {
await this.enableSite(site.name)
}
// 保存到配置
this.configStore.addSite(site)
return { success: true, message: `站点 ${site.name} 创建成功` }
} catch (error: any) {
return { success: false, message: `创建站点失败: ${error.message}` }
}
}
/**
*
*/
async removeSite(name: string): Promise<{ success: boolean; message: string }> {
try {
const sitesAvailable = this.configStore.getSitesAvailablePath()
const sitesEnabled = this.configStore.getSitesEnabledPath()
const availablePath = join(sitesAvailable, `${name}.conf`)
const enabledPath = join(sitesEnabled, `${name}.conf`)
if (existsSync(enabledPath)) unlinkSync(enabledPath)
if (existsSync(availablePath)) unlinkSync(availablePath)
this.configStore.removeSite(name)
return { success: true, message: `站点 ${name} 已删除` }
} catch (error: any) {
return { success: false, message: `删除站点失败: ${error.message}` }
}
}
/**
*
*/
async updateSite(originalName: string, site: SiteConfig): Promise<{ success: boolean; message: string }> {
try {
const sitesAvailable = this.configStore.getSitesAvailablePath()
const sitesEnabled = this.configStore.getSitesEnabledPath()
// 如果站点名称没变,直接更新配置文件
const configPath = join(sitesAvailable, `${originalName}.conf`)
const enabledPath = join(sitesEnabled, `${originalName}.conf`)
// 检查是否之前是启用状态
const wasEnabled = existsSync(enabledPath)
// 生成新的配置内容
const config = site.isLaravel
? this.generateLaravelSiteConfig(site)
: this.generateSiteConfig(site)
// 写入配置文件
writeFileSync(configPath, config)
// 如果之前是启用状态,更新启用的配置
if (wasEnabled) {
writeFileSync(enabledPath, config)
}
// 更新存储的配置
this.configStore.updateSite(originalName, site)
return { success: true, message: `站点 ${site.name} 更新成功` }
} catch (error: any) {
return { success: false, message: `更新站点失败: ${error.message}` }
}
}
/**
*
*/
async enableSite(name: string): Promise<{ success: boolean; message: string }> {
try {
const sitesAvailable = this.configStore.getSitesAvailablePath()
const sitesEnabled = this.configStore.getSitesEnabledPath()
const availablePath = join(sitesAvailable, `${name}.conf`)
const enabledPath = join(sitesEnabled, `${name}.conf`)
if (!existsSync(availablePath)) {
return { success: false, message: `站点配置 ${name} 不存在` }
}
// 复制配置到 enabled 目录
copyFileSync(availablePath, enabledPath)
this.configStore.updateSite(name, { enabled: true })
return { success: true, message: `站点 ${name} 已启用` }
} catch (error: any) {
return { success: false, message: `启用站点失败: ${error.message}` }
}
}
/**
*
*/
async disableSite(name: string): Promise<{ success: boolean; message: string }> {
try {
const sitesEnabled = this.configStore.getSitesEnabledPath()
const enabledPath = join(sitesEnabled, `${name}.conf`)
if (existsSync(enabledPath)) {
unlinkSync(enabledPath)
}
this.configStore.updateSite(name, { enabled: false })
return { success: true, message: `站点 ${name} 已禁用` }
} catch (error: any) {
return { success: false, message: `禁用站点失败: ${error.message}` }
}
}
/**
* Laravel
*/
async generateLaravelConfig(site: SiteConfig): Promise<string> {
return this.generateLaravelSiteConfig(site)
}
/**
* SSL Let's Encrypt
*/
async requestSSLCertificate(domain: string, email: string): Promise<{ success: boolean; message: string }> {
try {
// 检查是否安装了 win-acme
const acmePath = join(this.configStore.getBasePath(), 'tools', 'win-acme')
const wacs = join(acmePath, 'wacs.exe')
if (!existsSync(wacs)) {
return {
success: false,
message: '请先下载 win-acme 工具到 tools/win-acme 目录。下载地址: https://www.win-acme.com/'
}
}
const sslPath = this.configStore.getSSLPath()
const certPath = join(sslPath, domain)
if (!existsSync(certPath)) {
mkdirSync(certPath, { recursive: true })
}
// 使用 win-acme 申请证书
const command = `"${wacs}" --target manual --host ${domain} --validation selfhosting --emailaddress ${email} --accepttos --store pemfiles --pemfilespath "${certPath}"`
await execAsync(command, { timeout: 120000 })
return { success: true, message: `SSL 证书已申请成功,保存在 ${certPath}` }
} catch (error: any) {
return { success: false, message: `申请 SSL 证书失败: ${error.message}` }
}
}
// ==================== 私有方法 ====================
private async createDefaultConfig(): Promise<void> {
const nginxPath = this.configStore.getNginxPath()
const configPath = join(nginxPath, 'conf', 'nginx.conf')
const logsPath = this.configStore.getLogsPath()
const sitesEnabled = this.configStore.getSitesEnabledPath()
const config = `
worker_processes auto;
error_log "${logsPath.replace(/\\/g, '/')}/nginx-error.log";
pid "${nginxPath.replace(/\\/g, '/')}/nginx.pid";
events {
worker_connections 1024;
}
http {
include mime.types;
default_type application/octet-stream;
log_format main '$remote_addr - $remote_user [$time_local] "$request" '
'$status $body_bytes_sent "$http_referer" '
'"$http_user_agent" "$http_x_forwarded_for"';
access_log "${logsPath.replace(/\\/g, '/')}/nginx-access.log" main;
sendfile on;
keepalive_timeout 65;
# Gzip
gzip on;
gzip_vary on;
gzip_proxied any;
gzip_comp_level 6;
gzip_types text/plain text/css text/xml application/json application/javascript application/rss+xml application/atom+xml image/svg+xml;
#
client_max_body_size 100M;
#
include "${sitesEnabled.replace(/\\/g, '/')}/*.conf";
#
server {
listen 80;
server_name localhost;
location / {
root html;
index index.html index.htm index.php;
}
error_page 500 502 503 504 /50x.html;
location = /50x.html {
root html;
}
}
}
`
writeFileSync(configPath, config)
}
private generateSiteConfig(site: SiteConfig): string {
const phpPath = this.configStore.getPhpPath(site.phpVersion)
const logsPath = this.configStore.getLogsPath()
let config = `
server {
listen 80;
server_name ${site.domain};
root "${site.rootPath.replace(/\\/g, '/')}";
index index.php index.html index.htm;
access_log "${logsPath.replace(/\\/g, '/')}/${site.name}-access.log";
error_log "${logsPath.replace(/\\/g, '/')}/${site.name}-error.log";
location / {
try_files $uri $uri/ /index.php?$query_string;
}
location ~ \\.php$ {
fastcgi_pass 127.0.0.1:9000;
fastcgi_index index.php;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
include fastcgi_params;
}
location ~ /\\.(?!well-known).* {
deny all;
}
}
`
if (site.ssl) {
const sslPath = join(this.configStore.getSSLPath(), site.domain)
config += `
server {
listen 443 ssl http2;
server_name ${site.domain};
root "${site.rootPath.replace(/\\/g, '/')}";
index index.php index.html index.htm;
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 / {
try_files $uri $uri/ /index.php?$query_string;
}
location ~ \\.php$ {
fastcgi_pass 127.0.0.1:9000;
fastcgi_index index.php;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
include fastcgi_params;
}
location ~ /\\.(?!well-known).* {
deny all;
}
}
`
}
return config
}
private generateLaravelSiteConfig(site: SiteConfig): string {
const logsPath = this.configStore.getLogsPath()
// Laravel 项目 public 目录
const publicPath = join(site.rootPath, 'public').replace(/\\/g, '/')
let config = `
server {
listen 80;
server_name ${site.domain};
root "${publicPath}";
index index.php;
access_log "${logsPath.replace(/\\/g, '/')}/${site.name}-access.log";
error_log "${logsPath.replace(/\\/g, '/')}/${site.name}-error.log";
add_header X-Frame-Options "SAMEORIGIN";
add_header X-Content-Type-Options "nosniff";
charset utf-8;
location / {
try_files $uri $uri/ /index.php?$query_string;
}
location = /favicon.ico { access_log off; log_not_found off; }
location = /robots.txt { access_log off; log_not_found off; }
error_page 404 /index.php;
location ~ \\.php$ {
fastcgi_pass 127.0.0.1:9000;
fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
include fastcgi_params;
}
location ~ /\\.(?!well-known).* {
deny all;
}
}
`
if (site.ssl) {
const sslPath = join(this.configStore.getSSLPath(), site.domain)
config += `
server {
listen 443 ssl http2;
server_name ${site.domain};
root "${publicPath}";
index index.php;
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;
ssl_prefer_server_ciphers off;
access_log "${logsPath.replace(/\\/g, '/')}/${site.name}-ssl-access.log";
error_log "${logsPath.replace(/\\/g, '/')}/${site.name}-ssl-error.log";
add_header X-Frame-Options "SAMEORIGIN";
add_header X-Content-Type-Options "nosniff";
charset utf-8;
location / {
try_files $uri $uri/ /index.php?$query_string;
}
location = /favicon.ico { access_log off; log_not_found off; }
location = /robots.txt { access_log off; log_not_found off; }
error_page 404 /index.php;
location ~ \\.php$ {
fastcgi_pass 127.0.0.1:9000;
fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
include fastcgi_params;
}
location ~ /\\.(?!well-known).* {
deny all;
}
}
`
}
return config
}
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, (response) => {
if (response.statusCode === 301 || response.statusCode === 302) {
const redirectUrl = response.headers.location
if (redirectUrl) {
file.close()
unlinkSync(dest)
this.downloadFile(redirectUrl, dest).then(resolve).catch(reject)
return
}
}
if (response.statusCode !== 200) {
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('nginx', progress, downloadedSize, totalSize)
lastProgressTime = now
}
})
response.pipe(file)
file.on('finish', () => {
file.close()
sendDownloadProgress('nginx', 100, totalSize, totalSize)
resolve()
})
})
request.on('error', (err) => {
file.close()
if (existsSync(dest)) unlinkSync(dest)
reject(err)
})
})
}
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 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

@ -0,0 +1,460 @@
import { ConfigStore } from './ConfigStore'
import { exec } from 'child_process'
import { promisify } from 'util'
import { existsSync, mkdirSync, readdirSync, rmSync, readFileSync, writeFileSync, unlinkSync } from 'fs'
import { join } from 'path'
import https from 'https'
import http from 'http'
import { createWriteStream } from 'fs'
import unzipper from 'unzipper'
import { sendDownloadProgress } from '../main'
const execAsync = promisify(exec)
interface NodeVersion {
version: string
path: string
isActive: boolean
npmVersion?: string
}
interface AvailableNodeVersion {
version: string
date: string
lts: string | false
security: boolean
downloadUrl: string
}
export class NodeManager {
private configStore: ConfigStore
private versionsCache: AvailableNodeVersion[] = []
private cacheTime: number = 0
private readonly CACHE_DURATION = 5 * 60 * 1000 // 5 分钟缓存
constructor(configStore: ConfigStore) {
this.configStore = configStore
}
/**
* Node.js
*/
async getInstalledVersions(): Promise<NodeVersion[]> {
const versions: NodeVersion[] = []
const nodePath = this.configStore.getNodePath()
if (!existsSync(nodePath)) {
return versions
}
const dirs = readdirSync(nodePath, { withFileTypes: true })
const activeVersion = this.configStore.get('activeNodeVersion') || ''
for (const dir of dirs) {
if (dir.isDirectory() && dir.name.startsWith('node-')) {
const versionDir = join(nodePath, dir.name)
const nodeExe = join(versionDir, 'node.exe')
if (existsSync(nodeExe)) {
const version = dir.name.replace('node-', '').replace('-win-x64', '')
let npmVersion = ''
// 尝试获取 npm 版本
try {
const npmPath = join(versionDir, 'npm.cmd')
if (existsSync(npmPath)) {
const { stdout } = await execAsync(`"${npmPath}" --version`, { timeout: 5000 })
npmVersion = stdout.trim()
}
} catch (e) {
// 忽略错误
}
versions.push({
version,
path: versionDir,
isActive: version === activeVersion,
npmVersion
})
}
}
}
// 按版本号排序(降序)
versions.sort((a, b) => {
const aParts = a.version.replace('v', '').split('.').map(Number)
const bParts = b.version.replace('v', '').split('.').map(Number)
for (let i = 0; i < 3; i++) {
if (aParts[i] !== bParts[i]) {
return bParts[i] - aParts[i]
}
}
return 0
})
return versions
}
/**
* Node.js
*/
async getAvailableVersions(): Promise<AvailableNodeVersion[]> {
// 检查缓存
if (this.versionsCache.length > 0 && Date.now() - this.cacheTime < this.CACHE_DURATION) {
return this.versionsCache
}
try {
const versions = await this.fetchVersionsFromNodejs()
if (versions.length > 0) {
this.versionsCache = versions
this.cacheTime = Date.now()
return versions
}
} catch (error) {
console.error('获取 Node.js 版本列表失败:', error)
}
// 返回硬编码的版本列表作为后备
return this.getFallbackVersions()
}
/**
* Node.js
*/
private async fetchVersionsFromNodejs(): Promise<AvailableNodeVersion[]> {
return new Promise((resolve, reject) => {
const url = 'https://nodejs.org/dist/index.json'
https.get(url, {
headers: {
'User-Agent': 'PHPer-Dev-Manager/1.0'
},
timeout: 30000
}, (res) => {
if (res.statusCode === 301 || res.statusCode === 302) {
const redirectUrl = res.headers.location
if (redirectUrl) {
https.get(redirectUrl, (redirectRes) => {
this.handleVersionResponse(redirectRes, resolve, reject)
}).on('error', reject)
return
}
}
this.handleVersionResponse(res, resolve, reject)
}).on('error', reject)
.on('timeout', () => reject(new Error('请求超时')))
})
}
private handleVersionResponse(res: http.IncomingMessage, resolve: Function, reject: Function) {
let data = ''
res.on('data', chunk => data += chunk)
res.on('end', () => {
try {
const versions = JSON.parse(data)
const availableVersions: AvailableNodeVersion[] = []
for (const v of versions) {
// 只获取有 Windows 64位版本的
if (v.files && v.files.includes('win-x64-zip')) {
availableVersions.push({
version: v.version,
date: v.date,
lts: v.lts,
security: v.security,
downloadUrl: `https://nodejs.org/dist/${v.version}/node-${v.version}-win-x64.zip`
})
}
}
// 只返回前 30 个版本
resolve(availableVersions.slice(0, 30))
} catch (e) {
reject(e)
}
})
res.on('error', reject)
}
/**
* Node.js
*/
async install(version: string, downloadUrl: string): Promise<{ success: boolean; message: string }> {
try {
const nodePath = this.configStore.getNodePath()
const tempPath = this.configStore.getTempPath()
const zipPath = join(tempPath, `node-${version}.zip`)
const extractDir = join(nodePath, `node-${version}-win-x64`)
// 确保目录存在
if (!existsSync(nodePath)) {
mkdirSync(nodePath, { recursive: true })
}
if (!existsSync(tempPath)) {
mkdirSync(tempPath, { recursive: true })
}
// 检查是否已安装
if (existsSync(extractDir) && existsSync(join(extractDir, 'node.exe'))) {
return { success: false, message: `Node.js ${version} 已安装` }
}
// 下载
console.log(`开始下载 Node.js ${version}...`)
await this.downloadFile(downloadUrl, zipPath, `node-${version}`)
// 解压
console.log(`开始解压 Node.js ${version}...`)
await this.extractZip(zipPath, nodePath)
// 清理下载文件
try {
unlinkSync(zipPath)
} catch (e) {
// 忽略清理错误
}
// 验证安装
if (!existsSync(join(extractDir, 'node.exe'))) {
return { success: false, message: '安装失败node.exe 不存在' }
}
// 更新配置
const nodeVersions = this.configStore.get('nodeVersions') || []
if (!nodeVersions.includes(version)) {
nodeVersions.push(version)
this.configStore.set('nodeVersions', nodeVersions)
}
// 如果是第一个版本,设为默认
if (nodeVersions.length === 1) {
await this.setActive(version)
}
return { success: true, message: `Node.js ${version} 安装成功` }
} catch (error: any) {
return { success: false, message: `安装失败: ${error.message}` }
}
}
/**
* Node.js
*/
async uninstall(version: string): Promise<{ success: boolean; message: string }> {
try {
const nodePath = this.configStore.getNodePath()
const versionDir = join(nodePath, `node-${version}-win-x64`)
if (!existsSync(versionDir)) {
return { success: false, message: `Node.js ${version} 未安装` }
}
// 如果是当前激活的版本,先取消激活
const activeVersion = this.configStore.get('activeNodeVersion')
if (activeVersion === version) {
this.configStore.set('activeNodeVersion', '')
}
// 删除目录
rmSync(versionDir, { recursive: true, force: true })
// 更新配置
const nodeVersions = this.configStore.get('nodeVersions') || []
const index = nodeVersions.indexOf(version)
if (index > -1) {
nodeVersions.splice(index, 1)
this.configStore.set('nodeVersions', nodeVersions)
}
return { success: true, message: `Node.js ${version} 已卸载` }
} catch (error: any) {
return { success: false, message: `卸载失败: ${error.message}` }
}
}
/**
* Node.js
*/
async setActive(version: string): Promise<{ success: boolean; message: string }> {
try {
const nodePath = this.configStore.getNodePath()
const versionDir = join(nodePath, `node-${version}-win-x64`)
if (!existsSync(join(versionDir, 'node.exe'))) {
return { success: false, message: `Node.js ${version} 未安装` }
}
// 添加到 PATH
await this.addToPath(versionDir)
// 更新配置
this.configStore.set('activeNodeVersion', version)
return { success: true, message: `已将 Node.js ${version} 设为默认版本` }
} catch (error: any) {
return { success: false, message: `设置失败: ${error.message}` }
}
}
/**
* Node.js
*/
async getNodeInfo(version: string): Promise<any> {
const nodePath = this.configStore.getNodePath()
const versionDir = join(nodePath, `node-${version}-win-x64`)
const nodeExe = join(versionDir, 'node.exe')
if (!existsSync(nodeExe)) {
return null
}
try {
const { stdout: nodeVersion } = await execAsync(`"${nodeExe}" --version`, { timeout: 5000 })
let npmVersion = ''
const npmCmd = join(versionDir, 'npm.cmd')
if (existsSync(npmCmd)) {
const { stdout } = await execAsync(`"${npmCmd}" --version`, { timeout: 5000 })
npmVersion = stdout.trim()
}
return {
nodeVersion: nodeVersion.trim(),
npmVersion,
path: versionDir
}
} catch (error) {
return null
}
}
// ==================== 私有方法 ====================
private async downloadFile(url: string, dest: string, name: string): Promise<void> {
return new Promise((resolve, reject) => {
const protocol = url.startsWith('https') ? https : http
const request = protocol.get(url, {
headers: {
'User-Agent': 'PHPer-Dev-Manager/1.0'
},
timeout: 600000 // 10 分钟超时
}, (response) => {
// 处理重定向
if (response.statusCode === 301 || response.statusCode === 302) {
const redirectUrl = response.headers.location
if (redirectUrl) {
this.downloadFile(redirectUrl, dest, name).then(resolve).catch(reject)
return
}
}
if (response.statusCode !== 200) {
reject(new Error(`下载失败: HTTP ${response.statusCode}`))
return
}
const totalSize = parseInt(response.headers['content-length'] || '0', 10)
let downloadedSize = 0
const file = createWriteStream(dest)
let lastProgressTime = 0
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('nodejs', progress, downloadedSize, totalSize)
lastProgressTime = now
}
})
response.pipe(file)
file.on('finish', () => {
file.close()
sendDownloadProgress('nodejs', 100, totalSize, totalSize)
resolve()
})
file.on('error', (err) => {
unlinkSync(dest)
reject(err)
})
})
request.on('error', reject)
request.on('timeout', () => {
request.destroy()
reject(new Error('下载超时'))
})
})
}
private async extractZip(zipPath: string, destDir: string): Promise<void> {
return new Promise((resolve, reject) => {
const readStream = require('fs').createReadStream(zipPath)
readStream
.pipe(unzipper.Extract({ path: destDir }))
.on('close', resolve)
.on('error', reject)
})
}
private async addToPath(nodePath: string): Promise<void> {
// 使用 PowerShell 更新用户 PATH
const psScript = `
$ErrorActionPreference = 'Stop'
$newPath = '${nodePath.replace(/\\/g, '\\\\')}'
# Get current user PATH
$currentPath = [Environment]::GetEnvironmentVariable('Path', 'User')
$pathArray = $currentPath -split ';' | Where-Object { $_ -ne '' }
# Remove existing Node.js paths (from this manager and common locations)
$filteredPaths = $pathArray | Where-Object {
$p = $_.ToLower()
-not ($p -like '*\\node-v*' -or
$p -like '*\\nodejs*' -or
$p -like '*phper-dev-manager*node*' -or
$p -like '*\\nvm\\*')
}
# Add new path at the beginning
$newPathArray = @($newPath) + $filteredPaths
# Join and set
$finalPath = ($newPathArray | Select-Object -Unique) -join ';'
[Environment]::SetEnvironmentVariable('Path', $finalPath, 'User')
Write-Output "PATH updated successfully"
`
const tempPs1 = join(this.configStore.getTempPath(), 'update_node_path.ps1')
writeFileSync(tempPs1, psScript, 'utf-8')
try {
await execAsync(`powershell -ExecutionPolicy Bypass -File "${tempPs1}"`, { timeout: 30000 })
} finally {
try {
unlinkSync(tempPs1)
} catch (e) {
// 忽略
}
}
}
private getFallbackVersions(): AvailableNodeVersion[] {
return [
{ version: 'v22.12.0', date: '2024-12-03', lts: false, security: false, downloadUrl: 'https://nodejs.org/dist/v22.12.0/node-v22.12.0-win-x64.zip' },
{ version: 'v22.11.0', date: '2024-10-29', lts: 'Jod', security: false, downloadUrl: 'https://nodejs.org/dist/v22.11.0/node-v22.11.0-win-x64.zip' },
{ version: 'v20.18.1', date: '2024-11-21', lts: 'Iron', security: false, downloadUrl: 'https://nodejs.org/dist/v20.18.1/node-v20.18.1-win-x64.zip' },
{ version: 'v20.18.0', date: '2024-10-03', lts: 'Iron', security: false, downloadUrl: 'https://nodejs.org/dist/v20.18.0/node-v20.18.0-win-x64.zip' },
{ version: 'v18.20.5', date: '2024-11-21', lts: 'Hydrogen', security: false, downloadUrl: 'https://nodejs.org/dist/v18.20.5/node-v18.20.5-win-x64.zip' },
{ version: 'v18.20.4', date: '2024-08-21', lts: 'Hydrogen', security: true, downloadUrl: 'https://nodejs.org/dist/v18.20.4/node-v18.20.4-win-x64.zip' },
]
}
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,666 @@
import { ConfigStore } from './ConfigStore'
import { exec, spawn } from 'child_process'
import { promisify } from 'util'
import { existsSync, readFileSync, writeFileSync, readdirSync, unlinkSync, rmdirSync, mkdirSync } 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 RedisVersion {
version: string
path: string
isRunning: boolean
}
interface AvailableRedisVersion {
version: string
downloadUrl: string
}
interface RedisStatus {
running: boolean
pid?: number
port?: number
memory?: string
}
export class RedisManager {
private configStore: ConfigStore
constructor(configStore: ConfigStore) {
this.configStore = configStore
}
/**
* Redis
*/
async getInstalledVersions(): Promise<RedisVersion[]> {
const versions: RedisVersion[] = []
const redisPath = this.configStore.getRedisPath()
if (!existsSync(redisPath)) {
return versions
}
// 检查是否存在 redis-server.exe
if (existsSync(join(redisPath, 'redis-server.exe'))) {
try {
// 尝试获取版本
const { stdout } = await execAsync(`"${join(redisPath, 'redis-server.exe')}" --version`)
const match = stdout.match(/v=(\d+\.\d+\.\d+)/)
if (match) {
const isRunning = await this.checkIsRunning()
versions.push({
version: match[1],
path: redisPath,
isRunning
})
}
} catch (error) {
// 默认版本
const isRunning = await this.checkIsRunning()
versions.push({
version: 'unknown',
path: redisPath,
isRunning
})
}
}
return versions
}
// 缓存版本列表
private versionCache: { versions: AvailableRedisVersion[]; timestamp: number } | null = null
private readonly CACHE_TTL = 5 * 60 * 1000 // 5分钟缓存
/**
* Redis Windows
* GitHub releases
*/
async getAvailableVersions(): Promise<AvailableRedisVersion[]> {
// 检查缓存
if (this.versionCache && (Date.now() - this.versionCache.timestamp) < this.CACHE_TTL) {
console.log('使用缓存的 Redis 版本列表')
return this.versionCache.versions
}
let versions: AvailableRedisVersion[] = []
try {
// 从 redis-windows GitHub releases 获取版本列表
console.log('从 GitHub 获取 Redis Windows 版本列表...')
const releases = await this.fetchGitHubReleases('redis-windows', 'redis-windows')
for (const release of releases) {
const version = release.tag_name.replace(/^v/, '')
// 查找 Windows x64 ZIP 文件
const asset = release.assets?.find((a: any) =>
a.name.includes('Windows-x64') && a.name.endsWith('.zip')
)
if (asset) {
versions.push({
version,
downloadUrl: asset.browser_download_url
})
}
}
console.log(`从 GitHub 获取到 ${versions.length} 个 Redis 版本`)
} catch (error: any) {
console.error('从 GitHub 获取 Redis 版本失败:', error.message)
}
// 如果获取失败或为空,使用备用列表
if (versions.length === 0) {
console.log('使用备用 Redis 版本列表')
versions = this.getFallbackVersions()
}
// 更新缓存
this.versionCache = { versions, timestamp: Date.now() }
return versions
}
/**
* GitHub API releases
*/
private async fetchGitHubReleases(owner: string, repo: string): Promise<any[]> {
return new Promise((resolve, reject) => {
const options = {
hostname: 'api.github.com',
path: `/repos/${owner}/${repo}/releases?per_page=20`,
method: 'GET',
headers: {
'User-Agent': 'PHPer-Dev-Manager',
'Accept': 'application/vnd.github.v3+json'
}
}
const request = https.request(options, (response) => {
let data = ''
response.on('data', chunk => data += chunk)
response.on('end', () => {
try {
if (response.statusCode === 200) {
resolve(JSON.parse(data))
} else {
reject(new Error(`GitHub API 返回 ${response.statusCode}`))
}
} catch (e) {
reject(e)
}
})
})
request.on('error', reject)
request.setTimeout(15000, () => {
request.destroy()
reject(new Error('请求超时'))
})
request.end()
})
}
/**
*
*/
private getFallbackVersions(): AvailableRedisVersion[] {
return [
{
version: '7.4.2',
downloadUrl: 'https://github.com/redis-windows/redis-windows/releases/download/7.4.2/Redis-7.4.2-Windows-x64.zip'
},
{
version: '7.2.7',
downloadUrl: 'https://github.com/redis-windows/redis-windows/releases/download/7.2.7/Redis-7.2.7-Windows-x64.zip'
},
{
version: '7.0.15',
downloadUrl: 'https://github.com/redis-windows/redis-windows/releases/download/7.0.15/Redis-7.0.15-Windows-x64.zip'
},
{
version: '6.2.16',
downloadUrl: 'https://github.com/redis-windows/redis-windows/releases/download/6.2.16/Redis-6.2.16-Windows-x64.zip'
}
]
}
/**
* Redis
*/
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: `未找到 Redis ${version} 版本` }
}
const redisPath = this.configStore.getRedisPath()
const tempPath = this.configStore.getTempPath()
const zipPath = join(tempPath, `redis-${version}.zip`)
// 如果已有 Redis先备份配置
let oldConfig = ''
const configPath = join(redisPath, 'redis.windows.conf')
if (existsSync(configPath)) {
oldConfig = readFileSync(configPath, 'utf-8')
}
// 下载 Redis
await this.downloadFile(versionInfo.downloadUrl, zipPath)
// 清理旧版本(保留配置)
if (existsSync(redisPath)) {
const files = readdirSync(redisPath, { withFileTypes: true })
for (const file of files) {
if (!file.name.endsWith('.conf')) {
const fullPath = join(redisPath, file.name)
if (file.isDirectory()) {
this.removeDirectory(fullPath)
} else {
unlinkSync(fullPath)
}
}
}
} else {
mkdirSync(redisPath, { recursive: true })
}
// 解压
await this.unzip(zipPath, redisPath)
// 删除临时文件
if (existsSync(zipPath)) {
unlinkSync(zipPath)
}
// 移动解压后的文件(某些版本解压后有子目录)
await this.flattenDirectory(redisPath)
// 恢复或创建配置
if (oldConfig) {
writeFileSync(configPath, oldConfig)
} else {
await this.createDefaultConfig()
}
return { success: true, message: `Redis ${version} 安装成功` }
} catch (error: any) {
return { success: false, message: `安装失败: ${error.message}` }
}
}
/**
* Redis
*/
async uninstall(version: string): Promise<{ success: boolean; message: string }> {
try {
// 先停止服务
await this.stop()
const redisPath = this.configStore.getRedisPath()
if (!existsSync(redisPath)) {
return { success: false, message: 'Redis 未安装' }
}
// 递归删除目录
this.removeDirectory(redisPath)
return { success: true, message: 'Redis 已卸载' }
} catch (error: any) {
return { success: false, message: `卸载失败: ${error.message}` }
}
}
/**
* Redis
*/
async start(): Promise<{ success: boolean; message: string }> {
try {
const redisPath = this.configStore.getRedisPath()
const redisServer = join(redisPath, 'redis-server.exe')
const configPath = join(redisPath, 'redis.windows.conf')
if (!existsSync(redisServer)) {
return { success: false, message: 'Redis 未安装' }
}
// 检查是否已在运行
const isRunning = await this.checkIsRunning()
if (isRunning) {
return { success: true, message: 'Redis 已经在运行' }
}
// 确保配置文件存在
if (!existsSync(configPath)) {
await this.createDefaultConfig()
}
// 使用相对路径启动(避免 Cygwin 路径问题)
// Redis Windows 版本使用 Cygwin需要在正确的工作目录下用相对路径
const configFileName = 'redis.windows.conf'
const child = spawn(redisServer, [configFileName], {
cwd: redisPath,
detached: true,
stdio: 'ignore',
windowsHide: true,
shell: false
})
child.unref()
// 等待启动
await new Promise(resolve => setTimeout(resolve, 2000))
const running = await this.checkIsRunning()
if (running) {
return { success: true, message: 'Redis 启动成功' }
} else {
// 检查日志获取错误信息
const logsPath = this.configStore.getLogsPath()
const logFile = join(logsPath, 'redis.log')
let errorInfo = ''
if (existsSync(logFile)) {
try {
const logContent = readFileSync(logFile, 'utf-8')
const lines = logContent.split('\n').slice(-10)
errorInfo = '\n日志: ' + lines.join('\n')
} catch (e) {}
}
return { success: false, message: 'Redis 启动失败,请检查配置' + errorInfo }
}
} catch (error: any) {
return { success: false, message: `启动失败: ${error.message}` }
}
}
/**
* Redis
*/
async stop(): Promise<{ success: boolean; message: string }> {
try {
const redisPath = this.configStore.getRedisPath()
const redisCli = join(redisPath, 'redis-cli.exe')
if (existsSync(redisCli)) {
try {
await execAsync(`"${redisCli}" shutdown`, { timeout: 10000 })
} catch (e) {
// 尝试强制结束
try {
await execAsync('taskkill /F /IM redis-server.exe', { timeout: 5000 })
} catch (e2) {
// 进程可能不存在
}
}
}
await new Promise(resolve => setTimeout(resolve, 1000))
const isRunning = await this.checkIsRunning()
if (!isRunning) {
return { success: true, message: 'Redis 已停止' }
} else {
return { success: false, message: 'Redis 停止失败' }
}
} catch (error: any) {
return { success: false, message: `停止失败: ${error.message}` }
}
}
/**
* Redis
*/
async restart(): Promise<{ success: boolean; message: string }> {
await this.stop()
await new Promise(resolve => setTimeout(resolve, 500))
return await this.start()
}
/**
* Redis
*/
async getStatus(): Promise<RedisStatus> {
const isRunning = await this.checkIsRunning()
if (!isRunning) {
return { running: false }
}
try {
const redisPath = this.configStore.getRedisPath()
const redisCli = join(redisPath, 'redis-cli.exe')
if (existsSync(redisCli)) {
const { stdout } = await execAsync(`"${redisCli}" INFO server`, { timeout: 5000 })
const portMatch = stdout.match(/tcp_port:(\d+)/)
const memMatch = stdout.match(/used_memory_human:(\S+)/)
// 获取 PID
const { stdout: taskOutput } = await execAsync('tasklist /FI "IMAGENAME eq redis-server.exe" /FO CSV /NH')
let pid: number | undefined
if (taskOutput.includes('redis-server.exe')) {
const parts = taskOutput.split(',')
pid = parseInt(parts[1].replace(/"/g, ''))
}
return {
running: true,
pid,
port: portMatch ? parseInt(portMatch[1]) : 6379,
memory: memMatch ? memMatch[1] : undefined
}
}
} catch (e) {
// 忽略错误
}
return { running: isRunning }
}
/**
* Redis
*/
async getConfig(): Promise<string> {
const redisPath = this.configStore.getRedisPath()
const configPath = join(redisPath, 'redis.windows.conf')
if (!existsSync(configPath)) {
return ''
}
return readFileSync(configPath, 'utf-8')
}
/**
* Redis
*/
async saveConfig(config: string): Promise<{ success: boolean; message: string }> {
try {
const redisPath = this.configStore.getRedisPath()
const configPath = join(redisPath, 'redis.windows.conf')
writeFileSync(configPath, config)
return { success: true, message: 'redis.windows.conf 保存成功,需要重启 Redis 生效' }
} catch (error: any) {
return { success: false, message: `保存失败: ${error.message}` }
}
}
// ==================== 私有方法 ====================
private async checkIsRunning(): Promise<boolean> {
try {
const { stdout } = await execAsync('tasklist /FI "IMAGENAME eq redis-server.exe" /FO CSV /NH')
return stdout.includes('redis-server.exe')
} catch (e) {
return false
}
}
/**
* Windows Cygwin
*/
private toCygwinPath(winPath: string): string {
// C:\Users\... -> /cygdrive/c/Users/...
const match = winPath.match(/^([A-Za-z]):[\\\/](.*)$/)
if (match) {
const drive = match[1].toLowerCase()
const rest = match[2].replace(/\\/g, '/')
return `/cygdrive/${drive}/${rest}`
}
return winPath.replace(/\\/g, '/')
}
private async createDefaultConfig(): Promise<void> {
const redisPath = this.configStore.getRedisPath()
const configPath = join(redisPath, 'redis.windows.conf')
const logsPath = this.configStore.getLogsPath()
// 确保日志目录存在
if (!existsSync(logsPath)) {
mkdirSync(logsPath, { recursive: true })
}
// 转换为 Cygwin 路径格式
const cygwinLogsPath = this.toCygwinPath(logsPath)
const cygwinRedisPath = this.toCygwinPath(redisPath)
// 检查是否有示例配置文件
const sampleConfig = join(redisPath, 'redis.windows-service.conf')
if (existsSync(sampleConfig)) {
let config = readFileSync(sampleConfig, 'utf-8')
// 修改一些配置
config = config.replace(/^# bind 127.0.0.1/m, 'bind 127.0.0.1')
config = config.replace(/^logfile ""/m, `logfile "${cygwinLogsPath}/redis.log"`)
writeFileSync(configPath, config)
return
}
// 创建基本配置(使用 Cygwin 路径格式)
const config = `# Redis Configuration File
# Bind address
bind 127.0.0.1
# Port
port 6379
# Log file (Cygwin path format)
logfile "${cygwinLogsPath}/redis.log"
# Log level
loglevel notice
# Number of databases
databases 16
# Persistence
save 900 1
save 300 10
save 60 10000
# Data file
dbfilename dump.rdb
dir "${cygwinRedisPath}"
# Max memory
maxmemory 256mb
maxmemory-policy allkeys-lru
# Timeout
timeout 0
tcp-keepalive 300
# Protected mode
protected-mode yes
`
writeFileSync(configPath, config)
}
private async flattenDirectory(dir: string): Promise<void> {
const items = readdirSync(dir, { withFileTypes: true })
// 检查是否只有一个子目录
const subdirs = items.filter(item => item.isDirectory())
if (subdirs.length === 1 && items.filter(item => !item.isDirectory()).length === 0) {
const subdir = join(dir, subdirs[0].name)
const subItems = readdirSync(subdir)
const { rename } = await import('fs/promises')
// 移动子目录中的所有文件到父目录
for (const item of subItems) {
const srcPath = join(subdir, item)
const destPath = join(dir, item)
await rename(srcPath, destPath)
}
// 删除空的子目录
rmdirSync(subdir)
}
}
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) {
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('redis', progress, downloadedSize, totalSize)
lastProgressTime = now
}
})
response.pipe(file)
file.on('finish', () => {
file.close()
sendDownloadProgress('redis', 100, totalSize, totalSize)
resolve()
})
})
request.on('error', (err) => {
file.close()
if (existsSync(dest)) unlinkSync(dest)
reject(err)
})
request.setTimeout(300000, () => {
request.destroy()
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 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

@ -0,0 +1,356 @@
import { ConfigStore } from './ConfigStore'
import { exec, spawn } from 'child_process'
import { promisify } from 'util'
import { existsSync, writeFileSync, readFileSync, mkdirSync } from 'fs'
import { join } from 'path'
const execAsync = promisify(exec)
interface ServiceStatus {
name: string
displayName: string
running: boolean
autoStart: boolean
}
export class ServiceManager {
private configStore: ConfigStore
private startupDir: string
constructor(configStore: ConfigStore) {
this.configStore = configStore
// Windows 启动目录
this.startupDir = join(process.env.APPDATA || '', 'Microsoft', 'Windows', 'Start Menu', 'Programs', 'Startup')
}
/**
*
*/
async getAllServices(): Promise<ServiceStatus[]> {
const services: ServiceStatus[] = []
// 检查 Nginx
const nginxPath = this.configStore.getNginxPath()
if (existsSync(join(nginxPath, 'nginx.exe'))) {
const running = await this.checkProcess('nginx.exe')
const autoStart = this.checkAutoStart('nginx')
services.push({
name: 'nginx',
displayName: 'Nginx',
running,
autoStart
})
}
// 检查 MySQL
const mysqlVersions = this.configStore.get('mysqlVersions')
for (const version of mysqlVersions) {
const mysqlPath = this.configStore.getMysqlPath(version)
if (existsSync(join(mysqlPath, 'bin', 'mysqld.exe'))) {
const running = await this.checkProcess('mysqld.exe')
const autoStart = this.checkAutoStart(`mysql-${version}`)
services.push({
name: `mysql-${version}`,
displayName: `MySQL ${version}`,
running,
autoStart
})
}
}
// 检查 Redis
const redisPath = this.configStore.getRedisPath()
if (existsSync(join(redisPath, 'redis-server.exe'))) {
const running = await this.checkProcess('redis-server.exe')
const autoStart = this.checkAutoStart('redis')
services.push({
name: 'redis',
displayName: 'Redis',
running,
autoStart
})
}
// 检查 PHP-CGI
const activePhp = this.configStore.get('activePhpVersion')
if (activePhp) {
const phpPath = this.configStore.getPhpPath(activePhp)
if (existsSync(join(phpPath, 'php-cgi.exe'))) {
const running = await this.checkProcess('php-cgi.exe')
const autoStart = this.checkAutoStart('php-cgi')
services.push({
name: 'php-cgi',
displayName: `PHP-CGI (${activePhp})`,
running,
autoStart
})
}
}
return services
}
/**
*
*/
async setAutoStart(service: string, enabled: boolean): Promise<{ success: boolean; message: string }> {
try {
const batPath = join(this.startupDir, `phper-${service}.bat`)
if (enabled) {
// 创建启动脚本
let script = '@echo off\n'
script += `cd /d "${this.configStore.getBasePath()}"\n`
if (service === 'nginx') {
const nginxPath = this.configStore.getNginxPath()
script += `start "" /B "${join(nginxPath, 'nginx.exe')}"\n`
} else if (service.startsWith('mysql-')) {
const version = service.replace('mysql-', '')
const mysqlPath = this.configStore.getMysqlPath(version)
script += `start "" /B "${join(mysqlPath, 'bin', 'mysqld.exe')}" --defaults-file="${join(mysqlPath, 'my.ini')}"\n`
} else if (service === 'redis') {
const redisPath = this.configStore.getRedisPath()
script += `start "" /B "${join(redisPath, 'redis-server.exe')}" "${join(redisPath, 'redis.windows.conf')}"\n`
} else if (service === 'php-cgi') {
const activePhp = this.configStore.get('activePhpVersion')
if (activePhp) {
const phpPath = this.configStore.getPhpPath(activePhp)
script += `start "" /B "${join(phpPath, 'php-cgi.exe')}" -b 127.0.0.1:9000\n`
}
}
writeFileSync(batPath, script)
// 更新配置
const autoStart = this.configStore.get('autoStart')
if (service === 'nginx') autoStart.nginx = true
else if (service.startsWith('mysql')) autoStart.mysql = true
else if (service === 'redis') autoStart.redis = true
this.configStore.set('autoStart', autoStart)
return { success: true, message: `${service} 开机自启已启用` }
} else {
// 删除启动脚本
if (existsSync(batPath)) {
const { unlinkSync } = await import('fs')
unlinkSync(batPath)
}
// 更新配置
const autoStart = this.configStore.get('autoStart')
if (service === 'nginx') autoStart.nginx = false
else if (service.startsWith('mysql')) autoStart.mysql = false
else if (service === 'redis') autoStart.redis = false
this.configStore.set('autoStart', autoStart)
return { success: true, message: `${service} 开机自启已禁用` }
}
} catch (error: any) {
return { success: false, message: `设置失败: ${error.message}` }
}
}
/**
*
*/
getAutoStart(service: string): boolean {
return this.checkAutoStart(service)
}
/**
*
*/
async startAll(): Promise<{ success: boolean; message: string; details: string[] }> {
const details: string[] = []
try {
// 启动 Nginx
const nginxPath = this.configStore.getNginxPath()
if (existsSync(join(nginxPath, 'nginx.exe'))) {
if (!(await this.checkProcess('nginx.exe'))) {
await this.startProcess(join(nginxPath, 'nginx.exe'), [], nginxPath)
details.push('Nginx 已启动')
} else {
details.push('Nginx 已在运行')
}
}
// 启动 MySQL (启动第一个已安装的版本)
const mysqlVersions = this.configStore.get('mysqlVersions')
if (mysqlVersions.length > 0) {
if (!(await this.checkProcess('mysqld.exe'))) {
for (const version of mysqlVersions) {
const mysqlPath = this.configStore.getMysqlPath(version)
const mysqld = join(mysqlPath, 'bin', 'mysqld.exe')
if (existsSync(mysqld)) {
// 使用 VBScript 隐藏窗口启动
const vbsPath = join(mysqlPath, 'start_mysql.vbs')
const vbsContent = `Set WshShell = CreateObject("WScript.Shell")\nWshShell.Run """${mysqld}"" --defaults-file=""${join(mysqlPath, 'my.ini')}""", 0, False`
writeFileSync(vbsPath, vbsContent)
await execAsync(`cscript //nologo "${vbsPath}"`, { cwd: mysqlPath })
details.push(`MySQL ${version} 已启动`)
break // 只启动第一个版本
}
}
} else {
details.push('MySQL 已在运行')
}
}
// 启动 Redis
const redisPath = this.configStore.getRedisPath()
const redisServer = join(redisPath, 'redis-server.exe')
if (existsSync(redisServer)) {
if (!(await this.checkProcess('redis-server.exe'))) {
const configFile = join(redisPath, 'redis.windows.conf')
const args = existsSync(configFile) ? ['redis.windows.conf'] : []
await this.startProcess(redisServer, args, redisPath)
details.push('Redis 已启动')
} else {
details.push('Redis 已在运行')
}
}
if (details.length === 0) {
return { success: true, message: '没有已安装的服务', details }
}
return { success: true, message: '服务启动完成', details }
} catch (error: any) {
return { success: false, message: `启动失败: ${error.message}`, details }
}
}
/**
*
*/
async stopAll(): Promise<{ success: boolean; message: string; details: string[] }> {
const details: string[] = []
try {
// 停止 PHP-CGI
if (await this.checkProcess('php-cgi.exe')) {
await execAsync('taskkill /F /IM php-cgi.exe', { timeout: 5000 }).catch(() => {})
details.push('PHP-CGI 已停止')
}
// 停止 Nginx
if (await this.checkProcess('nginx.exe')) {
const nginxPath = this.configStore.getNginxPath()
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(() => {})
}
details.push('Nginx 已停止')
}
// 停止 MySQL
if (await this.checkProcess('mysqld.exe')) {
await execAsync('taskkill /F /IM mysqld.exe', { timeout: 5000 }).catch(() => {})
details.push('MySQL 已停止')
}
// 停止 Redis
if (await this.checkProcess('redis-server.exe')) {
const redisPath = this.configStore.getRedisPath()
const redisCli = join(redisPath, 'redis-cli.exe')
if (existsSync(redisCli)) {
try {
await execAsync(`"${redisCli}" shutdown`, { timeout: 5000 })
} catch (e) {
await execAsync('taskkill /F /IM redis-server.exe', { timeout: 5000 }).catch(() => {})
}
} else {
await execAsync('taskkill /F /IM redis-server.exe', { timeout: 5000 }).catch(() => {})
}
details.push('Redis 已停止')
}
return { success: true, message: '所有服务已停止', details }
} catch (error: any) {
return { success: false, message: `停止失败: ${error.message}`, details }
}
}
/**
* PHP-CGI FastCGI
*/
async startPhpCgi(): Promise<{ success: boolean; message: string }> {
try {
const activePhp = this.configStore.get('activePhpVersion')
if (!activePhp) {
return { success: false, message: '未设置活动的 PHP 版本' }
}
const phpPath = this.configStore.getPhpPath(activePhp)
const phpCgi = join(phpPath, 'php-cgi.exe')
if (!existsSync(phpCgi)) {
return { success: false, message: 'php-cgi.exe 不存在' }
}
// 检查是否已在运行
if (await this.checkProcess('php-cgi.exe')) {
return { success: true, message: 'PHP-CGI 已经在运行' }
}
// 启动 PHP-CGI
await this.startProcess(phpCgi, ['-b', '127.0.0.1:9000'], phpPath)
// 等待启动
await new Promise(resolve => setTimeout(resolve, 1000))
if (await this.checkProcess('php-cgi.exe')) {
return { success: true, message: 'PHP-CGI 启动成功' }
} else {
return { success: false, message: 'PHP-CGI 启动失败' }
}
} catch (error: any) {
return { success: false, message: `启动失败: ${error.message}` }
}
}
/**
* PHP-CGI
*/
async stopPhpCgi(): Promise<{ success: boolean; message: string }> {
try {
await execAsync('taskkill /F /IM php-cgi.exe', { timeout: 5000 })
return { success: true, message: 'PHP-CGI 已停止' }
} catch (error: any) {
if (error.message.includes('not found')) {
return { success: true, message: 'PHP-CGI 未运行' }
}
return { success: false, message: `停止失败: ${error.message}` }
}
}
// ==================== 私有方法 ====================
private async checkProcess(name: string): Promise<boolean> {
try {
const { stdout } = await execAsync(`tasklist /FI "IMAGENAME eq ${name}" /FO CSV /NH`)
return stdout.includes(name)
} catch (e) {
return false
}
}
private checkAutoStart(service: string): boolean {
const batPath = join(this.startupDir, `phper-${service}.bat`)
return existsSync(batPath)
}
private async startProcess(exe: string, args: string[], cwd: string): Promise<void> {
const child = spawn(exe, args, {
cwd,
detached: true,
stdio: 'ignore',
windowsHide: true
})
child.unref()
}
}

14
index.html Normal file
View File

@ -0,0 +1,14 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>PHPer 开发环境管理器</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

6807
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

83
package.json Normal file
View File

@ -0,0 +1,83 @@
{
"name": "phper-dev-manager",
"version": "1.0.0",
"description": "PHP开发环境管理器 - 管理PHP、MySQL、Nginx、Redis服务",
"main": "dist-electron/main.js",
"scripts": {
"dev": "vite",
"build": "vue-tsc --noEmit && vite build && electron-builder",
"preview": "vite preview",
"electron:dev": "vite",
"electron:build": "vite build && electron-builder"
},
"author": "PHPer",
"license": "MIT",
"devDependencies": {
"@types/node": "^20.10.0",
"@vitejs/plugin-vue": "^4.5.0",
"concurrently": "^8.2.2",
"electron": "^28.0.0",
"electron-builder": "^24.9.1",
"sass": "^1.69.5",
"typescript": "^5.3.2",
"vite": "^5.0.0",
"vite-plugin-electron": "^0.15.5",
"vite-plugin-electron-renderer": "^0.14.5",
"vue-tsc": "^1.8.25",
"wait-on": "^7.2.0"
},
"dependencies": {
"@element-plus/icons-vue": "^2.3.1",
"axios": "^1.6.2",
"electron-store": "^8.1.0",
"element-plus": "^2.4.3",
"node-windows": "^1.0.0-beta.8",
"pinia": "^2.1.7",
"sudo-prompt": "^9.2.1",
"unzipper": "^0.12.3",
"vue": "^3.3.9",
"vue-router": "^4.2.5"
},
"build": {
"appId": "com.phper.devmanager",
"productName": "PHPer开发环境管理器",
"copyright": "Copyright © 2024 PHPer",
"directories": {
"output": "release"
},
"files": [
"dist/**/*",
"dist-electron/**/*"
],
"win": {
"target": [
{
"target": "nsis",
"arch": ["x64"]
}
],
"requestedExecutionLevel": "requireAdministrator"
},
"nsis": {
"oneClick": false,
"perMachine": true,
"allowToChangeInstallationDirectory": true,
"allowElevation": true,
"createDesktopShortcut": true,
"createStartMenuShortcut": true,
"shortcutName": "PHPer开发环境管理器",
"installerLanguages": ["zh_CN", "en_US"],
"language": "2052",
"runAfterFinish": true,
"deleteAppDataOnUninstall": false,
"include": "build/installer.nsh"
},
"extraResources": [
{
"from": "public/",
"to": "public/",
"filter": ["**/*"]
}
]
}
}

1762
pecl_redis_page.html Normal file

File diff suppressed because it is too large Load Diff

992
pecl_search.html Normal file
View File

@ -0,0 +1,992 @@
<!DOCTYPE html>
<html lang="en">
<head>
<title>PECL :: Package search</title>
<link rel="shortcut icon" href="/favicon.ico">
<link rel="alternate" type="application/rss+xml" title="RSS feed" href="https://pecl.php.net/feeds/latest.rss">
<link rel="stylesheet" href="/css/style.css">
<link rel="stylesheet" href="/js/calendar/dynCalendar.css">
</head>
<body >
<div><a id="TOP"></a></div>
<table class="head" cellspacing="0" cellpadding="0" width="100%">
<tr>
<td class="head-logo">
<a href="/"><img src="/img/peclsmall.gif" alt="PECL :: The PHP Extension Community Library" width="106" height="55" style="margin: 5px;"></a><br>
</td>
<td class="head-menu">
<a href="/login.php" class="menuBlack">Login</a>
&nbsp;|&nbsp;
<a href="/packages.php" class="menuBlack">Packages</a>
&nbsp;|&nbsp;
<a href="/support.php" class="menuBlack">Support</a>
&nbsp;|&nbsp;
<a href="/bugs/" class="menuBlack">Bugs</a>
</td>
</tr>
<tr>
<td class="head-search" colspan="2">
<form method="post" action="/search.php">
<p class="head-search"><span class="accesskey">S</span>earch for
<input class="small" type="text" name="search_string" value="" size="20" accesskey="s">
in the
<select name="search_in" class="small">
<option value="packages">Packages</option>
<option value="site">This site (using Google)</option>
<option value="developers">Developers</option>
<option value="pecl-dev">Developer mailing list</option>
<option value="pecl-cvs">SVN commits mailing list</option>
</select>
<input type="image" src="/img/small_submit_white.gif" alt="search" style="vertical-align: middle;">&nbsp;<br>
</p>
</form>
</td>
</tr>
</table>
<table class="middle" cellspacing="0" cellpadding="0">
<tr>
<td class="sidebar_left">
<ul class="side_pages">
<li class="side_page"><a href="/" >Home</a></li>
<li class="side_page"><a href="/news/" >News</a></li>
</ul>
<strong>Documentation:</strong>
<ul class="side_pages">
<li class="side_page"><a href="/support.php" >Support</a></li>
</ul>
<strong>Downloads:</strong>
<ul class="side_pages">
<li class="side_page">
<a href="/packages.php" >Browse Packages</a>
</li>
<li class="side_page">
<a href="/package-search.php" style="font-weight: bold" >Search Packages</a>
</li>
<li class="side_page">
<a href="/package-stats.php" >Download Statistics</a>
</li>
</ul>
</td>
<td class="content">
<h1>Package search</h1>
<script src="/js/calendar/browserSniffer.js"></script>
<script src="/js/calendar/dynCalendar.js"></script>
<script>
date_updated_released_on = false;
date_updated_released_before = false;
date_updated_released_since = false;
released_on_disabled = false;
released_before_disabled = false;
released_since_disabled = false;
/**
* Resets the above variables to false when form is cleared
*/
function form_reset()
{
searchForm = document.forms['search_form'];
if (1) {
location.href = 'package-search.php';
} else {
date_updated_released_on = false;
date_updated_released_before = false;
date_updated_released_since = false;
// Re-enable date dropdowns
searchForm.released_before_year.disabled = false;
searchForm.released_before_month.disabled = false;
searchForm.released_before_day.disabled = false;
searchForm.released_since_year.disabled = false;
searchForm.released_since_month.disabled = false;
searchForm.released_since_day.disabled = false;
searchForm.released_on_year.disabled = false;
searchForm.released_on_month.disabled = false;
searchForm.released_on_day.disabled = false;
released_on_disabled = false;
released_before_disabled = false;
released_since_disabled = false;
// Re-enable search button
searchForm.submitButton.disabled = false;
return true;
}
}
/**
* When changed, the date fields in the forms are updated by this
*/
function update_date(prefix, input)
{
searchForm = document.forms['search_form'];
if (eval('date_updated_' + prefix)) return true;
yearElement = searchForm.elements[prefix + '_year'];
monthElement = searchForm.elements[prefix + '_month'];
dayElement = searchForm.elements[prefix + '_day'];
today = new Date();
switch (input) {
case 'year':
if (monthElement.value != '' || dayElement.value != '') return true;
monthElement.value = today.getMonth() + 1;
dayElement.value = today.getDate();
break;
case 'month':
if (yearElement.value != '' || dayElement.value != '') return true;
yearElement.value = today.getFullYear();
dayElement.value = today.getDate();
break;
case 'day':
if (yearElement.value != '' || monthElement.value != '') return true;
yearElement.value = today.getFullYear();
monthElement.value = today.getMonth() + 1;
break;
}
disableDateOptions(prefix);
eval('date_updated_' + prefix + ' = true');
return true;
}
/**
* This function sets the date dropdowns to their
* search values.
*/
function setReleaseDropdowns()
{
if (0) {
setDateFromCalendar_released_on('', '', '');
} else {
if (0) {
setDateFromCalendar_released_before('', '', '');
}
if (0) {
setDateFromCalendar_released_since('', '', '');
}
}
}
/**
* Function to disable date dropdowns when the
* others are selected.
*/
function disableDateOptions(prefix)
{
// Disable appropriate option based on what just changed.
searchForm = document.forms['search_form'];
switch (prefix) {
case 'released_on':
searchForm.released_before_year.disabled = true;
searchForm.released_before_month.disabled = true;
searchForm.released_before_day.disabled = true;
released_before_disabled = true;
searchForm.released_since_year.disabled = true;
searchForm.released_since_month.disabled = true;
searchForm.released_since_day.disabled = true;
released_since_disabled = true;
break;
case 'released_before':
case 'released_since':
searchForm.released_on_year.disabled = true;
searchForm.released_on_month.disabled = true;
searchForm.released_on_day.disabled = true;
released_on_disabled = true;
break;
}
}
/**
* Callback functions for the calendar
*/
function setDateFromCalendar_released_on(date, month, year)
{
date_updated_released_on = true;
return setDateFromCalendar('released_on', date, month, year);
}
function setDateFromCalendar_released_before(date, month, year)
{
date_updated_released_before = true;
return setDateFromCalendar('released_before', date, month, year);
}
function setDateFromCalendar_released_since(date, month, year)
{
date_updated_released_since = true;
return setDateFromCalendar('released_since', date, month, year);
}
function setDateFromCalendar(prefix, date, month, year)
{
searchForm = document.forms['search_form'];
if (eval(prefix + '_disabled') == true) {
return;
} else {
disableDateOptions(prefix);
}
yearElement = searchForm.elements[prefix + '_year'].value = (year == '0' ? '' : year);
monthElement = searchForm.elements[prefix + '_month'].value = (month == '0' ? '' : month);
dayElement = searchForm.elements[prefix + '_day'].value = (date == '0' ? '' : date);
}
function validate_form()
{
searchForm = document.forms['search_form'];
onYearElement = searchForm.elements['released_on_year'];
onMonthElement = searchForm.elements['released_on_month'];
onDayElement = searchForm.elements['released_on_day'];
beforeYearElement = searchForm.elements['released_before_year'];
beforeMonthElement = searchForm.elements['released_before_month'];
beforeDayElement = searchForm.elements['released_before_day'];
sinceYearElement = searchForm.elements['released_since_year'];
sinceMonthElement = searchForm.elements['released_since_month'];
sinceDayElement = searchForm.elements['released_since_day'];
released_on_changed = (onYearElement.value != '' || onMonthElement.value != '' || onDayElement.value != '');
released_before_changed = (beforeYearElement.value != '' || beforeMonthElement.value != '' || beforeDayElement.value != '');
released_since_changed = (sinceYearElement.value != '' || sinceMonthElement.value != '' || sinceDayElement.value != '');
if (released_on_changed && (released_since_changed || released_before_changed)) {
alert('Cannot combine Released On and Released Before or Since!');
return false;
}
document.forms['search_form'].submitButton.value = 'Sending request...';
document.forms['search_form'].submitButton.disabled = true;
}
</script>
<form action="/package-search.php" method="get" name="search_form" onsubmit="validate_form()">
<table class="form-holder" cellspacing="1">
<caption class="form-caption">Search Options</caption>
<tr>
<th class="form-label_left">Sear<span class="accesskey">c</span>h for:</th>
<td class="form-input">
<input type="text" name="pkg_name" size="0" value="redis" accesskey="c">
</td>
</tr>
<tr>
<th class="form-label_left">Maintainer:</th>
<td class="form-input">
<input name="pkg_maintainer" type="text" value="">
<select onchange="document.forms['search_form'].pkg_maintainer.value = this.options[this.selectedIndex].value; this.selectedIndex = 0">
<option value="">Select user...</option>
<option value="ch">ch</option>
<option value="trowski">Aaron Piotrowski</option>
<option value="sodabrew">Aaron Stone</option>
<option value="dickmeiss">Adam Dickmeiss</option>
<option value="aharvey">Adam Harvey</option>
<option value="as">Adam Saponara</option>
<option value="advect">advect vasquaz</option>
<option value="akshat">Akshat Gupta</option>
<option value="alan_k">Alan Knowles</option>
<option value="estringanadd">Alejandro Estringana Ruiz</option>
<option value="valyala">Alexander Valyalkin</option>
<option value="santiago">Alexey Romanenko</option>
<option value="indeyets">Alexey Zakhlestin</option>
<option value="vnkbabu">Amarnath Reddy N</option>
<option value="anonamish">Amish M</option>
<option value="flabby">Anan Zhao</option>
<option value="ab">Anatol Belski</option>
<option value="andi">Andi Gutmans</option>
<option value="alcaeus">Andreas Braun</option>
<option value="andrei">Andrei Zmievski</option>
<option value="andrewdalpino">Andrew DalPino</option>
<option value="atex">Andrew Teixeira</option>
<option value="blindman">Andrey Demenev</option>
<option value="andrey">Andrey Hristov</option>
<option value="anilm3">Anil Mahtani</option>
<option value="adobkin">Anton Dobkin</option>
<option value="izero76">Anton Pitak</option>
<option value="jeckerson">Anton Vasiliev</option>
<option value="tony2001">Antony Dovgal</option>
<option value="skywalking">Apache SkyWalking</option>
<option value="abies">Ard Biesheuvel</option>
<option value="lbarnaud">Arnaud Le Blanc</option>
<option value="jasny">Arnold Daniels</option>
<option value="arpad">Arpad Ray</option>
<option value="basantk">Basant Kukreja</option>
<option value="behnam">Behnam Esfahbod</option>
<option value="benhanson">Ben Hanson</option>
<option value="ramsey">Ben Ramsey</option>
<option value="beberlei">Benjamin Eberlei</option>
<option value="silkcut">Bing Bai</option>
<option value="andot">Bingyao Ma</option>
<option value="biggi">Birgir Haraldsson</option>
<option value="rjcarroll">Bob Carroll</option>
<option value="bwoebi">Bob Weinand</option>
<option value="pinepain">Bohdan Padalko</option>
<option value="bor0">Boro Sitnikovski</option>
<option value="bradmssw">Brad House</option>
<option value="braulio">Braulio J. Solano Rojas</option>
<option value="bmoen">Brent Moen</option>
<option value="brettmc">Brett McBride</option>
<option value="shire">Brian Shire</option>
<option value="doubaokun">Bruce Dou</option>
<option value="bd808">Bryan Davis</option>
<option value="diesing">Burkhard Diesing</option>
<option value="crodas">C?sar D. Rodas</option>
<option value="calvinb">Calvin Buckley</option>
<option value="cem">Caroline Maynard</option>
<option value="luckec">Carsten Lucke</option>
<option value="langemeijer">Casper Langemeijer</option>
<option value="nicos">CHAILLAN Nicolas</option>
<option value="cjiang">Changhao Jiang</option>
<option value="neeke">Chitao Gao</option>
<option value="cschneid">Chris Schneider</option>
<option value="daverandom">Chris Wright</option>
<option value="nyenyon">Christian Cartus</option>
<option value="chregu">Christian Stocker</option>
<option value="cmb">Christoph M. Becker</option>
<option value="crobin">Christophe Robin</option>
<option value="cvubrugier">Christophe Vu-Brugier</option>
<option value="sixd">Christopher Jones</option>
<option value="cleong728">Chung Leong</option>
<option value="curt">Curt Zirzow</option>
<option value="danack">Dan Ackroyd</option>
<option value="witten">Dan Helfman</option>
<option value="marines">Dariusz Slusarczyk</option>
<option value="felceyd">Dave Felcey</option>
<option value="dsr">Dave Renshaw</option>
<option value="davidc">David Coallier</option>
<option value="davidengel">David Engel</option>
<option value="doury">David Oury</option>
<option value="dsp">David Soria Parra</option>
<option value="dmendolia">Davide Mendolia</option>
<option value="dthompso99">Davin Thompson</option>
<option value="void">De Cock Xavier</option>
<option value="deminy">Demin Yin</option>
<option value="dengket">Dengke Tang</option>
<option value="harveyrd">Dennis Harvey</option>
<option value="derick">Derick Rethans</option>
<option value="musatkd">Dmitriy Musatkin</option>
<option value="dmitrykoterov">Dmitry Koterov</option>
<option value="dmitry">Dmitry Stogov</option>
<option value="dzenovich">Dmitry Zenovich</option>
<option value="dom">Dominic Black</option>
<option value="ph4r05sk">Dusan Klinec</option>
<option value="dktapps">Dylan Taylor</option>
<option value="edink">Edin Kadribasic</option>
<option value="eduardo">Eduardo Bacchi Kienetz</option>
<option value="auroraeosrose">Elizabeth Smith</option>
<option value="eklausmeier">Elmar Klausmeier</option>
<option value="sankazim">Emanuele Ruffaldi</option>
<option value="ecolinet">Eric Colinet</option>
<option value="ericsten">Eric Stenson</option>
<option value="cubrid">Esen Sagynov</option>
<option value="colder">Etienne Kneuss</option>
<option value="enemerson">Evan Nemerson</option>
<option value="felipe">Felipe Pena</option>
<option value="fjanisze">Filip Janiszewski</option>
<option value="flavius">Flavius Aspra</option>
<option value="flowcontrol">Florian Engelhardt</option>
<option value="fcartegnie">Francois Cartegnie</option>
<option value="francois">Francois Laupretre</option>
<option value="jedisct1">Frank Denis</option>
<option value="fmk">Frank M. Kromann</option>
<option value="gerald">G?rald Cro</option>
<option value="gabe">Gabriel Ricard</option>
<option value="gardron">Gareth Ardron</option>
<option value="gasolwu">Gasol Wu</option>
<option value="gena01">Gennady Feldman</option>
<option value="georg">Georg Richter</option>
<option value="gschlossnagle">George Schlossnagle</option>
<option value="girgias">Gina Peter Banyard</option>
<option value="gchiesa">Giuseppe Chiesa</option>
<option value="zeriyoshi">Go Kudo</option>
<option value="gopalv">Gopal Vijayaraghavan</option>
<option value="gcc">Graham Charters</option>
<option value="graham">Graham Kelly</option>
<option value="grant">Grant Croker</option>
<option value="graphdat">Graphdat Support</option>
<option value="cellog">Greg Beaver</option>
<option value="oschwald">Greg Oschwald</option>
<option value="gamr">Guillaume Amringer</option>
<option value="cataphract">Gustavo Lopes</option>
<option value="haiping">Haiping Zhao</option>
<option value="tianfenghan">Han Tianfeng</option>
<option value="ymr674">Hang Zhang</option>
<option value="bjori">Hannes Magnusson</option>
<option value="haolu">Hao Lu</option>
<option value="hholzgra">Hartmut Holzgraefe</option>
<option value="tessus">Helmut K. C. Tessarek</option>
<option value="hradtke">Herman Radtke</option>
<option value="hburbach">Holger Burbach</option>
<option value="ianb">Ian Barber</option>
<option value="kfbombar">IBM OpenDev</option>
<option value="iliaa">Ilia Alshanetsky</option>
<option value="ioseb">Ioseb Dzmanashvili</option>
<option value="ip2location">IP2Location</option>
<option value="hywan">Ivan Enderlin</option>
<option value="bukka">Jakub Zelenka</option>
<option value="imajes">James Cox</option>
<option value="jluedke">James Luedke</option>
<option value="jmoore">James Moore</option>
<option value="asgrim">James Titcumb</option>
<option value="eeliu">Jason Mile</option>
<option value="jay">Jay Smith</option>
<option value="macintoshplus">Jean-Baptiste Nahan</option>
<option value="chingor">Jeff Ching</option>
<option value="jgmdev">Jefferson Gonzalez</option>
<option value="jsjohnst">Jeremy Johnstone</option>
<option value="jmikola">Jeremy Mikola</option>
<option value="theprez">Jesse Gorzinski</option>
<option value="jmjoy">Jiemin Xia</option>
<option value="jimjag">Jim Jagielski</option>
<option value="jimw">jim winstead</option>
<option value="krakjoe">Joe Watkins</option>
<option value="hartmann">Johann-Peter Hartmann</option>
<option value="hanez">Johannes Findeisen</option>
<option value="johannes">Johannes Schlüter</option>
<option value="jbboehr">John Boehr</option>
<option value="john">John Coggeshall</option>
<option value="jcupitt">John Cupitt</option>
<option value="jawed">John Jawed</option>
<option value="jon">Jon Parise</option>
<option value="joonas">Joonas Govenius</option>
<option value="jtate">Joseph Tate</option>
<option value="jblopez">Joshua Lopez</option>
<option value="jah">Jouni Ahto</option>
<option value="juliens">Julien Salleyron</option>
<option value="jhannus">Justin Hannus</option>
<option value="wenlong">Justin Wu</option>
<option value="joodk">Jørgen Olsen</option>
<option value="kalle">Kalle Sommer Nielsen</option>
<option value="kannan">Kannan Muthukkaruppan</option>
<option value="ksingla">Kanwaljeet Singla</option>
<option value="kasparp">Kaspar Bach Pedersen</option>
<option value="kvwalker">Katherine Walker</option>
<option value="kirtig">Kirti Velankar</option>
<option value="kaigai">Kohei KaiGai</option>
<option value="legoktm">Kunal Mehta</option>
<option value="lstrojny">Lars Strojny</option>
<option value="leigh">Leigh</option>
<option value="levim">Levi Morrison</option>
<option value="srain">Liao Huqiu</option>
<option value="lcastelli">Lorenzo Castelli</option>
<option value="iamluc">Luc Vieillescazes</option>
<option value="labbati">Luca Abbati</option>
<option value="lufei">Lufei</option>
<option value="seariver">M?rio Soares</option>
<option value="mksheoran">Manoj Kr. Sheoran</option>
<option value="kea">Manuel Baldassarri</option>
<option value="mboeren">Marc Boeren</option>
<option value="msaraujo">Marcelo Araujo</option>
<option value="mg">marcin gibula</option>
<option value="marco">Marco Schuster</option>
<option value="marcot">Marco Tabini</option>
<option value="helly">Marcus B?rger</option>
<option value="mbar">Marie Barwin</option>
<option value="mario">Mario Döring</option>
<option value="mruz">Mariusz Laczak</option>
<option value="magicaltux">Mark Karpeles</option>
<option value="mlwmohawk">Mark L. Woodward</option>
<option value="markskilbeck">Mark Skilbeck</option>
<option value="mnx">Markus Nix</option>
<option value="martynas">Martynas Venckus</option>
<option value="kocsismate">Máté Kocsis</option>
<option value="ut0pia">Mathieu Hurtevent</option>
<option value="mfp">Matthew Peters</option>
<option value="cyberspice">Melanie Rhianna Lewis</option>
<option value="merletenney">Merle Tenney</option>
<option value="mbretter">Michael Bretterklieber</option>
<option value="mgrunder">Michael Grunder</option>
<option value="agiroloki95">Michael Lochemem</option>
<option value="mgdm">Michael Maclean</option>
<option value="mpenick">Michael Penick</option>
<option value="michael">Michael Spector</option>
<option value="sqmk">Michael Squires</option>
<option value="mike">Michael Wallner</option>
<option value="mignov">Michal Novotny</option>
<option value="ironpinguin">Michele Catalano</option>
<option value="mikl">Mikael Johansson</option>
<option value="mikek">Mike Kaminski</option>
<option value="mikesul">Mike Sullivan</option>
<option value="mkoppanen">Mikko Koppanen</option>
<option value="koubel">Miroslav Kubelik</option>
<option value="mabouzou">Mohammed Abouzour</option>
<option value="fourd">Morgaut Alexandre</option>
<option value="mbechler">Moritz Bechler</option>
<option value="mysqlre">MySQL Release Engineering</option>
<option value="nabeel">Nabeel Yoosuf</option>
<option value="fyb3roptik">Nick Wallace</option>
<option value="nicolas">Nicolas Brousse</option>
<option value="nielsdos">Niels Dossche</option>
<option value="nickzh">Nikazu Tenaka</option>
<option value="nikic">Nikita Popov</option>
<option value="niden">Nikolaos Dimopoulos</option>
<option value="nlopess">Nuno Lopes</option>
<option value="phadej">Oleg Grenrus</option>
<option value="areaz2">Oliver Welter</option>
<option value="oliviergarcia">Olivier Garcia</option>
<option value="ohill">Olivier Hill</option>
<option value="omar">Omar Kilani</option>
<option value="omars">Omar Shaban</option>
<option value="patrickallaert">Patrick Allaert</option>
<option value="preilly">Patrick Reilly</option>
<option value="pestilence">Paul Chandler</option>
<option value="pmjones">Paul Jones</option>
<option value="pavels">Pavel Stano</option>
<option value="merlin">Pavlo Shelyazhenko</option>
<option value="yatsukhnenko">Pavlo Yatsukhnenko</option>
<option value="ppadron">Pedro Padron</option>
<option value="philip">Philip Olson</option>
<option value="philippe">Philippe Tjon - A - Hen</option>
<option value="pierotibou">Pierre Bonet</option>
<option value="pajoye">Pierre Joye</option>
<option value="pierrick">Pierrick Charron</option>
<option value="pdezwart">Pieter de Zwart</option>
<option value="makler">Piotr Klaban</option>
<option value="protobufpackages">Proto Google</option>
<option value="pdelewski">Przemyslaw Delewski</option>
<option value="bqq">Qianqian Bu</option>
<option value="rahulpriyadarshi">RAHUL PRIYADARSHI</option>
<option value="rjs">Rainer Schaaf</option>
<option value="rasmus">Rasmus Lerdorf</option>
<option value="reeze">Reeze Xia</option>
<option value="remi">Remi Collet</option>
<option value="rquadling">Richard Quadling</option>
<option value="rnp">RNP Ribose</option>
<option value="rrichards">Rob Richards</option>
<option value="jlesueur">Robert John LeSueur</option>
<option value="rtwitty">Robert Twitty</option>
<option value="rockli">rock li</option>
<option value="rubs">Rubem Pechansky</option>
<option value="rtheunissen">Rudi Theunissen</option>
<option value="hirokawa">Rui Hirokawa</option>
<option value="osmanov">Ruslan Osmanov</option>
<option value="ruslany">Ruslan Yakushev</option>
<option value="santiagolizardo">Santiago Lizardo</option>
<option value="pollita">Sara Golemon</option>
<option value="skettler">Sascha Kettler</option>
<option value="sas">Sascha Schumann</option>
<option value="scottmac">Scott MacVicar</option>
<option value="sean">Sean Coates</option>
<option value="seander">Sean DuBois</option>
<option value="avsej">Sergey Avseyev</option>
<option value="gluke">Sergey Kartashoff</option>
<option value="shane">Shane Caraveo</option>
<option value="sharadchan87">Sharad Chandran Raju</option>
<option value="chobieeee">Shuhei Tanuma</option>
<option value="sibaz">Simon Bazley</option>
<option value="treffynnon">Simon Holywell</option>
<option value="slaws">Simon Laws</option>
<option value="stas">Stanislav Malyshev</option>
<option value="stanleycheung">Stanley Cheung</option>
<option value="stesie">Stefan Siegl</option>
<option value="sfrausch">Stefano F. Rausch</option>
<option value="sfox">Steph Fox</option>
<option value="schst">Stephan Schmidt</option>
<option value="splanquart">stephane planquart</option>
<option value="sterling">Sterling Hughes</option>
<option value="ssb">Stig Bakken</option>
<option value="tal">Tal Peer</option>
<option value="kjdev">Tatsuya KAMIJO</option>
<option value="tricky">Teddy Grenman</option>
<option value="thierry">Thierry FOURNIER</option>
<option value="cubic">Thomas Hruska</option>
<option value="ttk">Thomas K?tter</option>
<option value="simenec">Thomas Simenec</option>
<option value="tianfyan">Tianfang Yang</option>
<option value="tstarling">Tim Starling</option>
<option value="timandes">Timandes White</option>
<option value="krinkle">Timo Tijhof</option>
<option value="datibbaw">Tjerk Meesters</option>
<option value="tvlooy">Tom Van Looy</option>
<option value="tomassrnka">Tomas Srnka</option>
<option value="tony">Tony Leake</option>
<option value="hamano">Tsukasa Hamano</option>
<option value="tandre">Tyson Andre</option>
<option value="uw">Ulf Wendel</option>
<option value="steinm">Uwe Steinmann</option>
<option value="val">val khokhlov</option>
<option value="donraman">Venkat Raman Don</option>
<option value="veeve">Venkat Venkataramani</option>
<option value="va">Vijay Aswadhati</option>
<option value="viktor">Viktor Djupsjöbacka</option>
<option value="vjardin">Vincent JARDIN</option>
<option value="vito">Vito Chin</option>
<option value="wjx">Wang Jiexin</option>
<option value="wez">Wez Furlong</option>
<option value="willfitch">Will Fitch</option>
<option value="wcandillon">William Candillon</option>
<option value="xnoguer">Xavier Noguer</option>
<option value="laruence">Xinchen Hui</option>
<option value="woshiguo35">xinhua guo</option>
<option value="longxinhui">xinhui long</option>
<option value="yanlong">Yanlong He</option>
<option value="yorambh">Yoram Bar-Haim</option>
<option value="hnw">Yoshio HANAWA</option>
<option value="monque">Yuchen Wang</option>
<option value="yumin1985">yuduan chen</option>
<option value="uchiyama">Yuji Uchiyama</option>
<option value="surfchen">Ze Chen</option>
<option value="zeev">Zeev Suraski</option>
<option value="bearlord">Zhenqiang Zhang</option>
<option value="rick">Zhenyu Wu</option>
</select>
</td>
</tr>
<tr>
<th class="form-label_left">Category:</th>
<td class="form-input">
<select name="pkg_category">
<option value=""></option>
<option value="47" >Audio</option>
<option value="1" >Authentication</option>
<option value="2" >Benchmarking</option>
<option value="3" >Caching</option>
<option value="4" >Configuration</option>
<option value="5" >Console</option>
<option value="7" >Database</option>
<option value="8" >Date and Time</option>
<option value="6" >Encryption</option>
<option value="44" >Event</option>
<option value="33" >File Formats</option>
<option value="9" >File System</option>
<option value="34" >Gtk Components</option>
<option value="53" >Gtk2 Components</option>
<option value="45" >GUI</option>
<option value="10" >HTML</option>
<option value="11" >HTTP</option>
<option value="12" >Images</option>
<option value="28" >Internationalization</option>
<option value="59" >Languages</option>
<option value="13" >Logging</option>
<option value="14" >Mail</option>
<option value="15" >Math</option>
<option value="46" >Multimedia</option>
<option value="16" >Networking</option>
<option value="17" >Numbers</option>
<option value="18" >Payment</option>
<option value="19" >PEAR</option>
<option value="55" >PEAR Website</option>
<option value="25" >PHP</option>
<option value="31" >Processing</option>
<option value="56" >QA Tools</option>
<option value="20" >Scheduling</option>
<option value="21" >Science</option>
<option value="57" >Search Engine</option>
<option value="54" >Security</option>
<option value="42" >Semantic Web</option>
<option value="35" >Streams</option>
<option value="27" >Structures</option>
<option value="37" >System</option>
<option value="43" >Testing</option>
<option value="36" >Text</option>
<option value="29" >Tools and Utilities</option>
<option value="50" >Validate</option>
<option value="40" >Version Control</option>
<option value="60" >Virtualization</option>
<option value="23" >Web Services</option>
<option value="22" >XML</option>
</select>
</td>
</tr>
<tr><td class="form-input" colspan="2">&nbsp;</td></tr>
<tr>
<th class="form-label_left" colspan="2">With a release...</th>
</tr>
<tr>
<th class="form-label_left">On:</th>
<td class="form-input">
<input type="text" name="released_on_year" value="" size="5" onkeyup="update_date('released_on', 'year')">
<select name="released_on_month" onchange="update_date('released_on', 'month')">
<option value=""></option>
<option value="1">January</option>
<option value="2">February</option>
<option value="3">March</option>
<option value="4">April</option>
<option value="5">May</option>
<option value="6">June</option>
<option value="7">July</option>
<option value="8">August</option>
<option value="9">September</option>
<option value="10">October</option>
<option value="11">November</option>
<option value="12">December</option>
</select>
<select name="released_on_day" onchange="update_date('released_on', 'day')">
<option value=""></option>
<option value="1">1</option>
<option value="2">2</option>
<option value="3">3</option>
<option value="4">4</option>
<option value="5">5</option>
<option value="6">6</option>
<option value="7">7</option>
<option value="8">8</option>
<option value="9">9</option>
<option value="10">10</option>
<option value="11">11</option>
<option value="12">12</option>
<option value="13">13</option>
<option value="14">14</option>
<option value="15">15</option>
<option value="16">16</option>
<option value="17">17</option>
<option value="18">18</option>
<option value="19">19</option>
<option value="20">20</option>
<option value="21">21</option>
<option value="22">22</option>
<option value="23">23</option>
<option value="24">24</option>
<option value="25">25</option>
<option value="26">26</option>
<option value="27">27</option>
<option value="28">28</option>
<option value="29">29</option>
<option value="30">30</option>
<option value="31">31</option>
</select>
<script>
calendarReleasedOn = new dynCalendar('calendarReleasedOn', 'setDateFromCalendar_released_on', 'img/');
</script>
</td>
</tr>
<tr>
<th class="form-label_left">Before:</th>
<td class="form-input">
<input type="text" name="released_before_year" value="" size="5" onkeyup="update_date('released_before', 'year')" />
<select name="released_before_month" onchange="update_date('released_before', 'month')">
<option value=""></option>
<option value="1">January</option>
<option value="2">February</option>
<option value="3">March</option>
<option value="4">April</option>
<option value="5">May</option>
<option value="6">June</option>
<option value="7">July</option>
<option value="8">August</option>
<option value="9">September</option>
<option value="10">October</option>
<option value="11">November</option>
<option value="12">December</option>
</select>
<select name="released_before_day" onchange="update_date('released_before', 'day')">
<option value=""></option>
<option value="1">1</option>
<option value="2">2</option>
<option value="3">3</option>
<option value="4">4</option>
<option value="5">5</option>
<option value="6">6</option>
<option value="7">7</option>
<option value="8">8</option>
<option value="9">9</option>
<option value="10">10</option>
<option value="11">11</option>
<option value="12">12</option>
<option value="13">13</option>
<option value="14">14</option>
<option value="15">15</option>
<option value="16">16</option>
<option value="17">17</option>
<option value="18">18</option>
<option value="19">19</option>
<option value="20">20</option>
<option value="21">21</option>
<option value="22">22</option>
<option value="23">23</option>
<option value="24">24</option>
<option value="25">25</option>
<option value="26">26</option>
<option value="27">27</option>
<option value="28">28</option>
<option value="29">29</option>
<option value="30">30</option>
<option value="31">31</option>
</select>
<script>
calendarReleasedBefore = new dynCalendar('calendarReleasedBefore', 'setDateFromCalendar_released_before', 'img/');
</script>
</td>
</tr>
<tr>
<th class="form-label_left">Since:</th>
<td class="form-input">
<input type="text" name="released_since_year" value="" size="5" onkeyup="update_date('released_since', 'year')" />
<select name="released_since_month" onchange="update_date('released_since', 'month')">
<option value=""></option>
<option value="1">January</option>
<option value="2">February</option>
<option value="3">March</option>
<option value="4">April</option>
<option value="5">May</option>
<option value="6">June</option>
<option value="7">July</option>
<option value="8">August</option>
<option value="9">September</option>
<option value="10">October</option>
<option value="11">November</option>
<option value="12">December</option>
</select>
<select name="released_since_day" onchange="update_date('released_since', 'day')">
<option value=""></option>
<option value="1">1</option>
<option value="2">2</option>
<option value="3">3</option>
<option value="4">4</option>
<option value="5">5</option>
<option value="6">6</option>
<option value="7">7</option>
<option value="8">8</option>
<option value="9">9</option>
<option value="10">10</option>
<option value="11">11</option>
<option value="12">12</option>
<option value="13">13</option>
<option value="14">14</option>
<option value="15">15</option>
<option value="16">16</option>
<option value="17">17</option>
<option value="18">18</option>
<option value="19">19</option>
<option value="20">20</option>
<option value="21">21</option>
<option value="22">22</option>
<option value="23">23</option>
<option value="24">24</option>
<option value="25">25</option>
<option value="26">26</option>
<option value="27">27</option>
<option value="28">28</option>
<option value="29">29</option>
<option value="30">30</option>
<option value="31">31</option>
</select>
<script>
calendarReleasedSince = new dynCalendar('calendarReleasedSince', 'setDateFromCalendar_released_since', 'img/');
</script>
</td>
</tr>
<tr>
<th class="form-label_left">&nbsp;</th>
<td class="form-input">
<input type="submit" name="submitButton" value="Search">
<input type="reset" value="Clear" onclick="return form_reset()">
</td>
</tr>
</table>
</form>
<script>
// Call function to set dropdowns to their search values.
setReleaseDropdowns();
</script>
<br><br>
<table cellpadding="0" cellspacing="1" style="width: 90%; border: 0px;">
<tr>
<td bgcolor="#000000">
<table cellpadding="2" cellspacing="1" style="width: 100%; border: 0px;">
<tr style="background-color: #CCCCCC;">
<th><table border="0" width="100%" cellspacing="0" cellpadding="0">
<tr>
<td align="left" width="50"></td>
<td align="center">Search results (1 - 1 of 1)</td>
<td align="right" width="50"></td>
</tr>
</table></th>
</tr>
<tr bgcolor="#ffffff">
<td>
<table border="0" cellpadding="2" cellspacing="2">
<tr>
<td>
<a href="/package/redis"><span style="background-color: #d5ffc1">redis</span></a>
</td>
<td>PHP extension for interfacing with key-value stores</td>
</tr>
</table>
</td>
</tr>
</table>
</td>
</tr>
</table>
</td>
</tr>
</table>
<table class="foot" cellspacing="0" cellpadding="0">
<tr>
<td class="foot-bar" colspan="2">
<a href="/about/privacy.php" class="menuBlack">PRIVACY POLICY</a>
&nbsp;|&nbsp;
<a href="/credits.php" class="menuBlack">CREDITS</a>
<br>
</td>
</tr>
<tr>
<td class="foot-copy">
<small>
<a href="/copyright.php">Copyright &copy; 2001-2025 The PHP Group</a><br>
All rights reserved.<br>
</small>
</td>
<td class="foot-source">
<small>
Last updated: Wed Sep 03 10:50:24 2025 UTC<br>
Bandwidth and hardware provided by: <a href="https://www.pair.com/">pair Networks</a>
</small>
</td>
</tr>
</table>
</body>
</html>

388
pecl_test.html Normal file
View File

@ -0,0 +1,388 @@
<!DOCTYPE html>
<html lang="en">
<head>
<title>PECL :: Package :: redis 6.3.0 for Windows</title>
<link rel="shortcut icon" href="/favicon.ico">
<link rel="alternate" type="application/rss+xml" title="RSS feed" href="https://pecl.php.net/feeds/latest.rss">
<link rel="stylesheet" href="/css/style.css">
</head>
<body >
<div><a id="TOP"></a></div>
<table class="head" cellspacing="0" cellpadding="0" width="100%">
<tr>
<td class="head-logo">
<a href="/"><img src="/img/peclsmall.gif" alt="PECL :: The PHP Extension Community Library" width="106" height="55" style="margin: 5px;"></a><br>
</td>
<td class="head-menu">
<a href="/login.php" class="menuBlack">Login</a>
&nbsp;|&nbsp;
<a href="/packages.php" class="menuBlack">Packages</a>
&nbsp;|&nbsp;
<a href="/support.php" class="menuBlack">Support</a>
&nbsp;|&nbsp;
<a href="/bugs/" class="menuBlack">Bugs</a>
</td>
</tr>
<tr>
<td class="head-search" colspan="2">
<form method="post" action="/search.php">
<p class="head-search"><span class="accesskey">S</span>earch for
<input class="small" type="text" name="search_string" value="" size="20" accesskey="s">
in the
<select name="search_in" class="small">
<option value="packages">Packages</option>
<option value="site">This site (using Google)</option>
<option value="developers">Developers</option>
<option value="pecl-dev">Developer mailing list</option>
<option value="pecl-cvs">SVN commits mailing list</option>
</select>
<input type="image" src="/img/small_submit_white.gif" alt="search" style="vertical-align: middle;">&nbsp;<br>
</p>
</form>
</td>
</tr>
</table>
<table class="middle" cellspacing="0" cellpadding="0">
<tr>
<td class="sidebar_left">
<ul class="side_pages">
<li class="side_page"><a href="/" >Home</a></li>
<li class="side_page"><a href="/news/" >News</a></li>
</ul>
<strong>Documentation:</strong>
<ul class="side_pages">
<li class="side_page"><a href="/support.php" >Support</a></li>
</ul>
<strong>Downloads:</strong>
<ul class="side_pages">
<li class="side_page">
<a href="/packages.php" >Browse Packages</a>
</li>
<li class="side_page">
<a href="/package-search.php" >Search Packages</a>
</li>
<li class="side_page">
<a href="/package-stats.php" >Download Statistics</a>
</li>
</ul>
</td>
<td class="content">
<a href="/packages.php">Top Level</a> :: <a href="/packages.php?catpid=7&catname=Database">Database</a>
:: <a href="/package/redis">redis</a>
:: <a href="/package/redis/6.3.0">6.3.0</a>
:: Windows
<h2 style="text-align:center">
redis 6.3.0 for Windows</h2>
<table cellpadding="0" cellspacing="1" style="width: 90%; border: 0px;">
<tr>
<td style="background-color: #000000">
<table cellpadding="2" cellspacing="1" style="width: 100%; border: 0px;">
<tr style="background-color: #CCCCCC;">
<th colspan="2">Package Information</th>
</tr>
<tr>
<th valign="top" style="background-color: #cccccc">Summary</th>
<td valign="top" style="background-color: #e8e8e8">PHP extension for interfacing with key-value stores</td>
</tr>
<tr>
<th valign="top" style="background-color: #cccccc">Maintainers</th>
<td valign="top" style="background-color: #e8e8e8">
Michael Grunder &lt;<a href="/account-mail.php?handle=mgrunder">
michael dot grunder at gmail dot com </a>&gt;
(lead)
[<a href="/user/mgrunder">details</a>]<br>
Pavlo Yatsukhnenko (lead)
[<a href="/user/yatsukhnenko">details</a>]<br>
</td>
</tr>
<tr>
<th valign="top" style="background-color: #cccccc">License</th>
<td valign="top" style="background-color: #e8e8e8"><a href="https://php.net/license/3_01.txt">PHP</a></td>
</tr>
<tr>
<th valign="top" style="background-color: #cccccc">Description</th>
<td valign="top" style="background-color: #e8e8e8">This extension provides an API for communicating with RESP-based key-value<br />
stores, such as Redis, Valkey, and KeyDB.</td>
</tr>
<tr>
<th valign="top" style="background-color: #cccccc">Homepage</th>
<td valign="top" style="background-color: #e8e8e8">
<a href="https://github.com/phpredis/phpredis/">
https://github.com/phpredis/phpredis/ </a>
</td>
</tr>
<tr>
<th valign="top" style="background-color: #cccccc">
Release notes<br>
Version 6.3.0<br>
(stable)
</th>
<td valign="top" style="background-color: #e8e8e8">
--- Sponsors ---<br><br>A-VISION Advertising - https&colon;&sol;&sol;github&period;com&sol;A-VISION-BV<br>Avtandil Kikabidze - https&colon;&sol;&sol;github&period;com&sol;akalongman<br>Geoffrey Hoffman - https&colon;&sol;&sol;github&period;com&sol;phpguru<br>Object Cache Pro for WordPress - https&colon;&sol;&sol;objectcache&period;pro&sol;<br>Open LMS - https&colon;&sol;&sol;openlms&period;net&sol;<br>Relay - https&colon;&sol;&sol;relay&period;so<br>Salvatore Sanfilippo - https&colon;&sol;&sol;github&period;com&sol;antirez<br>Ty Karok - https&colon;&sol;&sol;github&period;com&sol;karock<br><br>--- 6&period;3&period;0 ---<br><br>This release introduces support for dozens of new commands&comma; including hash<br>field expiration&comma; Valkey&quest;s DELIFEQ&comma; and Redis vector set commands&period; It also<br>includes many bug fixes and performance improvements&period;<br><br>Fixed&colon;<br><br>&ast; Cloning our objects should not segfault &lbrack;770034cc&rsqb; &lpar;michael-grunder&rpar;<br>&ast; Fix return type for &grave;RedisCluster&grave; &grave;vgetattr&grave; and &grave;vsetattr&grave;<br> &lbrack;834d2b37&rsqb; &lpar;michael-grunder&rpar; </td>
</tr>
</table>
</td>
</tr>
</table>
<div>&nbsp;</div>
<table cellpadding="0" cellspacing="1" style="width: 90%; border: 0px;">
<tr>
<td style="background-color: #000000">
<table cellpadding="2" cellspacing="1" style="width: 100%; border: 0px;">
<tr style="background-color: #CCCCCC;">
<th colspan="2">DLL List</th>
</tr>
<tr>
<th valign="top" style="background-color: #cccccc">PHP 8.5</th>
<td valign="top" style="background-color: #e8e8e8">
<a href="https://downloads.php.net/~windows/pecl/releases/redis/6.3.0/php_redis-6.3.0-8.5-nts-vs17-x64.zip">
8.5 Non Thread Safe (NTS) x64 </a>
<br>
<a href="https://downloads.php.net/~windows/pecl/releases/redis/6.3.0/php_redis-6.3.0-8.5-ts-vs17-x64.zip">
8.5 Thread Safe (TS) x64 </a>
<br>
<a href="https://downloads.php.net/~windows/pecl/releases/redis/6.3.0/php_redis-6.3.0-8.5-nts-vs17-x86.zip">
8.5 Non Thread Safe (NTS) x86 </a>
<br>
<a href="https://downloads.php.net/~windows/pecl/releases/redis/6.3.0/php_redis-6.3.0-8.5-ts-vs17-x86.zip">
8.5 Thread Safe (TS) x86 </a>
<br>
</td>
</tr>
<tr>
<th valign="top" style="background-color: #cccccc">PHP 8.4</th>
<td valign="top" style="background-color: #e8e8e8">
<a href="https://downloads.php.net/~windows/pecl/releases/redis/6.3.0/php_redis-6.3.0-8.4-nts-vs17-x64.zip">
8.4 Non Thread Safe (NTS) x64 </a>
<br>
<a href="https://downloads.php.net/~windows/pecl/releases/redis/6.3.0/php_redis-6.3.0-8.4-ts-vs17-x64.zip">
8.4 Thread Safe (TS) x64 </a>
<br>
<a href="https://downloads.php.net/~windows/pecl/releases/redis/6.3.0/php_redis-6.3.0-8.4-nts-vs17-x86.zip">
8.4 Non Thread Safe (NTS) x86 </a>
<br>
<a href="https://downloads.php.net/~windows/pecl/releases/redis/6.3.0/php_redis-6.3.0-8.4-ts-vs17-x86.zip">
8.4 Thread Safe (TS) x86 </a>
<br>
</td>
</tr>
<tr>
<th valign="top" style="background-color: #cccccc">PHP 8.3</th>
<td valign="top" style="background-color: #e8e8e8">
<a href="https://downloads.php.net/~windows/pecl/releases/redis/6.3.0/php_redis-6.3.0-8.3-nts-vs16-x64.zip">
8.3 Non Thread Safe (NTS) x64 </a>
<br>
<a href="https://downloads.php.net/~windows/pecl/releases/redis/6.3.0/php_redis-6.3.0-8.3-ts-vs16-x64.zip">
8.3 Thread Safe (TS) x64 </a>
<br>
<a href="https://downloads.php.net/~windows/pecl/releases/redis/6.3.0/php_redis-6.3.0-8.3-nts-vs16-x86.zip">
8.3 Non Thread Safe (NTS) x86 </a>
<br>
<a href="https://downloads.php.net/~windows/pecl/releases/redis/6.3.0/php_redis-6.3.0-8.3-ts-vs16-x86.zip">
8.3 Thread Safe (TS) x86 </a>
<br>
</td>
</tr>
<tr>
<th valign="top" style="background-color: #cccccc">PHP 8.2</th>
<td valign="top" style="background-color: #e8e8e8">
<a href="https://downloads.php.net/~windows/pecl/releases/redis/6.3.0/php_redis-6.3.0-8.2-nts-vs16-x64.zip">
8.2 Non Thread Safe (NTS) x64 </a>
<br>
<a href="https://downloads.php.net/~windows/pecl/releases/redis/6.3.0/php_redis-6.3.0-8.2-ts-vs16-x64.zip">
8.2 Thread Safe (TS) x64 </a>
<br>
<a href="https://downloads.php.net/~windows/pecl/releases/redis/6.3.0/php_redis-6.3.0-8.2-nts-vs16-x86.zip">
8.2 Non Thread Safe (NTS) x86 </a>
<br>
<a href="https://downloads.php.net/~windows/pecl/releases/redis/6.3.0/php_redis-6.3.0-8.2-ts-vs16-x86.zip">
8.2 Thread Safe (TS) x86 </a>
<br>
</td>
</tr>
<tr>
<th valign="top" style="background-color: #cccccc">PHP 8.1</th>
<td valign="top" style="background-color: #e8e8e8">
<a href="https://downloads.php.net/~windows/pecl/releases/redis/6.3.0/php_redis-6.3.0-8.1-nts-vs16-x64.zip">
8.1 Non Thread Safe (NTS) x64 </a>
<br>
<a href="https://downloads.php.net/~windows/pecl/releases/redis/6.3.0/php_redis-6.3.0-8.1-ts-vs16-x64.zip">
8.1 Thread Safe (TS) x64 </a>
<br>
<a href="https://downloads.php.net/~windows/pecl/releases/redis/6.3.0/php_redis-6.3.0-8.1-nts-vs16-x86.zip">
8.1 Non Thread Safe (NTS) x86 </a>
<br>
<a href="https://downloads.php.net/~windows/pecl/releases/redis/6.3.0/php_redis-6.3.0-8.1-ts-vs16-x86.zip">
8.1 Thread Safe (TS) x86 </a>
<br>
</td>
</tr>
<tr>
<th valign="top" style="background-color: #cccccc">PHP 8.0</th>
<td valign="top" style="background-color: #e8e8e8">
<a href="https://downloads.php.net/~windows/pecl/releases/redis/6.3.0/php_redis-6.3.0-8.0-nts-vs16-x64.zip">
8.0 Non Thread Safe (NTS) x64 </a>
<br>
<a href="https://downloads.php.net/~windows/pecl/releases/redis/6.3.0/php_redis-6.3.0-8.0-ts-vs16-x64.zip">
8.0 Thread Safe (TS) x64 </a>
<br>
<a href="https://downloads.php.net/~windows/pecl/releases/redis/6.3.0/php_redis-6.3.0-8.0-nts-vs16-x86.zip">
8.0 Non Thread Safe (NTS) x86 </a>
<br>
<a href="https://downloads.php.net/~windows/pecl/releases/redis/6.3.0/php_redis-6.3.0-8.0-ts-vs16-x86.zip">
8.0 Thread Safe (TS) x86 </a>
<br>
</td>
</tr>
<tr>
<th valign="top" style="background-color: #cccccc">PHP 7.4</th>
<td valign="top" style="background-color: #e8e8e8">
<a href="https://downloads.php.net/~windows/pecl/releases/redis/6.3.0/php_redis-6.3.0-7.4-nts-vc15-x64.zip">
7.4 Non Thread Safe (NTS) x64 </a>
<br>
<a href="https://downloads.php.net/~windows/pecl/releases/redis/6.3.0/php_redis-6.3.0-7.4-ts-vc15-x64.zip">
7.4 Thread Safe (TS) x64 </a>
<br>
<a href="https://downloads.php.net/~windows/pecl/releases/redis/6.3.0/php_redis-6.3.0-7.4-nts-vc15-x86.zip">
7.4 Non Thread Safe (NTS) x86 </a>
<br>
<a href="https://downloads.php.net/~windows/pecl/releases/redis/6.3.0/php_redis-6.3.0-7.4-ts-vc15-x86.zip">
7.4 Thread Safe (TS) x86 </a>
<br>
</td>
</tr>
</table>
</td>
</tr>
</table>
<p>In case of missing DLLs, consider to contact the
<a href="https://www.php.net/mailing-lists.php#internals">Windows Internals List</a>
(subscribe first).</p>
<br>
<table border="0" cellspacing="3" cellpadding="3" height="48" width="90%" align="center">
<tr>
<td align="center">[ <a href="/get/redis">Latest Tarball</a> ]</td>
<td align="center">[
<a href="/package-changelog.php?package=redis&amp;release=6&period;3&period;0">
Changelog
</a>
]</td>
<td align="center">
[ <a href="/package-stats.php?pid=935&amp;rid=&amp;cid=7">
View Statistics
</a> ]
</td>
</tr>
<tr>
<td align="center">
[ <a href="https://github.com/phpredis/phpredis/" target="_blank">Browse Source</a> ]
</td>
<td align="center">
[ <a href="https://github.com/phpredis/phpredis/issues">Package Bugs</a> ]
</td>
<td align="center">
[ <a href="https://github.com/phpredis/phpredis/#readme">
View Documentation
</a> ]
</td>
</tr>
</table>
<br>
<table cellpadding="0" cellspacing="1" style="width: 90%; border: 0px;">
<tr>
<td style="background-color: #000000">
<table cellpadding="2" cellspacing="1" style="width: 100%; border: 0px;">
<tr style="background-color: #CCCCCC;">
<th colspan="2">Dependencies for release 6.3.0</th>
</tr>
<tr>
<th valign="top" style="background-color: #cccccc"></th>
<td valign="top" style="background-color: #e8e8e8">PHP Version: PHP 7.4.0 or newer<br />PEAR Package: <a href="https://pear.php.net/package/PEAR">PEAR</a> 1.4.0b1 or newer<br /></td>
</tr>
</table>
</td>
</tr>
</table>
</td>
</tr>
</table>
<table class="foot" cellspacing="0" cellpadding="0">
<tr>
<td class="foot-bar" colspan="2">
<a href="/about/privacy.php" class="menuBlack">PRIVACY POLICY</a>
&nbsp;|&nbsp;
<a href="/credits.php" class="menuBlack">CREDITS</a>
<br>
</td>
</tr>
<tr>
<td class="foot-copy">
<small>
<a href="/copyright.php">Copyright &copy; 2001-2025 The PHP Group</a><br>
All rights reserved.<br>
</small>
</td>
<td class="foot-source">
<small>
Last updated: Wed Sep 03 10:50:24 2025 UTC<br>
Bandwidth and hardware provided by: <a href="https://www.pair.com/">pair Networks</a>
</small>
</td>
</tr>
</table>
</body>
</html>

12
public/favicon.svg Normal file
View File

@ -0,0 +1,12 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
<defs>
<linearGradient id="bg" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#7c3aed"/>
<stop offset="100%" style="stop-color:#a855f7"/>
</linearGradient>
</defs>
<rect width="100" height="100" rx="20" fill="url(#bg)"/>
<text x="50" y="68" font-family="Arial, sans-serif" font-size="48" font-weight="bold" text-anchor="middle" fill="white">P</text>
<circle cx="75" cy="25" r="12" fill="#10b981"/>
</svg>

After

Width:  |  Height:  |  Size: 525 B

1762
redis_detail.html Normal file

File diff suppressed because it is too large Load Diff

373
src/App.vue Normal file
View File

@ -0,0 +1,373 @@
<template>
<div class="app-container" :class="{ 'dark-mode': isDark }">
<!-- 自定义标题栏 -->
<div class="title-bar">
<div class="title-bar-left">
<div class="app-logo">
<img src="/favicon.svg" alt="logo" class="logo-icon" />
<span class="app-name">PHPer 开发环境管理器</span>
</div>
</div>
<div class="title-bar-right">
<button class="title-btn" @click="toggleDark">
<el-icon><Sunny v-if="isDark" /><Moon v-else /></el-icon>
</button>
<button class="title-btn" @click="minimize">
<el-icon><Minus /></el-icon>
</button>
<button class="title-btn" @click="maximize">
<el-icon><FullScreen /></el-icon>
</button>
<button class="title-btn close-btn" @click="close">
<el-icon><Close /></el-icon>
</button>
</div>
</div>
<!-- 主内容区 -->
<div class="main-container">
<!-- 侧边栏 -->
<aside class="sidebar">
<nav class="nav-menu">
<router-link
v-for="item in menuItems"
:key="item.path"
:to="item.path"
class="nav-item"
:class="{ active: $route.path === item.path }"
>
<el-icon class="nav-icon"><component :is="item.icon" /></el-icon>
<span class="nav-label">{{ item.label }}</span>
<span
v-if="item.service"
class="status-dot"
:class="{ running: serviceStatus[item.service as keyof typeof serviceStatus] }"
></span>
</router-link>
</nav>
<div class="sidebar-footer">
<div class="quick-actions">
<el-button type="success" @click="startAll" :loading="startingAll">
<el-icon><VideoPlay /></el-icon>
启动全部
</el-button>
<el-button type="danger" @click="stopAll" :loading="stoppingAll">
<el-icon><VideoPause /></el-icon>
停止全部
</el-button>
</div>
</div>
</aside>
<!-- 内容区 -->
<main class="content">
<router-view v-slot="{ Component }">
<transition name="fade" mode="out-in">
<component :is="Component" />
</transition>
</router-view>
</main>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted, onUnmounted } from 'vue'
import { ElMessage } from 'element-plus'
const isDark = ref(true)
const startingAll = ref(false)
const stoppingAll = ref(false)
//
const serviceStatus = reactive({
nginx: false,
mysql: false,
redis: false
})
const menuItems = [
{ path: '/', label: '仪表盘', icon: 'Odometer', service: null },
{ path: '/php', label: 'PHP 管理', icon: 'Files', service: null },
{ path: '/mysql', label: 'MySQL 管理', icon: 'Coin', service: 'mysql' },
{ 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: '/sites', label: '站点管理', icon: 'Monitor', service: null },
{ path: '/hosts', label: 'Hosts 管理', icon: 'Document', service: null },
{ path: '/settings', label: '设置', icon: 'Setting', service: null }
]
//
const loadServiceStatus = async () => {
try {
const services = await window.electronAPI?.service.getAll()
if (services) {
serviceStatus.nginx = services.some(s => s.name === 'nginx' && s.running)
serviceStatus.mysql = services.some(s => s.name.startsWith('mysql') && s.running)
serviceStatus.redis = services.some(s => s.name === 'redis' && s.running)
}
} catch (error) {
console.error('加载服务状态失败:', error)
}
}
let statusInterval: ReturnType<typeof setInterval> | null = null
//
const minimize = () => window.electronAPI?.minimize()
const maximize = () => window.electronAPI?.maximize()
const close = () => window.electronAPI?.close()
//
const toggleDark = () => {
isDark.value = !isDark.value
document.documentElement.classList.toggle('dark', isDark.value)
}
//
const startAll = async () => {
startingAll.value = true
try {
const result = await window.electronAPI?.service.startAll()
if (result?.success) {
ElMessage.success(result.message)
//
setTimeout(loadServiceStatus, 2000)
} else {
ElMessage.error(result?.message || '启动失败')
}
} catch (error: any) {
ElMessage.error(error.message)
} finally {
startingAll.value = false
}
}
//
const stopAll = async () => {
stoppingAll.value = true
try {
const result = await window.electronAPI?.service.stopAll()
if (result?.success) {
ElMessage.success(result.message)
await loadServiceStatus()
} else {
ElMessage.error(result?.message || '停止失败')
}
} catch (error: any) {
ElMessage.error(error.message)
} finally {
stoppingAll.value = false
}
}
onMounted(() => {
document.documentElement.classList.add('dark')
loadServiceStatus()
// 5
statusInterval = setInterval(loadServiceStatus, 5000)
})
onUnmounted(() => {
if (statusInterval) {
clearInterval(statusInterval)
}
})
</script>
<style lang="scss" scoped>
.app-container {
width: 100vw;
height: 100vh;
display: flex;
flex-direction: column;
background: var(--bg-primary);
color: var(--text-primary);
overflow: hidden;
}
.title-bar {
height: 40px;
display: flex;
justify-content: space-between;
align-items: center;
background: var(--bg-titlebar);
border-bottom: 1px solid var(--border-color);
-webkit-app-region: drag;
padding: 0 12px;
}
.title-bar-left {
display: flex;
align-items: center;
gap: 12px;
}
.app-logo {
display: flex;
align-items: center;
gap: 8px;
.logo-icon {
width: 24px;
height: 24px;
}
.app-name {
font-size: 14px;
font-weight: 600;
color: var(--text-primary);
font-family: 'Noto Sans SC', 'Microsoft YaHei', sans-serif;
}
}
.title-bar-right {
display: flex;
gap: 4px;
-webkit-app-region: no-drag;
}
.title-btn {
width: 36px;
height: 28px;
display: flex;
align-items: center;
justify-content: center;
background: transparent;
border: none;
border-radius: 6px;
color: var(--text-secondary);
cursor: pointer;
transition: all 0.2s;
&:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
&.close-btn:hover {
background: #e81123;
color: white;
}
}
.main-container {
flex: 1;
display: flex;
overflow: hidden;
}
.sidebar {
width: 220px;
background: var(--bg-sidebar);
border-right: 1px solid var(--border-color);
display: flex;
flex-direction: column;
padding: 16px 12px;
}
.nav-menu {
flex: 1;
display: flex;
flex-direction: column;
gap: 4px;
}
.nav-item {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 16px;
border-radius: 10px;
text-decoration: none;
color: var(--text-secondary);
transition: all 0.2s;
&:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
&.active {
background: var(--accent-gradient);
color: white;
box-shadow: 0 4px 12px rgba(123, 97, 255, 0.3);
.status-dot {
border-color: rgba(255, 255, 255, 0.3);
}
}
.nav-icon {
font-size: 20px;
}
.nav-label {
font-size: 14px;
font-weight: 500;
flex: 1;
}
.status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: #6b7280;
border: 2px solid var(--bg-sidebar);
transition: all 0.3s;
&.running {
background: #10b981;
box-shadow: 0 0 8px rgba(16, 185, 129, 0.6);
}
}
}
.sidebar-footer {
padding-top: 16px;
border-top: 1px solid var(--border-color);
}
.quick-actions {
display: flex;
flex-direction: column;
gap: 10px;
padding: 0 12px;
:deep(.el-button) {
width: 100% !important;
height: 40px !important;
min-width: 100% !important;
max-width: 100% !important;
font-size: 14px !important;
justify-content: center !important;
border-radius: 8px !important;
padding: 0 16px !important;
margin-left: 0 !important;
}
:deep(.el-button + .el-button) {
margin-left: 0 !important;
}
}
.content {
flex: 1;
padding: 24px;
overflow-y: auto;
background: var(--bg-content);
}
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.15s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
</style>

23
src/main.ts Normal file
View File

@ -0,0 +1,23 @@
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import ElementPlus from 'element-plus'
import * as ElementPlusIconsVue from '@element-plus/icons-vue'
import 'element-plus/dist/index.css'
import 'element-plus/theme-chalk/dark/css-vars.css'
import App from './App.vue'
import router from './router'
import './styles/main.scss'
const app = createApp(App)
// 注册所有 Element Plus 图标
for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
app.component(key, component)
}
app.use(createPinia())
app.use(router)
app.use(ElementPlus)
app.mount('#app')

64
src/router/index.ts Normal file
View File

@ -0,0 +1,64 @@
import { createRouter, createWebHashHistory } from 'vue-router'
const router = createRouter({
history: createWebHashHistory(),
routes: [
{
path: '/',
name: 'dashboard',
component: () => import('@/views/Dashboard.vue'),
meta: { title: '仪表盘' }
},
{
path: '/php',
name: 'php',
component: () => import('@/views/PhpManager.vue'),
meta: { title: 'PHP 管理' }
},
{
path: '/mysql',
name: 'mysql',
component: () => import('@/views/MysqlManager.vue'),
meta: { title: 'MySQL 管理' }
},
{
path: '/nginx',
name: 'nginx',
component: () => import('@/views/NginxManager.vue'),
meta: { title: 'Nginx 管理' }
},
{
path: '/redis',
name: 'redis',
component: () => import('@/views/RedisManager.vue'),
meta: { title: 'Redis 管理' }
},
{
path: '/nodejs',
name: 'nodejs',
component: () => import('@/views/NodeManager.vue'),
meta: { title: 'Node.js 管理' }
},
{
path: '/sites',
name: 'sites',
component: () => import('@/views/SitesManager.vue'),
meta: { title: '站点管理' }
},
{
path: '/hosts',
name: 'hosts',
component: () => import('@/views/HostsManager.vue'),
meta: { title: 'Hosts 管理' }
},
{
path: '/settings',
name: 'settings',
component: () => import('@/views/Settings.vue'),
meta: { title: '设置' }
}
]
})
export default router

549
src/styles/main.scss Normal file
View File

@ -0,0 +1,549 @@
// ==================== 变量定义 ====================
:root {
// 浅色主题默认
--bg-primary: #f8fafc;
--bg-sidebar: #ffffff;
--bg-content: #f1f5f9;
--bg-card: #ffffff;
--bg-titlebar: #ffffff;
--bg-hover: rgba(0, 0, 0, 0.05);
--bg-input: #f8fafc;
--text-primary: #1e293b;
--text-secondary: #64748b;
--text-muted: #94a3b8;
--border-color: #e2e8f0;
--border-light: #f1f5f9;
--accent-color: #7c3aed;
--accent-light: #a78bfa;
--accent-gradient: linear-gradient(135deg, #7c3aed 0%, #a855f7 100%);
--success-color: #10b981;
--warning-color: #f59e0b;
--error-color: #ef4444;
--info-color: #3b82f6;
--shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
--shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
--shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1);
}
// 深色主题
.dark {
--bg-primary: #0f0f1a;
--bg-sidebar: #151525;
--bg-content: #0f0f1a;
--bg-card: #1a1a2e;
--bg-titlebar: #151525;
--bg-hover: rgba(255, 255, 255, 0.08);
--bg-input: #1a1a2e;
--text-primary: #f1f5f9;
--text-secondary: #94a3b8;
--text-muted: #64748b;
--border-color: #2d2d44;
--border-light: #232338;
--accent-color: #a78bfa;
--accent-light: #c4b5fd;
--accent-gradient: linear-gradient(135deg, #7c3aed 0%, #a855f7 100%);
--shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.3);
--shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.4);
--shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.5);
}
// ==================== 基础样式 ====================
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html, body {
font-family: 'Inter', 'Noto Sans SC', 'Microsoft YaHei', -apple-system, BlinkMacSystemFont, sans-serif;
font-size: 14px;
line-height: 1.6;
color: var(--text-primary);
background: var(--bg-primary);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
// 滚动条样式
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: var(--border-color);
border-radius: 4px;
&:hover {
background: var(--text-muted);
}
}
// ==================== 通用组件样式 ====================
.page-container {
animation: fadeIn 0.3s ease;
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.page-header {
margin-bottom: 24px;
.page-title {
font-size: 28px;
font-weight: 700;
color: var(--text-primary);
margin-bottom: 8px;
display: flex;
align-items: center;
gap: 12px;
.title-icon {
width: 36px;
height: 36px;
display: flex;
align-items: center;
justify-content: center;
background: var(--accent-gradient);
border-radius: 10px;
color: white;
font-size: 20px;
}
}
.page-description {
color: var(--text-secondary);
font-size: 14px;
}
}
.card {
background: var(--bg-card);
border-radius: 16px;
border: 1px solid var(--border-color);
padding: 24px;
margin-bottom: 24px;
transition: all 0.3s;
&:hover {
box-shadow: var(--shadow-md);
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
padding-bottom: 16px;
border-bottom: 1px solid var(--border-light);
.card-title {
font-size: 18px;
font-weight: 600;
color: var(--text-primary);
display: flex;
align-items: center;
gap: 10px;
}
.card-actions {
display: flex;
gap: 8px;
}
}
.card-content {
// 内容区域样式
}
}
// 状态标签
.status-tag {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 4px 12px;
border-radius: 20px;
font-size: 12px;
font-weight: 500;
&.running {
background: rgba(16, 185, 129, 0.15);
color: var(--success-color);
}
&.stopped {
background: rgba(239, 68, 68, 0.15);
color: var(--error-color);
}
&.warning {
background: rgba(245, 158, 11, 0.15);
color: var(--warning-color);
}
.status-dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: currentColor;
animation: pulse 2s infinite;
}
}
@keyframes pulse {
0%, 100% {
opacity: 1;
}
50% {
opacity: 0.5;
}
}
// 版本卡片
.version-card {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 20px;
background: var(--bg-input);
border-radius: 12px;
border: 1px solid var(--border-color);
margin-bottom: 12px;
transition: all 0.2s;
&:hover {
border-color: var(--accent-color);
box-shadow: 0 0 0 3px rgba(124, 58, 237, 0.1);
}
&.active {
border-color: var(--accent-color);
background: rgba(124, 58, 237, 0.05);
}
.version-info {
display: flex;
align-items: center;
gap: 16px;
.version-icon {
width: 48px;
height: 48px;
display: flex;
align-items: center;
justify-content: center;
background: var(--accent-gradient);
border-radius: 12px;
color: white;
font-size: 24px;
}
.version-details {
.version-name {
font-size: 16px;
font-weight: 600;
color: var(--text-primary);
margin-bottom: 4px;
}
.version-path {
font-size: 12px;
color: var(--text-muted);
font-family: 'Fira Code', 'Consolas', monospace;
}
}
}
.version-actions {
display: flex;
align-items: center;
gap: 8px;
}
}
// ==================== Element Plus 覆盖 ====================
.el-button {
border-radius: 8px;
font-weight: 500;
&--primary {
background: var(--accent-gradient);
border: none;
&:hover, &:focus {
background: linear-gradient(135deg, #6d28d9 0%, #9333ea 100%);
}
}
}
.el-input {
--el-input-bg-color: var(--bg-input);
--el-input-border-color: var(--border-color);
--el-input-text-color: var(--text-primary);
--el-input-placeholder-color: var(--text-muted);
.el-input__wrapper {
border-radius: 10px;
box-shadow: none;
border: 1px solid var(--border-color);
background: var(--bg-input);
&:hover {
border-color: var(--accent-light);
}
&.is-focus {
border-color: var(--accent-color);
box-shadow: 0 0 0 3px rgba(124, 58, 237, 0.15);
}
}
}
.el-select {
.el-input__wrapper {
border-radius: 10px;
}
}
.el-dialog {
border-radius: 16px;
overflow: hidden;
.el-dialog__header {
background: var(--bg-sidebar);
border-bottom: 1px solid var(--border-color);
padding: 20px 24px;
margin: 0;
}
.el-dialog__body {
padding: 24px;
}
.el-dialog__footer {
padding: 16px 24px;
border-top: 1px solid var(--border-color);
}
}
.el-table {
--el-table-bg-color: transparent;
--el-table-tr-bg-color: transparent;
--el-table-header-bg-color: var(--bg-input);
--el-table-row-hover-bg-color: var(--bg-hover);
--el-table-border-color: var(--border-color);
--el-table-text-color: var(--text-primary);
--el-table-header-text-color: var(--text-secondary);
border-radius: 12px;
overflow: hidden;
border: 1px solid var(--border-color);
th.el-table__cell {
font-weight: 600;
}
}
.el-message {
border-radius: 10px;
border: none;
box-shadow: var(--shadow-lg);
}
.el-notification {
border-radius: 12px;
border: 1px solid var(--border-color);
}
// 暗色主题下的 Element Plus 调整
.dark {
.el-button {
--el-button-bg-color: var(--bg-input);
--el-button-border-color: var(--border-color);
--el-button-text-color: var(--text-primary);
&:hover {
--el-button-hover-bg-color: var(--bg-hover);
--el-button-hover-border-color: var(--accent-light);
--el-button-hover-text-color: var(--accent-light);
}
}
.el-dialog {
--el-dialog-bg-color: var(--bg-card);
}
.el-message-box {
--el-messagebox-title-color: var(--text-primary);
--el-messagebox-content-color: var(--text-secondary);
background: var(--bg-card);
border: 1px solid var(--border-color);
border-radius: 16px;
}
.el-select-dropdown {
--el-bg-color-overlay: var(--bg-card);
border: 1px solid var(--border-color);
border-radius: 10px;
}
.el-popper {
--el-bg-color-overlay: var(--bg-card);
}
}
// ==================== 代码编辑器样式 ====================
.code-editor {
font-family: 'Fira Code', 'Consolas', 'Monaco', monospace;
font-size: 13px;
line-height: 1.6;
background: var(--bg-input);
border: 1px solid var(--border-color);
border-radius: 12px;
padding: 16px;
min-height: 300px;
resize: vertical;
color: var(--text-primary);
&:focus {
outline: none;
border-color: var(--accent-color);
box-shadow: 0 0 0 3px rgba(124, 58, 237, 0.15);
}
}
// ==================== 动画效果 ====================
.slide-up-enter-active,
.slide-up-leave-active {
transition: all 0.3s ease;
}
.slide-up-enter-from {
opacity: 0;
transform: translateY(20px);
}
.slide-up-leave-to {
opacity: 0;
transform: translateY(-20px);
}
// 加载状态
.loading-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(var(--bg-primary), 0.8);
display: flex;
align-items: center;
justify-content: center;
z-index: 100;
backdrop-filter: blur(4px);
}
.spinner {
width: 40px;
height: 40px;
border: 3px solid var(--border-color);
border-top-color: var(--accent-color);
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
// 空状态
.empty-state {
text-align: center;
padding: 60px 20px;
.empty-icon {
font-size: 64px;
color: var(--text-muted);
margin-bottom: 16px;
}
.empty-title {
font-size: 18px;
font-weight: 600;
color: var(--text-primary);
margin-bottom: 8px;
}
.empty-description {
color: var(--text-secondary);
margin-bottom: 24px;
}
}
// 下载进度条
.download-progress {
margin-top: 20px;
padding: 16px;
background: var(--bg-input);
border-radius: 12px;
border: 1px solid var(--border-color);
.progress-info {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 10px;
font-size: 13px;
color: var(--text-secondary);
span:first-child {
font-weight: 500;
color: var(--text-primary);
}
}
.el-progress {
.el-progress-bar__outer {
background-color: var(--border-color);
border-radius: 8px;
}
.el-progress-bar__inner {
background: var(--accent-gradient);
border-radius: 8px;
transition: width 0.3s ease;
}
.el-progress__text {
color: var(--accent-color);
font-weight: 600;
font-size: 13px !important;
}
}
}

597
src/views/Dashboard.vue Normal file
View File

@ -0,0 +1,597 @@
<template>
<div class="page-container">
<div class="page-header">
<h1 class="page-title">
<span class="title-icon"><el-icon><Odometer /></el-icon></span>
仪表盘
</h1>
<p class="page-description">服务状态概览与快捷操作</p>
</div>
<!-- 服务状态卡片 -->
<div class="status-grid">
<div
v-for="service in services"
:key="service.name"
class="status-card"
:class="{ running: service.running }"
>
<div class="status-header">
<div class="service-icon" :style="{ background: service.gradient }">
<el-icon><component :is="service.icon" /></el-icon>
</div>
<div class="service-info">
<h3 class="service-name">{{ service.displayName }}</h3>
<span class="status-tag" :class="service.running ? 'running' : 'stopped'">
<span class="status-dot"></span>
{{ service.running ? '运行中' : '已停止' }}
</span>
</div>
</div>
<div class="status-actions">
<el-button
v-if="!service.running"
type="success"
size="small"
@click="startService(service)"
:loading="service.loading"
>
<el-icon><VideoPlay /></el-icon>
启动
</el-button>
<el-button
v-else
type="danger"
size="small"
@click="stopService(service)"
:loading="service.loading"
>
<el-icon><VideoPause /></el-icon>
停止
</el-button>
<el-button
size="small"
@click="restartService(service)"
:loading="service.loading"
:disabled="!service.running"
>
<el-icon><RefreshRight /></el-icon>
重启
</el-button>
</div>
</div>
</div>
<!-- 快捷信息 -->
<div class="info-grid">
<!-- PHP 版本 -->
<div class="card">
<div class="card-header">
<span class="card-title">
<el-icon><Files /></el-icon>
PHP 版本
</span>
<router-link to="/php" class="view-more">
管理 <el-icon><ArrowRight /></el-icon>
</router-link>
</div>
<div class="card-content">
<div v-if="phpVersions.length > 0" class="version-list">
<div
v-for="version in phpVersions"
:key="version.version"
class="mini-version-card"
:class="{ active: version.isActive }"
>
<span class="version-number">PHP {{ version.version }}</span>
<div class="version-actions">
<el-tag v-if="version.isActive" type="success" size="small">当前</el-tag>
<el-button
v-else
type="primary"
size="small"
@click="setActivePhp(version.version)"
:loading="settingPhp === version.version"
>
设为默认
</el-button>
</div>
</div>
</div>
<div v-else class="empty-hint">
<span>暂未安装 PHP</span>
<router-link to="/php">去安装</router-link>
</div>
</div>
</div>
<!-- Node.js 版本 -->
<div class="card">
<div class="card-header">
<span class="card-title">
<el-icon><Promotion /></el-icon>
Node.js 版本
</span>
<router-link to="/nodejs" class="view-more">
管理 <el-icon><ArrowRight /></el-icon>
</router-link>
</div>
<div class="card-content">
<div v-if="nodeVersions.length > 0" class="version-list">
<div
v-for="version in nodeVersions"
:key="version.version"
class="mini-version-card"
:class="{ active: version.isActive }"
>
<span class="version-number">Node {{ version.version }}</span>
<div class="version-actions">
<el-tag v-if="version.isActive" type="success" size="small">当前</el-tag>
<el-button
v-else
type="primary"
size="small"
@click="setActiveNode(version.version)"
:loading="settingNode === version.version"
>
设为默认
</el-button>
</div>
</div>
</div>
<div v-else class="empty-hint">
<span>暂未安装 Node.js</span>
<router-link to="/nodejs">去安装</router-link>
</div>
</div>
</div>
</div>
<!-- 站点信息 -->
<div class="info-grid single">
<!-- 站点列表 -->
<div class="card">
<div class="card-header">
<span class="card-title">
<el-icon><Monitor /></el-icon>
站点列表
</span>
<router-link to="/sites" class="view-more">
管理 <el-icon><ArrowRight /></el-icon>
</router-link>
</div>
<div class="card-content">
<div v-if="sites.length > 0" class="site-list">
<div
v-for="site in sites.slice(0, 5)"
:key="site.name"
class="mini-site-card"
>
<a
:href="(site.ssl ? 'https://' : 'http://') + site.domain"
target="_blank"
class="site-domain-link"
@click.stop
>
{{ site.domain }}
<el-icon class="link-icon"><Link /></el-icon>
</a>
<div class="site-tags">
<el-tag v-if="site.ssl" type="success" size="small">SSL</el-tag>
<el-tag v-if="site.isLaravel" type="warning" size="small">Laravel</el-tag>
</div>
</div>
</div>
<div v-else class="empty-hint">
<span>暂无站点</span>
<router-link to="/sites">添加站点</router-link>
</div>
</div>
</div>
</div>
<!-- 系统信息 -->
<div class="card">
<div class="card-header">
<span class="card-title">
<el-icon><InfoFilled /></el-icon>
安装路径
</span>
<el-button size="small" @click="openBasePath">
<el-icon><FolderOpened /></el-icon>
打开目录
</el-button>
</div>
<div class="card-content">
<div class="path-display">
<span class="path-label">基础路径</span>
<code class="path-value">{{ basePath }}</code>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, reactive } from 'vue'
import { ElMessage } from 'element-plus'
import { Link, Promotion } from '@element-plus/icons-vue'
interface Service {
name: string
displayName: string
icon: string
gradient: string
running: boolean
loading: boolean
}
const services = reactive<Service[]>([
{ name: 'nginx', displayName: 'Nginx', icon: 'Connection', gradient: 'linear-gradient(135deg, #009639 0%, #0ecc5a 100%)', running: false, loading: false },
{ name: 'mysql', displayName: 'MySQL', icon: 'Coin', gradient: 'linear-gradient(135deg, #00758f 0%, #00b4d8 100%)', running: false, loading: false },
{ name: 'redis', displayName: 'Redis', icon: 'Grid', gradient: 'linear-gradient(135deg, #dc382d 0%, #ff6b6b 100%)', running: false, loading: false }
])
const phpVersions = ref<any[]>([])
const nodeVersions = ref<any[]>([])
const sites = ref<any[]>([])
const basePath = ref('')
const settingPhp = ref('')
const settingNode = ref('')
const loadData = async () => {
try {
//
const allServices = await window.electronAPI?.service.getAll()
if (allServices) {
for (const svc of allServices) {
const found = services.find(s => s.name === svc.name || svc.name.startsWith(s.name))
if (found) {
found.running = svc.running
}
}
}
// PHP
phpVersions.value = await window.electronAPI?.php.getVersions() || []
// Node.js
nodeVersions.value = await window.electronAPI?.node.getVersions() || []
//
sites.value = await window.electronAPI?.nginx.getSites() || []
//
basePath.value = await window.electronAPI?.config.getBasePath() || ''
} catch (error: any) {
console.error('加载数据失败:', error)
}
}
const startService = async (service: Service) => {
service.loading = true
try {
let result
if (service.name === 'nginx') {
result = await window.electronAPI?.nginx.start()
} else if (service.name === 'mysql') {
const versions = await window.electronAPI?.mysql.getVersions()
if (versions?.length > 0) {
result = await window.electronAPI?.mysql.start(versions[0].version)
} else {
result = { success: false, message: 'MySQL 未安装' }
}
} else if (service.name === 'redis') {
result = await window.electronAPI?.redis.start()
}
if (result?.success) {
service.running = true
ElMessage.success(result.message)
} else {
ElMessage.error(result?.message || '启动失败')
}
} catch (error: any) {
ElMessage.error(error.message)
} finally {
service.loading = false
}
}
const stopService = async (service: Service) => {
service.loading = true
try {
let result
if (service.name === 'nginx') {
result = await window.electronAPI?.nginx.stop()
} else if (service.name === 'mysql') {
const versions = await window.electronAPI?.mysql.getVersions()
if (versions?.length > 0) {
result = await window.electronAPI?.mysql.stop(versions[0].version)
}
} else if (service.name === 'redis') {
result = await window.electronAPI?.redis.stop()
}
if (result?.success) {
service.running = false
ElMessage.success(result.message)
} else {
ElMessage.error(result?.message || '停止失败')
}
} catch (error: any) {
ElMessage.error(error.message)
} finally {
service.loading = false
}
}
const restartService = async (service: Service) => {
service.loading = true
try {
let result
if (service.name === 'nginx') {
result = await window.electronAPI?.nginx.restart()
} else if (service.name === 'mysql') {
const versions = await window.electronAPI?.mysql.getVersions()
if (versions?.length > 0) {
result = await window.electronAPI?.mysql.restart(versions[0].version)
}
} else if (service.name === 'redis') {
result = await window.electronAPI?.redis.restart()
}
if (result?.success) {
ElMessage.success(result.message)
} else {
ElMessage.error(result?.message || '重启失败')
}
} catch (error: any) {
ElMessage.error(error.message)
} finally {
service.loading = false
}
}
const openBasePath = async () => {
if (basePath.value) {
await window.electronAPI?.openPath(basePath.value)
}
}
const setActivePhp = async (version: string) => {
settingPhp.value = version
try {
const result = await window.electronAPI?.php.setActive(version)
if (result?.success) {
ElMessage.success(result.message)
// PHP
phpVersions.value = await window.electronAPI?.php.getVersions() || []
} else {
ElMessage.error(result?.message || '设置失败')
}
} catch (error: any) {
ElMessage.error(error.message)
} finally {
settingPhp.value = ''
}
}
const setActiveNode = async (version: string) => {
settingNode.value = version
try {
const result = await window.electronAPI?.node.setActive(version)
if (result?.success) {
ElMessage.success(result.message)
// Node.js
nodeVersions.value = await window.electronAPI?.node.getVersions() || []
} else {
ElMessage.error(result?.message || '设置失败')
}
} catch (error: any) {
ElMessage.error(error.message)
} finally {
settingNode.value = ''
}
}
onMounted(() => {
loadData()
// 10
setInterval(loadData, 10000)
})
</script>
<style lang="scss" scoped>
.status-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 20px;
margin-bottom: 24px;
}
.status-card {
background: var(--bg-card);
border-radius: 16px;
border: 1px solid var(--border-color);
padding: 24px;
transition: all 0.3s;
&:hover {
box-shadow: var(--shadow-md);
transform: translateY(-2px);
}
&.running {
border-color: var(--success-color);
box-shadow: 0 0 20px rgba(16, 185, 129, 0.1);
}
.status-header {
display: flex;
align-items: center;
gap: 16px;
margin-bottom: 20px;
}
.service-icon {
width: 56px;
height: 56px;
border-radius: 14px;
display: flex;
align-items: center;
justify-content: center;
color: white;
font-size: 28px;
}
.service-info {
.service-name {
font-size: 18px;
font-weight: 600;
margin-bottom: 4px;
}
}
.status-actions {
display: flex;
gap: 8px;
}
}
.info-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(400px, 1fr));
gap: 24px;
margin-bottom: 24px;
&.single {
grid-template-columns: 1fr;
}
}
.version-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.mini-version-card {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 16px;
background: var(--bg-input);
border-radius: 10px;
border: 1px solid var(--border-color);
&.active {
border-color: var(--accent-color);
background: rgba(124, 58, 237, 0.05);
}
.version-number {
font-weight: 500;
}
.version-actions {
display: flex;
align-items: center;
gap: 8px;
}
}
.site-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.mini-site-card {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 16px;
background: var(--bg-input);
border-radius: 10px;
border: 1px solid var(--border-color);
.site-domain-link {
display: flex;
align-items: center;
gap: 6px;
font-weight: 500;
font-family: 'Fira Code', monospace;
color: var(--accent-color);
text-decoration: none;
transition: all 0.2s;
.link-icon {
font-size: 12px;
opacity: 0;
transition: opacity 0.2s;
}
&:hover {
text-decoration: underline;
.link-icon {
opacity: 1;
}
}
}
.site-tags {
display: flex;
gap: 6px;
}
}
.empty-hint {
padding: 24px;
text-align: center;
color: var(--text-muted);
a {
color: var(--accent-color);
margin-left: 8px;
text-decoration: none;
&:hover {
text-decoration: underline;
}
}
}
.view-more {
display: flex;
align-items: center;
gap: 4px;
color: var(--accent-color);
text-decoration: none;
font-size: 13px;
&:hover {
text-decoration: underline;
}
}
.path-display {
display: flex;
align-items: center;
gap: 12px;
padding: 16px;
background: var(--bg-input);
border-radius: 10px;
.path-label {
color: var(--text-secondary);
}
.path-value {
font-family: 'Fira Code', monospace;
color: var(--accent-color);
}
}
</style>

205
src/views/HostsManager.vue Normal file
View File

@ -0,0 +1,205 @@
<template>
<div class="page-container">
<div class="page-header">
<h1 class="page-title">
<span class="title-icon"><el-icon><Document /></el-icon></span>
Hosts 管理
</h1>
<p class="page-description">管理系统 hosts 文件添加或删除域名映射</p>
</div>
<!-- Hosts 列表 -->
<div class="card">
<div class="card-header">
<span class="card-title">
<el-icon><List /></el-icon>
Hosts 条目
</span>
<div class="card-actions">
<el-button @click="flushDns">
<el-icon><Refresh /></el-icon>
刷新 DNS
</el-button>
<el-button type="primary" @click="showAddDialog = true">
<el-icon><Plus /></el-icon>
添加条目
</el-button>
</div>
</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>
<el-table :data="hosts" style="width: 100%">
<el-table-column prop="ip" label="IP 地址" width="180" />
<el-table-column prop="domain" label="域名">
<template #default="{ row }">
<span class="domain-text">{{ row.domain }}</span>
</template>
</el-table-column>
<el-table-column prop="comment" label="备注" />
<el-table-column label="操作" width="100" fixed="right">
<template #default="{ row }">
<el-button
type="danger"
size="small"
link
@click="removeHost(row.domain)"
>
删除
</el-button>
</template>
</el-table-column>
</el-table>
<div v-if="hosts.length === 0" class="empty-hint">
暂无自定义 hosts 条目
</div>
</div>
</div>
</div>
<!-- 添加对话框 -->
<el-dialog
v-model="showAddDialog"
title="添加 Hosts 条目"
width="500px"
>
<el-form :model="hostForm" label-width="80px">
<el-form-item label="IP 地址" required>
<el-input v-model="hostForm.ip" placeholder="例如: 127.0.0.1" />
</el-form-item>
<el-form-item label="域名" required>
<el-input v-model="hostForm.domain" placeholder="例如: mysite.test" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="showAddDialog = false">取消</el-button>
<el-button type="primary" @click="addHost" :loading="adding">
添加
</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
interface HostEntry {
ip: string
domain: string
comment?: string
}
const loading = ref(false)
const hosts = ref<HostEntry[]>([])
const showAddDialog = ref(false)
const adding = ref(false)
const hostForm = reactive({
ip: '127.0.0.1',
domain: ''
})
const loadHosts = async () => {
try {
hosts.value = await window.electronAPI?.hosts.get() || []
} catch (error: any) {
console.error('加载 hosts 失败:', error)
}
}
const addHost = async () => {
if (!hostForm.ip || !hostForm.domain) {
ElMessage.warning('请填写 IP 地址和域名')
return
}
adding.value = true
try {
const result = await window.electronAPI?.hosts.add(hostForm.domain, hostForm.ip)
if (result?.success) {
ElMessage.success(result.message)
showAddDialog.value = false
hostForm.domain = ''
await loadHosts()
} else {
ElMessage.error(result?.message || '添加失败')
}
} catch (error: any) {
ElMessage.error(error.message)
} finally {
adding.value = false
}
}
const removeHost = async (domain: string) => {
try {
await ElMessageBox.confirm(
`确定要删除 ${domain} 吗?`,
'确认删除',
{ type: 'warning' }
)
const result = await window.electronAPI?.hosts.remove(domain)
if (result?.success) {
ElMessage.success(result.message)
await loadHosts()
} else {
ElMessage.error(result?.message || '删除失败')
}
} catch (error: any) {
if (error !== 'cancel') {
ElMessage.error(error.message)
}
}
}
const flushDns = async () => {
try {
ElMessage.success('DNS 缓存已刷新')
} catch (error: any) {
ElMessage.error(error.message)
}
}
onMounted(() => {
loadHosts()
})
</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); }
}
.domain-text {
font-family: 'Fira Code', monospace;
color: var(--accent-color);
}
.empty-hint {
text-align: center;
padding: 40px;
color: var(--text-muted);
}
</style>

555
src/views/MysqlManager.vue Normal file
View File

@ -0,0 +1,555 @@
<template>
<div class="page-container">
<div class="page-header">
<h1 class="page-title">
<span class="title-icon"><el-icon><Coin /></el-icon></span>
MySQL 管理
</h1>
<p class="page-description">安装配置和管理 MySQL 数据库服务</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"><Coin /></el-icon>
<h3 class="empty-title">暂未安装 MySQL</h3>
<p class="empty-description">点击上方按钮安装 MySQL 数据库</p>
</div>
<div v-else>
<div
v-for="version in installedVersions"
:key="version.version"
class="version-card"
:class="{ running: version.isRunning }"
>
<div class="version-info">
<div class="version-icon mysql-icon">
<el-icon><Coin /></el-icon>
</div>
<div class="version-details">
<div class="version-name">
MySQL {{ version.version }}
<span class="status-tag" :class="version.isRunning ? 'running' : 'stopped'">
<span class="status-dot"></span>
{{ version.isRunning ? '运行中' : '已停止' }}
</span>
</div>
<div class="version-path">{{ version.path }}</div>
</div>
</div>
<div class="version-actions">
<el-button
v-if="!version.isRunning"
type="success"
size="small"
@click="start(version.version)"
:loading="actionLoading === version.version"
>
<el-icon><VideoPlay /></el-icon>
启动
</el-button>
<el-button
v-else
type="warning"
size="small"
@click="stop(version.version)"
:loading="actionLoading === version.version"
>
<el-icon><VideoPause /></el-icon>
停止
</el-button>
<el-button
size="small"
@click="restart(version.version)"
:loading="actionLoading === version.version"
:disabled="!version.isRunning"
>
<el-icon><RefreshRight /></el-icon>
重启
</el-button>
<el-button size="small" @click="showPasswordDialog(version.version)">
<el-icon><Key /></el-icon>
密码
</el-button>
<el-button size="small" @click="showConfig(version)">
<el-icon><Document /></el-icon>
配置
</el-button>
<el-button
type="danger"
size="small"
@click="uninstall(version.version)"
:disabled="version.isRunning"
>
<el-icon><Delete /></el-icon>
卸载
</el-button>
</div>
</div>
</div>
</div>
</div>
<!-- 安装对话框 -->
<el-dialog
v-model="showInstallDialog"
title="安装 MySQL"
width="600px"
>
<el-alert type="info" :closable="false" class="mb-4">
<template #title>
安装说明
</template>
MySQL 将从阿里云镜像站下载安装后自动设置 root 密码为 123456
</el-alert>
<div 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">MySQL {{ version.version }}</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>
<!-- 修改密码对话框 -->
<el-dialog
v-model="passwordDialogVisible"
title="修改 root 密码"
width="450px"
>
<el-form :model="passwordForm" label-width="100px">
<el-form-item label="当前密码">
<el-input
v-model="passwordForm.currentPassword"
type="password"
show-password
placeholder="请输入当前密码(默认 123456"
/>
</el-form-item>
<el-form-item label="新密码">
<el-input
v-model="passwordForm.newPassword"
type="password"
show-password
placeholder="请输入新密码"
/>
</el-form-item>
<el-form-item label="确认密码">
<el-input
v-model="passwordForm.confirmPassword"
type="password"
show-password
placeholder="请再次输入密码"
/>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="passwordDialogVisible = false">取消</el-button>
<el-button
type="primary"
@click="changePassword"
:loading="changingPassword"
>
确认修改
</el-button>
</template>
</el-dialog>
<!-- 配置编辑对话框 -->
<el-dialog
v-model="showConfigDialog"
title="编辑 my.ini"
width="900px"
>
<textarea
v-model="configContent"
class="code-editor"
spellcheck="false"
></textarea>
<template #footer>
<el-button @click="showConfigDialog = false">取消</el-button>
<el-button type="primary" @click="saveConfig" :loading="savingConfig">
保存配置
</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 MysqlVersion {
version: string
path: string
isRunning: boolean
}
interface AvailableVersion {
version: string
downloadUrl: string
}
const loading = ref(false)
const installedVersions = ref<MysqlVersion[]>([])
const availableVersions = ref<AvailableVersion[]>([])
const showInstallDialog = ref(false)
const selectedVersion = ref('')
const installing = ref(false)
const actionLoading = ref('')
const downloadProgress = reactive({
progress: 0,
downloaded: 0,
total: 0
})
const passwordDialogVisible = ref(false)
const currentPasswordVersion = ref('')
const passwordForm = reactive({
currentPassword: '123456',
newPassword: '',
confirmPassword: ''
})
const changingPassword = ref(false)
const showConfigDialog = ref(false)
const configContent = ref('')
const savingConfig = ref(false)
const currentVersion = ref('')
const loadVersions = async () => {
try {
installedVersions.value = await window.electronAPI?.mysql.getVersions() || []
} catch (error: any) {
console.error('加载版本失败:', error)
}
}
const loadAvailableVersions = async () => {
try {
availableVersions.value = await window.electronAPI?.mysql.getAvailableVersions() || []
} 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?.mysql.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(
`确定要卸载 MySQL ${version} 吗?数据库文件将被删除,此操作不可恢复。`,
'确认卸载',
{ type: 'warning' }
)
const result = await window.electronAPI?.mysql.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 start = async (version: string) => {
actionLoading.value = version
try {
const result = await window.electronAPI?.mysql.start(version)
if (result?.success) {
ElMessage.success(result.message)
await loadVersions()
} else {
ElMessage.error(result?.message || '启动失败')
}
} catch (error: any) {
ElMessage.error(error.message)
} finally {
actionLoading.value = ''
}
}
const stop = async (version: string) => {
actionLoading.value = version
try {
const result = await window.electronAPI?.mysql.stop(version)
if (result?.success) {
ElMessage.success(result.message)
await loadVersions()
} else {
ElMessage.error(result?.message || '停止失败')
}
} catch (error: any) {
ElMessage.error(error.message)
} finally {
actionLoading.value = ''
}
}
const restart = async (version: string) => {
actionLoading.value = version
try {
const result = await window.electronAPI?.mysql.restart(version)
if (result?.success) {
ElMessage.success(result.message)
await loadVersions()
} else {
ElMessage.error(result?.message || '重启失败')
}
} catch (error: any) {
ElMessage.error(error.message)
} finally {
actionLoading.value = ''
}
}
const showPasswordDialog = (version: string) => {
currentPasswordVersion.value = version
passwordForm.currentPassword = '123456'
passwordForm.newPassword = ''
passwordForm.confirmPassword = ''
passwordDialogVisible.value = true
}
const changePassword = async () => {
if (!passwordForm.currentPassword) {
ElMessage.error('请输入当前密码')
return
}
if (passwordForm.newPassword !== passwordForm.confirmPassword) {
ElMessage.error('两次输入的密码不一致')
return
}
changingPassword.value = true
try {
const result = await window.electronAPI?.mysql.changePassword(
currentPasswordVersion.value,
passwordForm.newPassword,
passwordForm.currentPassword
)
if (result?.success) {
ElMessage.success(result.message)
passwordDialogVisible.value = false
} else {
ElMessage.error(result?.message || '修改失败')
}
} catch (error: any) {
ElMessage.error(error.message)
} finally {
changingPassword.value = false
}
}
const showConfig = async (version: MysqlVersion) => {
currentVersion.value = version.version
try {
configContent.value = await window.electronAPI?.mysql.getConfig(version.version) || ''
showConfigDialog.value = true
} catch (error: any) {
ElMessage.error('加载配置失败: ' + error.message)
}
}
const saveConfig = async () => {
savingConfig.value = true
try {
const result = await window.electronAPI?.mysql.saveConfig(currentVersion.value, configContent.value)
if (result?.success) {
ElMessage.success(result.message)
showConfigDialog.value = false
} 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(() => {
loadVersions()
loadAvailableVersions()
// 5
setInterval(loadVersions, 5000)
//
window.electronAPI?.onDownloadProgress((data: any) => {
if (data.type === 'mysql') {
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); }
}
.mb-4 {
margin-bottom: 16px;
}
.version-card {
&.running {
border-color: var(--success-color);
}
}
.mysql-icon {
background: linear-gradient(135deg, #00758f 0%, #00b4d8 100%) !important;
}
.available-versions {
max-height: 400px;
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 {
.version-number {
font-weight: 600;
font-size: 16px;
}
}
.check-icon {
color: var(--accent-color);
font-size: 20px;
}
}
.code-editor {
width: 100%;
height: 500px;
}
</style>

517
src/views/NginxManager.vue Normal file
View File

@ -0,0 +1,517 @@
<template>
<div class="page-container">
<div class="page-header">
<h1 class="page-title">
<span class="title-icon nginx-gradient"><el-icon><Connection /></el-icon></span>
Nginx 管理
</h1>
<p class="page-description">安装配置和管理 Nginx Web 服务器</p>
</div>
<!-- 服务状态 -->
<div class="card">
<div class="card-header">
<span class="card-title">
<el-icon><Connection /></el-icon>
服务状态
</span>
<div class="card-actions">
<el-button type="primary" @click="showInstallDialog = true" v-if="!currentVersion">
<el-icon><Plus /></el-icon>
安装 Nginx
</el-button>
</div>
</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="!currentVersion" class="empty-state">
<el-icon class="empty-icon"><Connection /></el-icon>
<h3 class="empty-title">暂未安装 Nginx</h3>
<p class="empty-description">点击上方按钮安装 Nginx</p>
</div>
<div v-else class="service-panel">
<div class="service-status">
<div class="status-main">
<div class="service-icon nginx-gradient">
<el-icon><Connection /></el-icon>
</div>
<div class="service-info">
<h3 class="service-name">Nginx {{ currentVersion }}</h3>
<span class="status-tag" :class="status.running ? 'running' : 'stopped'">
<span class="status-dot"></span>
{{ status.running ? '运行中' : '已停止' }}
<span v-if="status.pid"> (PID: {{ status.pid }})</span>
</span>
</div>
</div>
<div class="service-controls">
<el-button
v-if="!status.running"
type="success"
@click="start"
:loading="actionLoading"
>
<el-icon><VideoPlay /></el-icon>
启动
</el-button>
<el-button
v-else
type="warning"
@click="stop"
:loading="actionLoading"
>
<el-icon><VideoPause /></el-icon>
停止
</el-button>
<el-button @click="restart" :loading="actionLoading" :disabled="!status.running">
<el-icon><RefreshRight /></el-icon>
重启
</el-button>
<el-button @click="reload" :disabled="!status.running">
<el-icon><Refresh /></el-icon>
重载配置
</el-button>
<el-button @click="showConfig">
<el-icon><Document /></el-icon>
编辑配置
</el-button>
</div>
</div>
</div>
</div>
</div>
<!-- 可用版本 -->
<div class="card" v-if="currentVersion">
<div class="card-header">
<span class="card-title">
<el-icon><Tickets /></el-icon>
版本管理
</span>
</div>
<div class="card-content">
<div class="version-switcher">
<span class="current-version">当前版本: Nginx {{ currentVersion }}</span>
<el-button @click="showInstallDialog = true">
<el-icon><Refresh /></el-icon>
切换版本
</el-button>
<el-button type="danger" @click="uninstall" :disabled="status.running">
<el-icon><Delete /></el-icon>
卸载
</el-button>
</div>
</div>
</div>
<!-- 安装对话框 -->
<el-dialog
v-model="showInstallDialog"
title="安装/切换 Nginx 版本"
width="600px"
>
<div 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">Nginx {{ version.version }}</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>
<!-- 配置编辑对话框 -->
<el-dialog
v-model="showConfigDialog"
title="编辑 nginx.conf"
width="1000px"
>
<textarea
v-model="configContent"
class="code-editor"
spellcheck="false"
></textarea>
<template #footer>
<el-button @click="showConfigDialog = false">取消</el-button>
<el-button type="primary" @click="saveConfig" :loading="savingConfig">
保存配置
</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 NginxStatus {
running: boolean
pid?: number
}
interface AvailableVersion {
version: string
downloadUrl: string
}
const loading = ref(false)
const currentVersion = ref('')
const status = ref<NginxStatus>({ running: false })
const availableVersions = ref<AvailableVersion[]>([])
const showInstallDialog = ref(false)
const selectedVersion = ref('')
const installing = ref(false)
const actionLoading = ref(false)
const downloadProgress = reactive({
progress: 0,
downloaded: 0,
total: 0
})
const showConfigDialog = ref(false)
const configContent = ref('')
const savingConfig = ref(false)
const loadData = async () => {
try {
const versions = await window.electronAPI?.nginx.getVersions() || []
if (versions.length > 0) {
currentVersion.value = versions[0].version
}
status.value = await window.electronAPI?.nginx.getStatus() || { running: false }
} catch (error: any) {
console.error('加载数据失败:', error)
} finally {
}
}
const loadAvailableVersions = async () => {
try {
availableVersions.value = await window.electronAPI?.nginx.getAvailableVersions() || []
} catch (error: any) {
console.error('加载可用版本失败:', error)
}
}
const install = async () => {
if (!selectedVersion.value) return
//
downloadProgress.progress = 0
downloadProgress.downloaded = 0
downloadProgress.total = 0
installing.value = true
try {
//
if (status.value.running) {
await window.electronAPI?.nginx.stop()
}
const result = await window.electronAPI?.nginx.install(selectedVersion.value)
if (result?.success) {
ElMessage.success(result.message)
showInstallDialog.value = false
selectedVersion.value = ''
await loadData()
} 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 () => {
try {
await ElMessageBox.confirm(
'确定要卸载 Nginx 吗?站点配置将被保留。',
'确认卸载',
{ type: 'warning' }
)
const result = await window.electronAPI?.nginx.uninstall(currentVersion.value)
if (result?.success) {
ElMessage.success(result.message)
currentVersion.value = ''
await loadData()
} else {
ElMessage.error(result?.message || '卸载失败')
}
} catch (error: any) {
if (error !== 'cancel') {
ElMessage.error(error.message)
}
}
}
const start = async () => {
actionLoading.value = true
try {
const result = await window.electronAPI?.nginx.start()
if (result?.success) {
ElMessage.success(result.message)
await loadData()
} else {
ElMessage.error(result?.message || '启动失败')
}
} catch (error: any) {
ElMessage.error(error.message)
} finally {
actionLoading.value = false
}
}
const stop = async () => {
actionLoading.value = true
try {
const result = await window.electronAPI?.nginx.stop()
if (result?.success) {
ElMessage.success(result.message)
await loadData()
} else {
ElMessage.error(result?.message || '停止失败')
}
} catch (error: any) {
ElMessage.error(error.message)
} finally {
actionLoading.value = false
}
}
const restart = async () => {
actionLoading.value = true
try {
const result = await window.electronAPI?.nginx.restart()
if (result?.success) {
ElMessage.success(result.message)
await loadData()
} else {
ElMessage.error(result?.message || '重启失败')
}
} catch (error: any) {
ElMessage.error(error.message)
} finally {
actionLoading.value = false
}
}
const reload = async () => {
try {
const result = await window.electronAPI?.nginx.reload()
if (result?.success) {
ElMessage.success(result.message)
} else {
ElMessage.error(result?.message || '重载失败')
}
} catch (error: any) {
ElMessage.error(error.message)
}
}
const showConfig = async () => {
try {
configContent.value = await window.electronAPI?.nginx.getConfig() || ''
showConfigDialog.value = true
} catch (error: any) {
ElMessage.error('加载配置失败: ' + error.message)
}
}
const saveConfig = async () => {
savingConfig.value = true
try {
const result = await window.electronAPI?.nginx.saveConfig(configContent.value)
if (result?.success) {
ElMessage.success(result.message)
showConfigDialog.value = false
} 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(() => {
loadData()
loadAvailableVersions()
setInterval(loadData, 5000)
window.electronAPI?.onDownloadProgress((data: any) => {
if (data.type === 'nginx') {
downloadProgress.progress = data.progress
downloadProgress.downloaded = data.downloaded
downloadProgress.total = data.total
}
})
})
onUnmounted(() => {
window.electronAPI?.removeDownloadProgressListener()
})
</script>
<style lang="scss" scoped>
.nginx-gradient {
background: linear-gradient(135deg, #009639 0%, #0ecc5a 100%) !important;
}
.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); }
}
.service-panel {
.service-status {
display: flex;
flex-direction: column;
gap: 20px;
}
.status-main {
display: flex;
align-items: center;
gap: 20px;
}
.service-icon {
width: 64px;
height: 64px;
border-radius: 16px;
display: flex;
align-items: center;
justify-content: center;
color: white;
font-size: 32px;
}
.service-info {
.service-name {
font-size: 20px;
font-weight: 600;
margin-bottom: 8px;
}
}
.service-controls {
display: flex;
gap: 12px;
flex-wrap: wrap;
}
}
.version-switcher {
display: flex;
align-items: center;
gap: 16px;
.current-version {
font-weight: 500;
color: var(--text-secondary);
}
}
.available-versions {
max-height: 400px;
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 {
.version-number {
font-weight: 600;
font-size: 16px;
}
}
.check-icon {
color: var(--accent-color);
font-size: 20px;
}
}
.code-editor {
width: 100%;
height: 500px;
}
</style>

397
src/views/NodeManager.vue Normal file
View File

@ -0,0 +1,397 @@
<template>
<div class="page-container">
<div class="page-header">
<h1 class="page-title">
<span class="title-icon"><el-icon><Promotion /></el-icon></span>
Node.js 管理
</h1>
<p class="page-description">管理本地 Node.js 版本支持多版本切换</p>
</div>
<!-- 下载进度 -->
<div v-if="downloadProgress.percent > 0 && downloadProgress.percent < 100" class="download-progress">
<div class="progress-info">
<span>正在下载 Node.js...</span>
<span>{{ formatSize(downloadProgress.downloaded) }} / {{ formatSize(downloadProgress.total) }}</span>
</div>
<el-progress :percentage="downloadProgress.percent" :stroke-width="10" />
</div>
<!-- 已安装版本 -->
<div class="card">
<div class="card-header">
<span class="card-title">已安装版本</span>
<el-button type="primary" @click="showInstallDialog = true">
<el-icon><Plus /></el-icon>
安装新版本
</el-button>
</div>
<div class="card-content">
<div v-if="versions.length > 0" class="version-grid">
<div
v-for="version in versions"
:key="version.version"
class="version-card"
:class="{ active: version.isActive }"
>
<div class="version-main">
<div class="version-icon">
<el-icon :size="32"><Promotion /></el-icon>
</div>
<div class="version-content">
<div class="version-title">
<span class="version-number">Node.js {{ version.version }}</span>
<el-tag v-if="version.isActive" type="success" size="small" effect="dark">当前版本</el-tag>
</div>
<div class="version-meta">
<span v-if="version.npmVersion" class="npm-version">
<el-icon><Box /></el-icon>
npm {{ version.npmVersion }}
</span>
</div>
</div>
</div>
<div class="version-actions">
<el-button
v-if="!version.isActive"
type="primary"
size="small"
@click="setActiveVersion(version.version)"
:loading="settingActive === version.version"
>
设为默认
</el-button>
<el-button
type="danger"
size="small"
plain
@click="uninstallVersion(version.version)"
:loading="uninstalling === version.version"
>
卸载
</el-button>
</div>
</div>
</div>
<el-empty v-else description="暂未安装 Node.js" />
</div>
</div>
<!-- 安装新版本对话框 -->
<el-dialog
v-model="showInstallDialog"
title="安装 Node.js"
width="700px"
>
<div class="available-versions">
<el-table :data="availableVersions" style="width: 100%" max-height="400">
<el-table-column prop="version" label="版本" width="120" />
<el-table-column prop="date" label="发布日期" width="120" />
<el-table-column label="类型" width="120">
<template #default="{ row }">
<el-tag v-if="row.lts" type="success" size="small">LTS {{ row.lts }}</el-tag>
<el-tag v-else type="warning" size="small">Current</el-tag>
</template>
</el-table-column>
<el-table-column label="安全更新" width="100">
<template #default="{ row }">
<el-tag v-if="row.security" type="danger" size="small">安全</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="100">
<template #default="{ row }">
<el-button
v-if="!isInstalled(row.version)"
type="primary"
size="small"
@click="installVersion(row)"
:loading="installing === row.version"
>
安装
</el-button>
<el-tag v-else type="info" size="small">已安装</el-tag>
</template>
</el-table-column>
</el-table>
</div>
<template #footer>
<el-button @click="showInstallDialog = false">关闭</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, computed, onMounted, onUnmounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Plus, Promotion, Box } from '@element-plus/icons-vue'
interface NodeVersion {
version: string
path: string
isActive: boolean
npmVersion?: string
}
interface AvailableNodeVersion {
version: string
date: string
lts: string | false
security: boolean
downloadUrl: string
}
const versions = ref<NodeVersion[]>([])
const availableVersions = ref<AvailableNodeVersion[]>([])
const showInstallDialog = ref(false)
const installing = ref('')
const uninstalling = ref('')
const settingActive = ref('')
const downloadProgress = reactive({
percent: 0,
downloaded: 0,
total: 0
})
const loadVersions = async () => {
try {
versions.value = await window.electronAPI?.node.getVersions() || []
} catch (error: any) {
console.error('加载版本失败:', error)
}
}
const loadAvailableVersions = async () => {
try {
availableVersions.value = await window.electronAPI?.node.getAvailableVersions() || []
} catch (error: any) {
console.error('加载可用版本失败:', error)
}
}
const isInstalled = (version: string) => {
return versions.value.some(v => v.version === version)
}
const installVersion = async (row: AvailableNodeVersion) => {
installing.value = row.version
downloadProgress.percent = 0
downloadProgress.downloaded = 0
downloadProgress.total = 0
try {
const result = await window.electronAPI?.node.install(row.version, row.downloadUrl)
if (result?.success) {
ElMessage.success(result.message)
await loadVersions()
} else {
ElMessage.error(result?.message || '安装失败')
}
} catch (error: any) {
ElMessage.error(error.message)
} finally {
installing.value = ''
downloadProgress.percent = 0
}
}
const uninstallVersion = async (version: string) => {
try {
await ElMessageBox.confirm(
`确定要卸载 Node.js ${version} 吗?`,
'确认卸载',
{ type: 'warning' }
)
uninstalling.value = version
const result = await window.electronAPI?.node.uninstall(version)
if (result?.success) {
ElMessage.success(result.message)
await loadVersions()
} else {
ElMessage.error(result?.message || '卸载失败')
}
} catch (error: any) {
if (error !== 'cancel') {
ElMessage.error(error.message)
}
} finally {
uninstalling.value = ''
}
}
const setActiveVersion = async (version: string) => {
settingActive.value = version
try {
const result = await window.electronAPI?.node.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 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(2)) + ' ' + sizes[i]
}
//
const onDownloadProgress = (_event: any, data: any) => {
if (data.type === 'nodejs') {
downloadProgress.percent = data.progress
downloadProgress.downloaded = data.downloaded
downloadProgress.total = data.total
}
}
onMounted(() => {
loadVersions()
loadAvailableVersions()
window.electronAPI?.onDownloadProgress(onDownloadProgress)
})
onUnmounted(() => {
window.electronAPI?.removeDownloadProgressListener(onDownloadProgress)
})
</script>
<style lang="scss" scoped>
.version-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
gap: 20px;
}
.version-card {
background: var(--bg-input);
border: 1px solid var(--border-color);
border-radius: 16px;
padding: 24px;
transition: all 0.3s;
display: flex;
flex-direction: column;
gap: 20px;
&:hover {
border-color: var(--accent-color);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.15);
transform: translateY(-2px);
}
&.active {
border-color: var(--success-color);
background: linear-gradient(135deg, rgba(16, 185, 129, 0.08) 0%, rgba(16, 185, 129, 0.02) 100%);
.version-icon {
background: linear-gradient(135deg, #10b981 0%, #059669 100%);
}
}
.version-main {
display: flex;
align-items: flex-start;
gap: 16px;
}
.version-icon {
width: 56px;
height: 56px;
border-radius: 14px;
background: linear-gradient(135deg, #68a063 0%, #3c873a 100%);
display: flex;
align-items: center;
justify-content: center;
color: white;
flex-shrink: 0;
}
.version-content {
flex: 1;
min-width: 0;
}
.version-title {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 8px;
flex-wrap: wrap;
}
.version-number {
font-size: 20px;
font-weight: 700;
color: var(--text-primary);
letter-spacing: -0.5px;
}
.version-meta {
display: flex;
align-items: center;
gap: 16px;
flex-wrap: wrap;
}
.npm-version {
display: flex;
align-items: center;
gap: 6px;
font-size: 13px;
color: var(--text-secondary);
background: var(--bg-card);
padding: 4px 10px;
border-radius: 6px;
.el-icon {
color: #cb3837;
}
}
.version-actions {
display: flex;
gap: 10px;
padding-top: 4px;
border-top: 1px solid var(--border-color);
.el-button {
flex: 1;
}
}
}
.available-versions {
.el-table {
--el-table-bg-color: transparent;
--el-table-tr-bg-color: transparent;
--el-table-header-bg-color: var(--bg-input);
}
}
.download-progress {
background: var(--bg-card);
border: 1px solid var(--border-color);
border-radius: 12px;
padding: 16px;
margin-bottom: 20px;
.progress-info {
display: flex;
justify-content: space-between;
margin-bottom: 8px;
font-size: 14px;
color: var(--text-secondary);
}
}
</style>

835
src/views/PhpManager.vue Normal file
View File

@ -0,0 +1,835 @@
<template>
<div class="page-container">
<div class="page-header">
<h1 class="page-title">
<span class="title-icon"><el-icon><Files /></el-icon></span>
PHP 版本管理
</h1>
<p class="page-description">安装卸载和管理 PHP 版本配置扩展</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"><Files /></el-icon>
<h3 class="empty-title">暂未安装 PHP</h3>
<p class="empty-description">点击上方按钮安装第一个 PHP 版本</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><Files /></el-icon>
</div>
<div class="version-details">
<div class="version-name">
PHP {{ 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>
</div>
<div class="version-actions">
<el-button
v-if="!version.isActive"
type="primary"
size="small"
@click="setActive(version.version)"
>
设为默认
</el-button>
<el-button size="small" @click="showExtensions(version)">
<el-icon><Setting /></el-icon>
扩展
</el-button>
<el-button size="small" @click="showConfig(version)">
<el-icon><Document /></el-icon>
配置
</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>
<!-- 安装对话框 -->
<el-dialog
v-model="showInstallDialog"
title="安装 PHP 版本"
width="600px"
>
<el-alert type="warning" :closable="false" class="mb-4">
<template #title>安装说明</template>
PHP 从官方网站 (windows.php.net) 下载国内网络可能较慢请耐心等待
下载进度可在控制台查看 (F12)
</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">PHP {{ version.version }}</span>
<span class="version-type">{{ version.type.toUpperCase() }} - {{ version.arch }}</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>
<!-- 扩展管理对话框 -->
<el-dialog
v-model="showExtensionsDialog"
title="PHP 扩展管理"
width="800px"
>
<!-- 搜索框 -->
<div class="extension-search-bar">
<el-input
v-model="extensionSearchKeyword"
placeholder="搜索扩展名称(如 redis, xdebug..."
clearable
class="extension-search"
@keyup.enter="searchExtensions"
>
<template #prefix>
<el-icon><Search /></el-icon>
</template>
</el-input>
<el-button
type="primary"
@click="searchExtensions"
:loading="loadingAvailableExtensions"
:disabled="extensionTab !== 'available'"
>
搜索
</el-button>
</div>
<el-alert type="info" :closable="false" class="mb-2" v-if="extensionTab === 'available'">
扩展列表来源于 <a href="https://pecl.php.net/" target="_blank">pecl.php.net</a>
Windows 预编译版本由 <a href="https://windows.php.net/downloads/pecl/" target="_blank">windows.php.net</a> 提供
输入关键词搜索更多扩展
</el-alert>
<!-- 手动安装提示和打开目录按钮 -->
<div class="manual-install-hint mb-2">
<el-button type="info" size="small" @click="openExtensionDir" :icon="FolderOpened">
打开扩展目录
</el-button>
<span class="hint-text">手动安装 DLL 文件复制到扩展目录然后在"已安装扩展"中启用</span>
</div>
<el-tabs v-model="extensionTab">
<el-tab-pane label="已安装扩展" name="installed">
<div v-if="loadingExtensions" class="loading-state">
<el-icon class="is-loading"><Loading /></el-icon>
<span>加载扩展列表...</span>
</div>
<div v-else-if="filteredInstalledExtensions.length === 0" class="empty-state small">
<span v-if="extensionSearchKeyword">未找到匹配 "{{ extensionSearchKeyword }}" 的扩展</span>
<span v-else>暂无已安装的扩展</span>
</div>
<div v-else class="extensions-list">
<div class="extensions-count">
{{ filteredInstalledExtensions.length }} 个扩展
<span v-if="extensionSearchKeyword">搜索自 {{ extensions.length }} </span>
</div>
<div
v-for="ext in filteredInstalledExtensions"
:key="ext.name"
class="extension-item"
>
<div class="ext-info">
<span class="ext-name" v-html="highlightKeyword(ext.name)"></span>
<el-tag :type="ext.enabled ? 'success' : 'info'" size="small">
{{ ext.enabled ? '已启用' : '已禁用' }}
</el-tag>
</div>
<el-switch
v-model="ext.enabled"
@change="(val) => toggleExtension(ext.name, val as boolean)"
/>
</div>
</div>
</el-tab-pane>
<el-tab-pane label="在线安装" name="available">
<div v-if="loadingAvailableExtensions" class="loading-state">
<el-icon class="is-loading"><Loading /></el-icon>
<span>正在搜索可用扩展...</span>
</div>
<div v-else-if="availableExtensions.length === 0" class="empty-state small">
<span v-if="extensionSearchKeyword">未找到适用于 PHP {{ currentVersion }} "{{ extensionSearchKeyword }}" 扩展</span>
<span v-else>输入关键词搜索扩展或点击下方按钮加载推荐扩展</span>
<el-button type="primary" size="small" @click="loadAvailableExtensionsData()" class="mt-2">
加载推荐扩展
</el-button>
</div>
<div v-else class="extensions-list">
<div class="extensions-count">
找到 {{ availableExtensions.length }} 个适用于 PHP {{ currentVersion }} 的扩展
</div>
<div
v-for="ext in availableExtensions"
:key="ext.name"
class="extension-item"
>
<div class="ext-info">
<div class="ext-main">
<span class="ext-name" v-html="highlightKeyword(ext.name)"></span>
<el-tag type="warning" size="small">v{{ ext.version }}</el-tag>
</div>
<span class="ext-desc" v-if="ext.description">{{ ext.description }}</span>
</div>
<el-button
type="primary"
size="small"
@click="installExtension(ext)"
:loading="installingExtension === ext.name"
>
{{ installingExtension === ext.name ? '安装中...' : '安装' }}
</el-button>
</div>
</div>
<!-- 扩展下载进度条 -->
<div v-if="installingExtension && extDownloadProgress.total > 0" class="download-progress mt-3">
<div class="progress-info">
<span>正在下载 {{ installingExtension }}...</span>
<span>{{ formatSize(extDownloadProgress.downloaded) }} / {{ formatSize(extDownloadProgress.total) }}</span>
</div>
<el-progress :percentage="extDownloadProgress.progress" :stroke-width="8" />
</div>
</el-tab-pane>
</el-tabs>
</el-dialog>
<!-- 配置编辑对话框 -->
<el-dialog
v-model="showConfigDialog"
title="编辑 php.ini"
width="900px"
>
<textarea
v-model="configContent"
class="code-editor"
spellcheck="false"
></textarea>
<template #footer>
<el-button @click="showConfigDialog = false">取消</el-button>
<el-button type="primary" @click="saveConfig" :loading="savingConfig">
保存配置
</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, computed, onMounted, onUnmounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { FolderOpened } from '@element-plus/icons-vue'
interface PhpVersion {
version: string
path: string
isActive: boolean
}
interface AvailableVersion {
version: string
downloadUrl: string
type: string
arch: string
}
interface Extension {
name: string
enabled: boolean
installed: boolean
}
interface AvailableExtension {
name: string
version: string
downloadUrl: string
description?: string
packageName?: string // Packagist PIE
}
const loading = ref(false)
const installedVersions = ref<PhpVersion[]>([])
const availableVersions = ref<AvailableVersion[]>([])
const showInstallDialog = ref(false)
const selectedVersion = ref('')
const installing = ref(false)
const downloadProgress = reactive({
progress: 0,
downloaded: 0,
total: 0
})
const showExtensionsDialog = ref(false)
const loadingExtensions = ref(false)
const extensions = ref<Extension[]>([])
const currentVersion = ref('')
const extensionTab = ref('installed')
const loadingAvailableExtensions = ref(false)
const availableExtensions = ref<AvailableExtension[]>([])
const installingExtension = ref('')
const extDownloadProgress = reactive({
progress: 0,
downloaded: 0,
total: 0
})
const extensionSearchKeyword = ref('')
//
const filteredInstalledExtensions = computed(() => {
if (!extensionSearchKeyword.value) {
return extensions.value
}
const keyword = extensionSearchKeyword.value.toLowerCase()
return extensions.value.filter(ext =>
ext.name.toLowerCase().includes(keyword)
)
})
//
const filteredAvailableExtensions = computed(() => {
if (!extensionSearchKeyword.value) {
return availableExtensions.value
}
const keyword = extensionSearchKeyword.value.toLowerCase()
return availableExtensions.value.filter(ext =>
ext.name.toLowerCase().includes(keyword)
)
})
//
const highlightKeyword = (text: string) => {
if (!extensionSearchKeyword.value) return text
const keyword = extensionSearchKeyword.value
const regex = new RegExp(`(${keyword})`, 'gi')
return text.replace(regex, '<mark class="search-highlight">$1</mark>')
}
const showConfigDialog = ref(false)
const configContent = ref('')
const savingConfig = ref(false)
const loadVersions = async () => {
try {
installedVersions.value = await window.electronAPI?.php.getVersions() || []
} catch (error: any) {
console.error('加载版本失败:', error)
}
}
const loadAvailableVersions = async () => {
try {
availableVersions.value = await window.electronAPI?.php.getAvailableVersions() || []
} 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?.php.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(
`确定要卸载 PHP ${version} 吗?此操作不可恢复。`,
'确认卸载',
{ type: 'warning' }
)
const result = await window.electronAPI?.php.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) => {
try {
const result = await window.electronAPI?.php.setActive(version)
if (result?.success) {
ElMessage.success(result.message)
await loadVersions()
} else {
ElMessage.error(result?.message || '设置失败')
}
} catch (error: any) {
ElMessage.error(error.message)
}
}
const showExtensions = async (version: PhpVersion) => {
currentVersion.value = version.version
showExtensionsDialog.value = true
extensionTab.value = 'installed'
extensionSearchKeyword.value = '' //
loadingExtensions.value = true
try {
extensions.value = await window.electronAPI?.php.getExtensions(version.version) || []
} catch (error: any) {
ElMessage.error('加载扩展失败: ' + error.message)
} finally {
loadingExtensions.value = false
}
//
loadAvailableExtensionsData()
}
const loadAvailableExtensionsData = async (searchKeyword?: string) => {
if (!currentVersion.value) return
loadingAvailableExtensions.value = true
availableExtensions.value = []
try {
const result = await window.electronAPI?.php.getAvailableExtensions(
currentVersion.value,
searchKeyword || undefined
)
availableExtensions.value = result || []
} catch (error: any) {
console.error('加载可用扩展失败:', error)
ElMessage.error('加载扩展列表失败')
} finally {
loadingAvailableExtensions.value = false
}
}
const searchExtensions = () => {
if (extensionTab.value === 'available') {
loadAvailableExtensionsData(extensionSearchKeyword.value)
}
}
//
const openExtensionDir = async () => {
if (!currentVersion.value) {
ElMessage.warning('请先选择 PHP 版本')
return
}
try {
const result = await window.electronAPI?.php.openExtensionDir(currentVersion.value)
if (result?.success) {
ElMessage.success(result.message)
} else {
ElMessage.error(result?.message || '打开失败')
}
} catch (error: any) {
ElMessage.error('打开扩展目录失败: ' + error.message)
}
}
const installExtension = async (ext: AvailableExtension) => {
installingExtension.value = ext.name
extDownloadProgress.progress = 0
extDownloadProgress.downloaded = 0
extDownloadProgress.total = 0
try {
// 使 PIE 使 packageName
const result = await window.electronAPI?.php.installExtension(
currentVersion.value,
ext.name,
ext.downloadUrl,
ext.packageName // Packagist PIE
)
if (result?.success) {
ElMessage.success(result.message)
//
extensions.value = await window.electronAPI?.php.getExtensions(currentVersion.value) || []
//
await loadAvailableExtensionsData()
} else {
ElMessage.error(result?.message || '安装失败')
}
} catch (error: any) {
ElMessage.error('安装失败: ' + error.message)
} finally {
installingExtension.value = ''
extDownloadProgress.progress = 0
extDownloadProgress.downloaded = 0
extDownloadProgress.total = 0
}
}
const toggleExtension = async (extName: string, enable: boolean) => {
try {
let result
if (enable) {
result = await window.electronAPI?.php.enableExtension(currentVersion.value, extName)
} else {
result = await window.electronAPI?.php.disableExtension(currentVersion.value, extName)
}
if (result?.success) {
ElMessage.success(result.message)
//
extensions.value = await window.electronAPI?.php.getExtensions(currentVersion.value) || []
} else {
ElMessage.error(result?.message || '操作失败')
//
extensions.value = await window.electronAPI?.php.getExtensions(currentVersion.value) || []
}
} catch (error: any) {
ElMessage.error(error.message)
//
extensions.value = await window.electronAPI?.php.getExtensions(currentVersion.value) || []
}
}
const showConfig = async (version: PhpVersion) => {
currentVersion.value = version.version
try {
configContent.value = await window.electronAPI?.php.getConfig(version.version) || ''
showConfigDialog.value = true
} catch (error: any) {
ElMessage.error('加载配置失败: ' + error.message)
}
}
const saveConfig = async () => {
savingConfig.value = true
try {
const result = await window.electronAPI?.php.saveConfig(currentVersion.value, configContent.value)
if (result?.success) {
ElMessage.success(result.message)
showConfigDialog.value = false
} 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(() => {
loadVersions()
loadAvailableVersions()
//
window.electronAPI?.onDownloadProgress((data: any) => {
if (data.type === 'php') {
downloadProgress.progress = data.progress
downloadProgress.downloaded = data.downloaded
downloadProgress.total = data.total
} else if (data.type === 'php-ext') {
extDownloadProgress.progress = data.progress
extDownloadProgress.downloaded = data.downloaded
extDownloadProgress.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;
}
.available-versions {
max-height: 400px;
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;
}
}
.extensions-list {
max-height: 400px;
overflow-y: auto;
}
.extension-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 16px;
border-bottom: 1px solid var(--border-light);
transition: background-color 0.2s;
&:hover {
background-color: var(--bg-hover);
}
&:last-child {
border-bottom: none;
}
.ext-info {
display: flex;
flex-direction: column;
gap: 4px;
.ext-main {
display: flex;
align-items: center;
gap: 12px;
}
.ext-name {
font-family: 'Fira Code', monospace;
font-weight: 500;
}
.ext-desc {
font-size: 12px;
color: var(--text-muted);
}
}
}
.empty-state.small {
padding: 40px 20px;
text-align: center;
color: var(--text-secondary);
}
.mb-3 {
margin-bottom: 12px;
}
.mt-2 {
margin-top: 8px;
}
.mt-3 {
margin-top: 12px;
}
.manual-install-hint {
display: flex;
align-items: center;
gap: 12px;
padding: 10px 12px;
background: rgba(64, 158, 255, 0.08);
border-radius: 8px;
border: 1px dashed rgba(64, 158, 255, 0.3);
.hint-text {
font-size: 13px;
color: #909399;
}
}
.extension-search-bar {
display: flex;
gap: 10px;
margin-bottom: 12px;
.extension-search {
flex: 1;
:deep(.el-input__wrapper) {
border-radius: 10px;
}
}
}
.mb-2 {
margin-bottom: 8px;
a {
color: var(--accent-color);
text-decoration: none;
&:hover {
text-decoration: underline;
}
}
}
.extensions-count {
padding: 8px 16px;
font-size: 12px;
color: var(--text-secondary);
border-bottom: 1px solid var(--border-light);
}
:deep(.search-highlight) {
background-color: rgba(124, 58, 237, 0.3);
color: var(--accent-color);
padding: 0 2px;
border-radius: 2px;
}
.code-editor {
width: 100%;
height: 500px;
}
</style>

529
src/views/RedisManager.vue Normal file
View File

@ -0,0 +1,529 @@
<template>
<div class="page-container">
<div class="page-header">
<h1 class="page-title">
<span class="title-icon redis-gradient"><el-icon><Grid /></el-icon></span>
Redis 管理
</h1>
<p class="page-description">安装配置和管理 Redis 缓存服务</p>
</div>
<!-- 服务状态 -->
<div class="card">
<div class="card-header">
<span class="card-title">
<el-icon><Grid /></el-icon>
服务状态
</span>
<div class="card-actions">
<el-button type="primary" @click="showInstallDialog = true" v-if="!currentVersion">
<el-icon><Plus /></el-icon>
安装 Redis
</el-button>
</div>
</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="!currentVersion" class="empty-state">
<el-icon class="empty-icon"><Grid /></el-icon>
<h3 class="empty-title">暂未安装 Redis</h3>
<p class="empty-description">点击上方按钮安装 Redis</p>
</div>
<div v-else class="service-panel">
<div class="service-status">
<div class="status-main">
<div class="service-icon redis-gradient">
<el-icon><Grid /></el-icon>
</div>
<div class="service-info">
<h3 class="service-name">Redis {{ currentVersion }}</h3>
<span class="status-tag" :class="status.running ? 'running' : 'stopped'">
<span class="status-dot"></span>
{{ status.running ? '运行中' : '已停止' }}
<span v-if="status.pid"> (PID: {{ status.pid }})</span>
</span>
<div class="status-extra" v-if="status.running && status.port">
<span class="extra-item">端口: {{ status.port }}</span>
<span class="extra-item" v-if="status.memory">内存: {{ status.memory }}</span>
</div>
</div>
</div>
<div class="service-controls">
<el-button
v-if="!status.running"
type="success"
@click="start"
:loading="actionLoading"
>
<el-icon><VideoPlay /></el-icon>
启动
</el-button>
<el-button
v-else
type="warning"
@click="stop"
:loading="actionLoading"
>
<el-icon><VideoPause /></el-icon>
停止
</el-button>
<el-button @click="restart" :loading="actionLoading" :disabled="!status.running">
<el-icon><RefreshRight /></el-icon>
重启
</el-button>
<el-button @click="showConfig">
<el-icon><Document /></el-icon>
编辑配置
</el-button>
</div>
</div>
</div>
</div>
</div>
<!-- 版本管理 -->
<div class="card" v-if="currentVersion">
<div class="card-header">
<span class="card-title">
<el-icon><Tickets /></el-icon>
版本管理
</span>
</div>
<div class="card-content">
<div class="version-switcher">
<span class="current-version">当前版本: Redis {{ currentVersion }}</span>
<el-button @click="showInstallDialog = true">
<el-icon><Refresh /></el-icon>
切换版本
</el-button>
<el-button type="danger" @click="uninstall" :disabled="status.running">
<el-icon><Delete /></el-icon>
卸载
</el-button>
</div>
</div>
</div>
<!-- 安装对话框 -->
<el-dialog
v-model="showInstallDialog"
title="安装/切换 Redis 版本"
width="600px"
>
<el-alert type="info" :closable="false" class="mb-4">
<template #title>
Windows Redis
</template>
将从 GitHub 下载 Windows Redis下载速度可能较慢
</el-alert>
<div 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">Redis {{ version.version }}</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>
<!-- 配置编辑对话框 -->
<el-dialog
v-model="showConfigDialog"
title="编辑 redis.windows.conf"
width="1000px"
>
<textarea
v-model="configContent"
class="code-editor"
spellcheck="false"
></textarea>
<template #footer>
<el-button @click="showConfigDialog = false">取消</el-button>
<el-button type="primary" @click="saveConfig" :loading="savingConfig">
保存配置
</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 RedisStatus {
running: boolean
pid?: number
port?: number
memory?: string
}
interface AvailableVersion {
version: string
downloadUrl: string
}
const loading = ref(false)
const currentVersion = ref('')
const status = ref<RedisStatus>({ running: false })
const availableVersions = ref<AvailableVersion[]>([])
const showInstallDialog = ref(false)
const selectedVersion = ref('')
const installing = ref(false)
const actionLoading = ref(false)
const downloadProgress = reactive({
progress: 0,
downloaded: 0,
total: 0
})
const showConfigDialog = ref(false)
const configContent = ref('')
const savingConfig = ref(false)
const loadData = async () => {
try {
const versions = await window.electronAPI?.redis.getVersions() || []
if (versions.length > 0) {
currentVersion.value = versions[0].version
}
status.value = await window.electronAPI?.redis.getStatus() || { running: false }
} catch (error: any) {
console.error('加载数据失败:', error)
} finally {
}
}
const loadAvailableVersions = async () => {
try {
availableVersions.value = await window.electronAPI?.redis.getAvailableVersions() || []
} catch (error: any) {
console.error('加载可用版本失败:', error)
}
}
const install = async () => {
if (!selectedVersion.value) return
//
downloadProgress.progress = 0
downloadProgress.downloaded = 0
downloadProgress.total = 0
installing.value = true
try {
if (status.value.running) {
await window.electronAPI?.redis.stop()
}
const result = await window.electronAPI?.redis.install(selectedVersion.value)
if (result?.success) {
ElMessage.success(result.message)
showInstallDialog.value = false
selectedVersion.value = ''
await loadData()
} 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 () => {
try {
await ElMessageBox.confirm(
'确定要卸载 Redis 吗?',
'确认卸载',
{ type: 'warning' }
)
const result = await window.electronAPI?.redis.uninstall(currentVersion.value)
if (result?.success) {
ElMessage.success(result.message)
currentVersion.value = ''
await loadData()
} else {
ElMessage.error(result?.message || '卸载失败')
}
} catch (error: any) {
if (error !== 'cancel') {
ElMessage.error(error.message)
}
}
}
const start = async () => {
actionLoading.value = true
try {
const result = await window.electronAPI?.redis.start()
if (result?.success) {
ElMessage.success(result.message)
await loadData()
} else {
ElMessage.error(result?.message || '启动失败')
}
} catch (error: any) {
ElMessage.error(error.message)
} finally {
actionLoading.value = false
}
}
const stop = async () => {
actionLoading.value = true
try {
const result = await window.electronAPI?.redis.stop()
if (result?.success) {
ElMessage.success(result.message)
await loadData()
} else {
ElMessage.error(result?.message || '停止失败')
}
} catch (error: any) {
ElMessage.error(error.message)
} finally {
actionLoading.value = false
}
}
const restart = async () => {
actionLoading.value = true
try {
const result = await window.electronAPI?.redis.restart()
if (result?.success) {
ElMessage.success(result.message)
await loadData()
} else {
ElMessage.error(result?.message || '重启失败')
}
} catch (error: any) {
ElMessage.error(error.message)
} finally {
actionLoading.value = false
}
}
const showConfig = async () => {
try {
configContent.value = await window.electronAPI?.redis.getConfig() || ''
showConfigDialog.value = true
} catch (error: any) {
ElMessage.error('加载配置失败: ' + error.message)
}
}
const saveConfig = async () => {
savingConfig.value = true
try {
const result = await window.electronAPI?.redis.saveConfig(configContent.value)
if (result?.success) {
ElMessage.success(result.message)
showConfigDialog.value = false
} 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(() => {
loadData()
loadAvailableVersions()
setInterval(loadData, 5000)
window.electronAPI?.onDownloadProgress((data: any) => {
if (data.type === 'redis') {
downloadProgress.progress = data.progress
downloadProgress.downloaded = data.downloaded
downloadProgress.total = data.total
}
})
})
onUnmounted(() => {
window.electronAPI?.removeDownloadProgressListener()
})
</script>
<style lang="scss" scoped>
.redis-gradient {
background: linear-gradient(135deg, #dc382d 0%, #ff6b6b 100%) !important;
}
.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); }
}
.mb-4 {
margin-bottom: 16px;
}
.service-panel {
.service-status {
display: flex;
flex-direction: column;
gap: 20px;
}
.status-main {
display: flex;
align-items: center;
gap: 20px;
}
.service-icon {
width: 64px;
height: 64px;
border-radius: 16px;
display: flex;
align-items: center;
justify-content: center;
color: white;
font-size: 32px;
}
.service-info {
.service-name {
font-size: 20px;
font-weight: 600;
margin-bottom: 8px;
}
.status-extra {
margin-top: 8px;
display: flex;
gap: 16px;
.extra-item {
font-size: 13px;
color: var(--text-secondary);
padding: 4px 10px;
background: var(--bg-input);
border-radius: 6px;
}
}
}
.service-controls {
display: flex;
gap: 12px;
flex-wrap: wrap;
}
}
.version-switcher {
display: flex;
align-items: center;
gap: 16px;
.current-version {
font-weight: 500;
color: var(--text-secondary);
}
}
.available-versions {
max-height: 400px;
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 {
.version-number {
font-weight: 600;
font-size: 16px;
}
}
.check-icon {
color: var(--accent-color);
font-size: 20px;
}
}
.code-editor {
width: 100%;
height: 500px;
}
</style>

244
src/views/Settings.vue Normal file
View File

@ -0,0 +1,244 @@
<template>
<div class="page-container">
<div class="page-header">
<h1 class="page-title">
<span class="title-icon"><el-icon><Setting /></el-icon></span>
设置
</h1>
<p class="page-description">配置应用程序和服务设置</p>
</div>
<!-- 基础设置 -->
<div class="card">
<div class="card-header">
<span class="card-title">
<el-icon><Folder /></el-icon>
基础设置
</span>
</div>
<div class="card-content">
<div class="setting-item">
<div class="setting-info">
<h4 class="setting-title">安装目录</h4>
<p class="setting-description">PHPMySQLNginxRedis 等服务的安装目录</p>
</div>
<div class="setting-action">
<el-input v-model="basePath" style="width: 400px" disabled />
<el-button @click="openBasePath">
<el-icon><FolderOpened /></el-icon>
打开
</el-button>
</div>
</div>
</div>
</div>
<!-- 开机自启动 -->
<div class="card">
<div class="card-header">
<span class="card-title">
<el-icon><Timer /></el-icon>
开机自启动
</span>
</div>
<div class="card-content">
<div class="setting-item" v-for="service in services" :key="service.name">
<div class="setting-info">
<h4 class="setting-title">{{ service.displayName }}</h4>
<p class="setting-description">{{ service.description }}</p>
</div>
<div class="setting-action">
<el-switch
v-model="service.autoStart"
@change="(val) => toggleAutoStart(service.name, val as boolean)"
/>
</div>
</div>
</div>
</div>
<!-- 关于 -->
<div class="card">
<div class="card-header">
<span class="card-title">
<el-icon><InfoFilled /></el-icon>
关于
</span>
</div>
<div class="card-content">
<div class="about-section">
<div class="app-info">
<div class="app-logo-large">
<img src="/favicon.svg" alt="logo" />
</div>
<div class="app-details">
<h2 class="app-title">PHPer 开发环境管理器</h2>
<p class="app-version">版本 1.0.0</p>
<p class="app-desc">
一站式 PHP 开发环境管理工具支持 PHPMySQLNginxRedis 的安装和管理
</p>
</div>
</div>
<div class="about-links">
<el-button @click="openLink('https://windows.php.net/download/')">
PHP for Windows
</el-button>
<el-button @click="openLink('https://nginx.org/')">
Nginx 官网
</el-button>
<el-button @click="openLink('https://dev.mysql.com/')">
MySQL 官网
</el-button>
<el-button @click="openLink('https://redis.io/')">
Redis 官网
</el-button>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
import { ElMessage } from 'element-plus'
interface ServiceAutoStart {
name: string
displayName: string
description: string
autoStart: boolean
}
const basePath = ref('')
const services = reactive<ServiceAutoStart[]>([
{ name: 'nginx', displayName: 'Nginx', description: '开机时自动启动 Nginx 服务', autoStart: false },
{ name: 'mysql', displayName: 'MySQL', description: '开机时自动启动 MySQL 服务', autoStart: false },
{ name: 'redis', displayName: 'Redis', description: '开机时自动启动 Redis 服务', autoStart: false },
{ name: 'php-cgi', displayName: 'PHP-CGI', description: '开机时自动启动 PHP FastCGI 进程', autoStart: false }
])
const loadSettings = async () => {
try {
basePath.value = await window.electronAPI?.config.getBasePath() || ''
//
for (const service of services) {
service.autoStart = await window.electronAPI?.service.getAutoStart(service.name) || false
}
} catch (error: any) {
console.error('加载设置失败:', error)
}
}
const toggleAutoStart = async (name: string, enabled: boolean) => {
try {
const result = await window.electronAPI?.service.setAutoStart(name, enabled)
if (result?.success) {
ElMessage.success(result.message)
} else {
ElMessage.error(result?.message || '设置失败')
//
const service = services.find(s => s.name === name)
if (service) service.autoStart = !enabled
}
} catch (error: any) {
ElMessage.error(error.message)
}
}
const openBasePath = async () => {
if (basePath.value) {
await window.electronAPI?.openPath(basePath.value)
}
}
const openLink = (url: string) => {
window.electronAPI?.openExternal(url)
}
onMounted(() => {
loadSettings()
})
</script>
<style lang="scss" scoped>
.setting-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 20px 0;
border-bottom: 1px solid var(--border-light);
&:last-child {
border-bottom: none;
}
.setting-info {
.setting-title {
font-size: 15px;
font-weight: 600;
margin-bottom: 4px;
}
.setting-description {
font-size: 13px;
color: var(--text-muted);
}
}
.setting-action {
display: flex;
align-items: center;
gap: 12px;
}
}
.about-section {
.app-info {
display: flex;
align-items: center;
gap: 24px;
margin-bottom: 32px;
padding-bottom: 24px;
border-bottom: 1px solid var(--border-light);
}
.app-logo-large {
width: 80px;
height: 80px;
img {
width: 100%;
height: 100%;
}
}
.app-details {
.app-title {
font-size: 24px;
font-weight: 700;
margin-bottom: 8px;
}
.app-version {
font-size: 14px;
color: var(--text-muted);
margin-bottom: 8px;
}
.app-desc {
font-size: 14px;
color: var(--text-secondary);
max-width: 500px;
}
}
.about-links {
display: flex;
gap: 12px;
flex-wrap: wrap;
}
}
</style>

697
src/views/SitesManager.vue Normal file
View File

@ -0,0 +1,697 @@
<template>
<div class="page-container">
<div class="page-header">
<h1 class="page-title">
<span class="title-icon"><el-icon><Monitor /></el-icon></span>
站点管理
</h1>
<p class="page-description">创建和管理 Nginx 虚拟主机站点支持 Laravel 项目和 SSL 证书</p>
</div>
<!-- 站点列表 -->
<div class="card">
<div class="card-header">
<span class="card-title">
<el-icon><Collection /></el-icon>
站点列表
</span>
<el-button type="primary" @click="showAddSiteDialog = 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="sites.length === 0" class="empty-state">
<el-icon class="empty-icon"><Monitor /></el-icon>
<h3 class="empty-title">暂无站点</h3>
<p class="empty-description">点击上方按钮添加第一个站点</p>
</div>
<div v-else class="sites-grid">
<div
v-for="site in sites"
:key="site.name"
class="site-card"
:class="{ enabled: site.enabled }"
>
<div class="site-header">
<div class="site-icon" :class="{ laravel: site.isLaravel }">
<el-icon v-if="site.isLaravel"><Promotion /></el-icon>
<el-icon v-else><Monitor /></el-icon>
</div>
<div class="site-main">
<h3 class="site-name">{{ site.name }}</h3>
<a class="site-domain" :href="`http://${site.domain}`" @click.prevent="openSite(site)">
{{ site.ssl ? 'https' : 'http' }}://{{ site.domain }}
</a>
</div>
</div>
<div class="site-meta">
<span class="meta-item">
<el-icon><Folder /></el-icon>
{{ site.rootPath }}
</span>
<span class="meta-item">
<el-icon><Files /></el-icon>
PHP {{ site.phpVersion }}
</span>
</div>
<div class="site-tags">
<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 :type="site.enabled ? 'success' : 'info'" size="small">
{{ site.enabled ? '已启用' : '已禁用' }}
</el-tag>
</div>
<div class="site-actions">
<el-button
type="primary"
size="small"
@click="showEditDialog(site)"
>
编辑
</el-button>
<el-button
v-if="!site.enabled"
type="success"
size="small"
@click="enableSite(site.name)"
>
启用
</el-button>
<el-button
v-else
type="warning"
size="small"
@click="disableSite(site.name)"
>
禁用
</el-button>
<el-button size="small" @click="showSSLDialog(site)" v-if="!site.ssl">
申请SSL
</el-button>
<el-button
type="danger"
size="small"
@click="removeSite(site.name)"
>
删除
</el-button>
</div>
</div>
</div>
</div>
</div>
<!-- 添加站点对话框 -->
<el-dialog
v-model="showAddSiteDialog"
title="添加站点"
width="600px"
>
<el-form :model="siteForm" label-width="100px">
<el-form-item label="域名" required>
<el-input v-model="siteForm.domain" placeholder="例如: myproject.test" @blur="autoFillName" />
</el-form-item>
<el-form-item label="站点名称">
<el-input v-model="siteForm.name" placeholder="留空则使用域名作为名称" />
<span class="form-hint">可选默认使用域名</span>
</el-form-item>
<el-form-item label="根目录" required>
<div class="directory-input">
<el-input v-model="siteForm.rootPath" placeholder="点击右侧按钮选择目录" readonly />
<el-button type="primary" @click="selectDirectory" :icon="FolderOpened">
选择目录
</el-button>
</div>
</el-form-item>
<el-form-item label="PHP 版本" required>
<el-select v-model="siteForm.phpVersion" placeholder="选择 PHP 版本">
<el-option
v-for="v in phpVersions"
:key="v.version"
:label="'PHP ' + v.version"
:value="v.version"
/>
</el-select>
</el-form-item>
<el-form-item label="Laravel 项目">
<el-switch v-model="siteForm.isLaravel" />
<span class="form-hint">开启后将自动配置 Laravel 伪静态规则</span>
</el-form-item>
<el-form-item label="启用 SSL">
<el-switch v-model="siteForm.ssl" />
<span class="form-hint">需要先申请 SSL 证书</span>
</el-form-item>
<el-form-item label="添加 Hosts">
<el-switch v-model="addToHosts" />
<span class="form-hint">自动将域名添加到系统 hosts 文件</span>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="showAddSiteDialog = false">取消</el-button>
<el-button type="primary" @click="addSite" :loading="adding">
添加站点
</el-button>
</template>
</el-dialog>
<!-- SSL 申请对话框 -->
<el-dialog
v-model="showSSLDialogVisible"
title="申请 SSL 证书"
width="500px"
>
<el-alert type="warning" :closable="false" class="mb-4">
<template #title>
Let's Encrypt 证书
</template>
需要确保域名已解析到本机 80 端口可访问本地开发建议使用自签名证书
</el-alert>
<el-form :model="sslForm" label-width="80px">
<el-form-item label="域名">
<el-input v-model="sslForm.domain" disabled />
</el-form-item>
<el-form-item label="邮箱" required>
<el-input v-model="sslForm.email" placeholder="用于接收证书到期通知" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="showSSLDialogVisible = false">取消</el-button>
<el-button type="primary" @click="requestSSL" :loading="requestingSSL">
申请证书
</el-button>
</template>
</el-dialog>
<!-- 编辑站点对话框 -->
<el-dialog
v-model="showEditSiteDialog"
title="编辑站点"
width="600px"
>
<el-form :model="editForm" label-width="100px">
<el-form-item label="域名" required>
<el-input v-model="editForm.domain" placeholder="例如: myproject.test" />
</el-form-item>
<el-form-item label="站点名称">
<el-input v-model="editForm.name" disabled />
<span class="form-hint">站点名称不可修改</span>
</el-form-item>
<el-form-item label="根目录" required>
<div class="directory-input">
<el-input v-model="editForm.rootPath" placeholder="点击右侧按钮选择目录" readonly />
<el-button type="primary" @click="selectEditDirectory" :icon="FolderOpened">
选择目录
</el-button>
</div>
</el-form-item>
<el-form-item label="PHP 版本" required>
<el-select v-model="editForm.phpVersion" placeholder="选择 PHP 版本">
<el-option
v-for="v in phpVersions"
:key="v.version"
:label="'PHP ' + v.version"
:value="v.version"
/>
</el-select>
</el-form-item>
<el-form-item label="Laravel 项目">
<el-switch v-model="editForm.isLaravel" />
<span class="form-hint">开启后将自动配置 Laravel 伪静态规则</span>
</el-form-item>
<el-form-item label="启用 SSL">
<el-switch v-model="editForm.ssl" />
<span class="form-hint">需要先申请 SSL 证书</span>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="showEditSiteDialog = false">取消</el-button>
<el-button type="primary" @click="updateSite" :loading="updating">
保存修改
</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { FolderOpened } from '@element-plus/icons-vue'
interface SiteConfig {
name: string
domain: string
rootPath: string
phpVersion: string
isLaravel: boolean
ssl: boolean
enabled: boolean
}
const loading = ref(false)
const sites = ref<SiteConfig[]>([])
const phpVersions = ref<any[]>([])
const showAddSiteDialog = ref(false)
const adding = ref(false)
const addToHosts = ref(true)
const siteForm = reactive<SiteConfig>({
name: '',
domain: '',
rootPath: '',
phpVersion: '',
isLaravel: false,
ssl: false,
enabled: true
})
const showSSLDialogVisible = ref(false)
const sslForm = reactive({
domain: '',
email: ''
})
const requestingSSL = ref(false)
//
const showEditSiteDialog = ref(false)
const updating = ref(false)
const editingOriginalName = ref('')
const editForm = reactive<SiteConfig>({
name: '',
domain: '',
rootPath: '',
phpVersion: '',
isLaravel: false,
ssl: false,
enabled: true
})
const loadData = async () => {
try {
sites.value = await window.electronAPI?.nginx.getSites() || []
phpVersions.value = await window.electronAPI?.php.getVersions() || []
// PHP
if (phpVersions.value.length > 0 && !siteForm.phpVersion) {
siteForm.phpVersion = phpVersions.value[0].version
}
} catch (error: any) {
console.error('加载数据失败:', error)
}
}
//
const selectDirectory = async () => {
try {
const path = await window.electronAPI?.selectDirectory()
if (path) {
siteForm.rootPath = path
}
} catch (error: any) {
ElMessage.error('选择目录失败: ' + error.message)
}
}
//
const autoFillName = () => {
if (!siteForm.name && siteForm.domain) {
// 使 .test, .local
siteForm.name = siteForm.domain.replace(/\.(test|local|dev|localhost)$/i, '')
}
}
const addSite = async () => {
//
if (!siteForm.name && siteForm.domain) {
siteForm.name = siteForm.domain.replace(/\.(test|local|dev|localhost)$/i, '')
}
if (!siteForm.domain || !siteForm.rootPath || !siteForm.phpVersion) {
ElMessage.warning('请填写所有必填字段域名、根目录、PHP版本')
return
}
//
if (!siteForm.name) {
siteForm.name = siteForm.domain
}
adding.value = true
try {
// IPC
const siteData = {
name: siteForm.name,
domain: siteForm.domain,
rootPath: siteForm.rootPath,
phpVersion: siteForm.phpVersion,
isLaravel: siteForm.isLaravel,
ssl: siteForm.ssl,
enabled: siteForm.enabled
}
const result = await window.electronAPI?.nginx.addSite(siteData)
if (result?.success) {
// hosts
if (addToHosts.value) {
await window.electronAPI?.hosts.add(siteForm.domain, '127.0.0.1')
}
ElMessage.success(result.message)
showAddSiteDialog.value = false
//
Object.assign(siteForm, {
name: '',
domain: '',
rootPath: '',
phpVersion: phpVersions.value[0]?.version || '',
isLaravel: false,
ssl: false,
enabled: true
})
// Nginx
await window.electronAPI?.nginx.reload()
await loadData()
} else {
ElMessage.error(result?.message || '添加失败')
}
} catch (error: any) {
ElMessage.error(error.message)
} finally {
adding.value = false
}
}
const removeSite = async (name: string) => {
try {
await ElMessageBox.confirm(
`确定要删除站点 ${name} 吗?`,
'确认删除',
{ type: 'warning' }
)
const site = sites.value.find(s => s.name === name)
const result = await window.electronAPI?.nginx.removeSite(name)
if (result?.success) {
// hosts
if (site) {
await window.electronAPI?.hosts.remove(site.domain)
}
ElMessage.success(result.message)
await window.electronAPI?.nginx.reload()
await loadData()
} else {
ElMessage.error(result?.message || '删除失败')
}
} catch (error: any) {
if (error !== 'cancel') {
ElMessage.error(error.message)
}
}
}
const enableSite = async (name: string) => {
try {
const result = await window.electronAPI?.nginx.enableSite(name)
if (result?.success) {
ElMessage.success(result.message)
await window.electronAPI?.nginx.reload()
await loadData()
} else {
ElMessage.error(result?.message || '启用失败')
}
} catch (error: any) {
ElMessage.error(error.message)
}
}
const disableSite = async (name: string) => {
try {
const result = await window.electronAPI?.nginx.disableSite(name)
if (result?.success) {
ElMessage.success(result.message)
await window.electronAPI?.nginx.reload()
await loadData()
} else {
ElMessage.error(result?.message || '禁用失败')
}
} catch (error: any) {
ElMessage.error(error.message)
}
}
const openSite = (site: SiteConfig) => {
const protocol = site.ssl ? 'https' : 'http'
window.electronAPI?.openExternal(`${protocol}://${site.domain}`)
}
//
const showEditDialog = (site: SiteConfig) => {
editingOriginalName.value = site.name
Object.assign(editForm, {
name: site.name,
domain: site.domain,
rootPath: site.rootPath,
phpVersion: site.phpVersion,
isLaravel: site.isLaravel,
ssl: site.ssl,
enabled: site.enabled
})
showEditSiteDialog.value = true
}
//
const selectEditDirectory = async () => {
try {
const path = await window.electronAPI?.selectDirectory()
if (path) {
editForm.rootPath = path
}
} catch (error: any) {
ElMessage.error('选择目录失败: ' + error.message)
}
}
//
const updateSite = async () => {
if (!editForm.domain || !editForm.rootPath || !editForm.phpVersion) {
ElMessage.warning('请填写所有必填字段')
return
}
updating.value = true
try {
//
const siteData = {
name: editForm.name,
domain: editForm.domain,
rootPath: editForm.rootPath,
phpVersion: editForm.phpVersion,
isLaravel: editForm.isLaravel,
ssl: editForm.ssl,
enabled: editForm.enabled
}
const result = await window.electronAPI?.nginx.updateSite(editingOriginalName.value, siteData)
if (result?.success) {
ElMessage.success(result.message)
showEditSiteDialog.value = false
// hosts
const oldSite = sites.value.find(s => s.name === editingOriginalName.value)
if (oldSite && oldSite.domain !== editForm.domain) {
await window.electronAPI?.hosts.remove(oldSite.domain)
await window.electronAPI?.hosts.add(editForm.domain, '127.0.0.1')
}
//
await window.electronAPI?.nginx.reload()
await loadData()
} else {
ElMessage.error(result?.message || '更新失败')
}
} catch (error: any) {
ElMessage.error(error.message)
} finally {
updating.value = false
}
}
const showSSLDialog = (site: SiteConfig) => {
sslForm.domain = site.domain
sslForm.email = ''
showSSLDialogVisible.value = true
}
const requestSSL = async () => {
if (!sslForm.email) {
ElMessage.warning('请输入邮箱地址')
return
}
requestingSSL.value = true
try {
const result = await window.electronAPI?.nginx.requestSSL(sslForm.domain, sslForm.email)
if (result?.success) {
ElMessage.success(result.message)
showSSLDialogVisible.value = false
await loadData()
} else {
ElMessage.error(result?.message || '申请失败')
}
} catch (error: any) {
ElMessage.error(error.message)
} finally {
requestingSSL.value = false
}
}
onMounted(() => {
loadData()
})
</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); }
}
.mb-4 {
margin-bottom: 16px;
}
.sites-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(400px, 1fr));
gap: 20px;
}
.site-card {
background: var(--bg-input);
border: 1px solid var(--border-color);
border-radius: 12px;
padding: 20px;
transition: all 0.2s;
&:hover {
border-color: var(--accent-light);
box-shadow: var(--shadow-sm);
}
&.enabled {
border-color: var(--success-color);
}
.site-header {
display: flex;
align-items: center;
gap: 16px;
margin-bottom: 16px;
}
.site-icon {
width: 48px;
height: 48px;
border-radius: 12px;
background: var(--accent-gradient);
display: flex;
align-items: center;
justify-content: center;
color: white;
font-size: 24px;
&.laravel {
background: linear-gradient(135deg, #ff2d20 0%, #ff6b6b 100%);
}
}
.site-main {
flex: 1;
.site-name {
font-size: 16px;
font-weight: 600;
margin-bottom: 4px;
}
.site-domain {
font-size: 13px;
color: var(--accent-color);
text-decoration: none;
font-family: 'Fira Code', monospace;
&:hover {
text-decoration: underline;
}
}
}
.site-meta {
margin-bottom: 12px;
.meta-item {
display: flex;
align-items: center;
gap: 8px;
font-size: 12px;
color: var(--text-muted);
margin-bottom: 4px;
.el-icon {
font-size: 14px;
}
}
}
.site-tags {
display: flex;
gap: 8px;
margin-bottom: 16px;
}
.site-actions {
display: flex;
gap: 8px;
border-top: 1px solid var(--border-light);
padding-top: 16px;
}
}
.form-hint {
font-size: 12px;
color: var(--text-muted);
margin-left: 12px;
}
.directory-input {
display: flex;
gap: 10px;
width: 100%;
.el-input {
flex: 1;
}
}
</style>

110
src/vite-env.d.ts vendored Normal file
View File

@ -0,0 +1,110 @@
/// <reference types="vite/client" />
declare module '*.vue' {
import type { DefineComponent } from 'vue'
const component: DefineComponent<{}, {}, any>
export default component
}
interface Window {
electronAPI: {
minimize: () => Promise<void>
maximize: () => Promise<void>
close: () => Promise<void>
openExternal: (url: string) => Promise<void>
openPath: (path: string) => Promise<void>
php: {
getVersions: () => Promise<any[]>
getAvailableVersions: () => Promise<any[]>
install: (version: string) => Promise<{ success: boolean; message: string }>
uninstall: (version: string) => Promise<{ success: boolean; message: string }>
setActive: (version: string) => Promise<{ success: boolean; message: string }>
getExtensions: (version: string) => Promise<any[]>
enableExtension: (version: string, ext: string) => Promise<{ success: boolean; message: string }>
disableExtension: (version: string, ext: string) => Promise<{ success: boolean; message: string }>
installExtension: (version: string, ext: string) => Promise<{ success: boolean; message: string }>
getConfig: (version: string) => Promise<string>
saveConfig: (version: string, config: string) => Promise<{ success: boolean; message: string }>
}
mysql: {
getVersions: () => Promise<any[]>
getAvailableVersions: () => Promise<any[]>
install: (version: string) => Promise<{ success: boolean; message: string }>
uninstall: (version: string) => Promise<{ success: boolean; message: string }>
start: (version: string) => Promise<{ success: boolean; message: string }>
stop: (version: string) => Promise<{ success: boolean; message: string }>
restart: (version: string) => Promise<{ success: boolean; message: string }>
getStatus: (version: string) => Promise<any>
changePassword: (version: string, newPassword: string) => Promise<{ success: boolean; message: string }>
getConfig: (version: string) => Promise<string>
saveConfig: (version: string, config: string) => Promise<{ success: boolean; message: string }>
}
nginx: {
getVersions: () => Promise<any[]>
getAvailableVersions: () => Promise<any[]>
install: (version: string) => Promise<{ success: boolean; message: string }>
uninstall: (version: string) => Promise<{ success: boolean; message: string }>
start: () => Promise<{ success: boolean; message: string }>
stop: () => Promise<{ success: boolean; message: string }>
restart: () => Promise<{ success: boolean; message: string }>
reload: () => Promise<{ success: boolean; message: string }>
getStatus: () => Promise<any>
getConfig: () => Promise<string>
saveConfig: (config: string) => Promise<{ success: boolean; message: string }>
getSites: () => Promise<any[]>
addSite: (site: any) => Promise<{ success: boolean; message: string }>
removeSite: (name: string) => Promise<{ success: boolean; message: string }>
enableSite: (name: string) => Promise<{ success: boolean; message: string }>
disableSite: (name: string) => Promise<{ success: boolean; message: string }>
generateLaravelConfig: (site: any) => Promise<string>
requestSSL: (domain: string, email: string) => Promise<{ success: boolean; message: string }>
}
redis: {
getVersions: () => Promise<any[]>
getAvailableVersions: () => Promise<any[]>
install: (version: string) => Promise<{ success: boolean; message: string }>
uninstall: (version: string) => Promise<{ success: boolean; message: string }>
start: () => Promise<{ success: boolean; message: string }>
stop: () => Promise<{ success: boolean; message: string }>
restart: () => Promise<{ success: boolean; message: string }>
getStatus: () => Promise<any>
getConfig: () => Promise<string>
saveConfig: (config: string) => Promise<{ success: boolean; message: string }>
}
node: {
getVersions: () => Promise<any[]>
getAvailableVersions: () => Promise<any[]>
install: (version: string, downloadUrl: string) => Promise<{ success: boolean; message: string }>
uninstall: (version: string) => Promise<{ success: boolean; message: string }>
setActive: (version: string) => Promise<{ success: boolean; message: string }>
getInfo: (version: string) => Promise<any>
}
service: {
getAll: () => Promise<any[]>
setAutoStart: (service: string, enabled: boolean) => Promise<{ success: boolean; message: string }>
getAutoStart: (service: string) => Promise<boolean>
startAll: () => Promise<{ success: boolean; message: string; details: string[] }>
stopAll: () => Promise<{ success: boolean; message: string; details: string[] }>
}
hosts: {
get: () => Promise<any[]>
add: (domain: string, ip: string) => Promise<{ success: boolean; message: string }>
remove: (domain: string) => Promise<{ success: boolean; message: string }>
}
config: {
get: (key: string) => Promise<any>
set: (key: string, value: any) => Promise<void>
getBasePath: () => Promise<string>
setBasePath: (path: string) => Promise<void>
}
}
}

26
tsconfig.json Normal file
View File

@ -0,0 +1,26 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"module": "ESNext",
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "preserve",
"strict": true,
"noUnusedLocals": false,
"noUnusedParameters": false,
"noFallthroughCasesInSwitch": true,
"paths": {
"@/*": ["./src/*"]
},
"types": ["node"]
},
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue", "electron/**/*.ts"],
"references": [{ "path": "./tsconfig.node.json" }]
}

11
tsconfig.node.json Normal file
View File

@ -0,0 +1,11 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts"]
}

70
vite.config.ts Normal file
View File

@ -0,0 +1,70 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import electron from 'vite-plugin-electron'
import renderer from 'vite-plugin-electron-renderer'
import { resolve } from 'path'
// https://vitejs.dev/config/
export default defineConfig(({ command }) => {
const isServe = command === 'serve'
const isBuild = command === 'build'
return {
plugins: [
vue(),
electron([
{
entry: 'electron/main.ts',
onstart(options) {
options.startup()
},
vite: {
build: {
outDir: 'dist-electron',
minify: isBuild,
rollupOptions: {
external: [
'electron',
'node-windows',
'sudo-prompt',
'electron-store',
'unzipper',
'fs',
'path',
'child_process',
'https',
'http',
'stream',
'util'
]
}
}
}
},
{
entry: 'electron/preload.ts',
onstart(options) {
options.reload()
},
vite: {
build: {
outDir: 'dist-electron',
minify: isBuild
}
}
}
]),
renderer()
],
resolve: {
alias: {
'@': resolve(__dirname, 'src')
}
},
server: {
port: 5173
},
clearScreen: false
}
})