Sure! Pl
This commit is contained in:
commit
24dc4c739b
35
.gitignore
vendored
Normal file
35
.gitignore
vendored
Normal 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
283
README.md
Normal 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
13
build/installer.nsh
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
!macro customInit
|
||||||
|
; 设置默认安装目录为 D 盘
|
||||||
|
StrCpy $INSTDIR "D:\PHPer"
|
||||||
|
!macroend
|
||||||
|
|
||||||
|
!macro customInstall
|
||||||
|
; 安装完成后的自定义操作
|
||||||
|
!macroend
|
||||||
|
|
||||||
|
!macro customUnInstall
|
||||||
|
; 卸载时的自定义操作
|
||||||
|
!macroend
|
||||||
|
|
||||||
5
docs/.gitkeep
Normal file
5
docs/.gitkeep
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
# 此目录用于存放文档截图
|
||||||
|
# 请将以下截图放入此目录:
|
||||||
|
# - dashboard.png 仪表盘截图
|
||||||
|
# - php-manager.png PHP管理页面截图
|
||||||
|
|
||||||
196
electron/main.ts
Normal file
196
electron/main.ts
Normal 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
150
electron/preload.ts
Normal 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
|
||||||
|
}
|
||||||
|
|
||||||
212
electron/services/ConfigStore.ts
Normal file
212
electron/services/ConfigStore.ts
Normal 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')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
224
electron/services/HostsManager.ts
Normal file
224
electron/services/HostsManager.ts
Normal 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, '\\$&')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
1096
electron/services/MysqlManager.ts
Normal file
1096
electron/services/MysqlManager.ts
Normal file
File diff suppressed because it is too large
Load Diff
848
electron/services/NginxManager.ts
Normal file
848
electron/services/NginxManager.ts
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
460
electron/services/NodeManager.ts
Normal file
460
electron/services/NodeManager.ts
Normal 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' },
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
2063
electron/services/PhpManager.ts
Normal file
2063
electron/services/PhpManager.ts
Normal file
File diff suppressed because it is too large
Load Diff
666
electron/services/RedisManager.ts
Normal file
666
electron/services/RedisManager.ts
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
356
electron/services/ServiceManager.ts
Normal file
356
electron/services/ServiceManager.ts
Normal 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
14
index.html
Normal 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
6807
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
83
package.json
Normal file
83
package.json
Normal 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
1762
pecl_redis_page.html
Normal file
File diff suppressed because it is too large
Load Diff
992
pecl_search.html
Normal file
992
pecl_search.html
Normal 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>
|
||||||
|
|
||||||
|
|
|
||||||
|
<a href="/packages.php" class="menuBlack">Packages</a>
|
||||||
|
|
|
||||||
|
<a href="/support.php" class="menuBlack">Support</a>
|
||||||
|
|
|
||||||
|
<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;"> <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"> </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"> </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>
|
||||||
|
|
|
||||||
|
<a href="/credits.php" class="menuBlack">CREDITS</a>
|
||||||
|
<br>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<tr>
|
||||||
|
<td class="foot-copy">
|
||||||
|
<small>
|
||||||
|
<a href="/copyright.php">Copyright © 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
388
pecl_test.html
Normal 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>
|
||||||
|
|
||||||
|
|
|
||||||
|
<a href="/packages.php" class="menuBlack">Packages</a>
|
||||||
|
|
|
||||||
|
<a href="/support.php" class="menuBlack">Support</a>
|
||||||
|
|
|
||||||
|
<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;"> <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 <<a href="/account-mail.php?handle=mgrunder">
|
||||||
|
michael dot grunder at gmail dot com </a>>
|
||||||
|
(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://github.com/A-VISION-BV<br>Avtandil Kikabidze - https://github.com/akalongman<br>Geoffrey Hoffman - https://github.com/phpguru<br>Object Cache Pro for WordPress - https://objectcache.pro/<br>Open LMS - https://openlms.net/<br>Relay - https://relay.so<br>Salvatore Sanfilippo - https://github.com/antirez<br>Ty Karok - https://github.com/karock<br><br>--- 6.3.0 ---<br><br>This release introduces support for dozens of new commands, including hash<br>field expiration, Valkey?s DELIFEQ, and Redis vector set commands. It also<br>includes many bug fixes and performance improvements.<br><br>Fixed:<br><br>* Cloning our objects should not segfault [770034cc] (michael-grunder)<br>* Fix return type for `RedisCluster` `vgetattr` and `vsetattr`<br> [834d2b37] (michael-grunder) </td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<div> </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&release=6.3.0">
|
||||||
|
Changelog
|
||||||
|
</a>
|
||||||
|
]</td>
|
||||||
|
<td align="center">
|
||||||
|
[ <a href="/package-stats.php?pid=935&rid=&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>
|
||||||
|
|
|
||||||
|
<a href="/credits.php" class="menuBlack">CREDITS</a>
|
||||||
|
<br>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<tr>
|
||||||
|
<td class="foot-copy">
|
||||||
|
<small>
|
||||||
|
<a href="/copyright.php">Copyright © 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
12
public/favicon.svg
Normal 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
1762
redis_detail.html
Normal file
File diff suppressed because it is too large
Load Diff
373
src/App.vue
Normal file
373
src/App.vue
Normal 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
23
src/main.ts
Normal 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
64
src/router/index.ts
Normal 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
549
src/styles/main.scss
Normal 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
597
src/views/Dashboard.vue
Normal 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
205
src/views/HostsManager.vue
Normal 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
555
src/views/MysqlManager.vue
Normal 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
517
src/views/NginxManager.vue
Normal 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
397
src/views/NodeManager.vue
Normal 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
835
src/views/PhpManager.vue
Normal 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
529
src/views/RedisManager.vue
Normal 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
244
src/views/Settings.vue
Normal 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">PHP、MySQL、Nginx、Redis 等服务的安装目录</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 开发环境管理工具,支持 PHP、MySQL、Nginx、Redis 的安装和管理。
|
||||||
|
</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
697
src/views/SitesManager.vue
Normal 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
110
src/vite-env.d.ts
vendored
Normal 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
26
tsconfig.json
Normal 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
11
tsconfig.node.json
Normal 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
70
vite.config.ts
Normal 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
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
Loading…
Reference in New Issue
Block a user