Compare commits

...

2 Commits

22 changed files with 1395 additions and 81 deletions

View File

@ -63,13 +63,16 @@
### 🐘 PHP 版本管理 ### 🐘 PHP 版本管理
| 功能 | 说明 | | 功能 | 说明 |
| ---------- | ---------------------------------------------------------- | | ------------ | ---------------------------------------------------------- |
| 多版本管理 | 支持同时安装 PHP 8.1、8.2、8.3、8.4、8.5 等多个版本 | | 多版本管理 | 支持同时安装 PHP 8.1、8.2、8.3、8.4、8.5 等多个版本 |
| CGI 独立控制 | 每个 PHP 版本可独立启动/停止 CGI 进程,支持多版本并行运行 |
| 端口自动分配 | 各版本自动分配端口(如 8.4→9084, 8.3→9083 |
| 一键切换 | 点击即可切换 PHP 版本,自动配置系统环境变量 | | 一键切换 | 点击即可切换 PHP 版本,自动配置系统环境变量 |
| 扩展管理 | 可视化管理 PHP 扩展,支持在线安装(从 PECL | | 扩展管理 | 可视化管理 PHP 扩展,支持在线安装(从 PECL |
| 配置编辑 | 在线编辑 php.ini无需手动查找配置文件 | | 配置编辑 | 在线编辑 php.ini无需手动查找配置文件 |
| 自动配置 | 安装时自动启用常用扩展curl、gd、mbstring、pdo_mysql 等) | | 自动配置 | 安装时自动启用常用扩展curl、gd、mbstring、pdo_mysql 等) |
| Composer | 集成 Composer 管理,支持镜像源切换(阿里云、腾讯云等) | | Composer | 集成 Composer 管理,支持镜像源切换(阿里云、腾讯云等) |
| 日志查看 | 直接查看 PHP 错误日志 |
| 下载源 | 从 [windows.php.net](https://windows.php.net) 官方下载 | | 下载源 | 从 [windows.php.net](https://windows.php.net) 官方下载 |
### 🐬 MySQL 管理 ### 🐬 MySQL 管理
@ -139,15 +142,31 @@
- 🎯 **Laravel 一键配置** - 自动配置 public 目录和伪静态规则 - 🎯 **Laravel 一键配置** - 自动配置 public 目录和伪静态规则
- 🔒 **SSL 证书申请** - 集成 Let's Encrypt 自动申请 - 🔒 **SSL 证书申请** - 集成 Let's Encrypt 自动申请
- 📝 **Hosts 自动配置** - 自动添加域名到系统 hosts 文件 - 📝 **Hosts 自动配置** - 自动添加域名到系统 hosts 文件
- 📋 **站点日志查看** - 查看每个站点的访问日志和错误日志
- 🌐 **一键打开站点** - 点击域名在默认浏览器打开
### 📋 日志查看
| 功能 | 说明 |
| ------------ | ---------------------------------------------- |
| 多服务日志 | 支持查看 Nginx、PHP、MySQL、Redis 日志 |
| 站点日志 | 查看各站点的访问日志和错误日志 |
| 实时刷新 | 支持刷新日志内容,查看最新记录 |
| 行数控制 | 可配置显示的日志行数100-5000 行) |
| 快速清空 | 一键清空指定日志文件 |
| 打开目录 | 快速在文件管理器中打开日志目录 |
### ⚙️ 其他功能 ### ⚙️ 其他功能
- 🚀 **开机自启动** - 可配置各服务开机自动启动 - 🚀 **开机自启动** - 可配置各服务开机自动启动(静默模式,无弹窗)
- 🔇 **静默启动** - 所有服务启动无黑色窗口闪烁
- 📋 **Hosts 管理** - 可视化管理系统 hosts 文件 - 📋 **Hosts 管理** - 可视化管理系统 hosts 文件
- 🌙 **深色/浅色主题** - 支持主题切换 - 🌙 **深色/浅色主题** - 支持主题切换
- 📊 **服务状态监控** - 实时显示各服务运行状态 - 📊 **服务状态监控** - 实时显示各服务运行状态
- ⏳ **加载状态提示** - 版本列表加载时显示 Loading 状态 - ⚡ **页面切换优化** - 使用 KeepAlive 缓存页面,切换无闪烁
- 🔢 **自动版本号** - 打包时自动更新版本号
- 📥 **下载源说明** - 清晰显示各软件的下载来源 - 📥 **下载源说明** - 清晰显示各软件的下载来源
- 🌐 **默认浏览器打开** - 站点链接自动在默认浏览器打开
## 🛠️ 技术栈 ## 🛠️ 技术栈
@ -186,8 +205,16 @@ npm run electron:dev
### 构建生产版本 ### 构建生产版本
```bash ```bash
# 构建 Windows 安装包 # 构建 Windows 安装包(自动更新 patch 版本号 +0.0.1
npm run electron:build npm run build
# 指定版本号更新类型
npm run build:patch # 1.0.0 -> 1.0.1
npm run build:minor # 1.0.0 -> 1.1.0
npm run build:major # 1.0.0 -> 2.0.0
# 不更新版本号直接打包
npm run build:nobump
``` ```
构建完成后,安装包将生成在 `release` 目录中。 构建完成后,安装包将生成在 `release` 目录中。
@ -209,14 +236,19 @@ phper/
│ ├── PythonManager.ts # Python 版本管理器 │ ├── PythonManager.ts # Python 版本管理器
│ ├── GitManager.ts # Git 管理器 │ ├── GitManager.ts # Git 管理器
│ ├── ServiceManager.ts # 开机自启服务管理器 │ ├── ServiceManager.ts # 开机自启服务管理器
│ └── HostsManager.ts # Hosts 文件管理器 │ ├── HostsManager.ts # Hosts 文件管理器
│ └── LogManager.ts # 日志管理器
├── src/ # Vue 前端源码 ├── src/ # Vue 前端源码
│ ├── App.vue # 根组件 │ ├── App.vue # 根组件(含 KeepAlive 缓存)
│ ├── main.ts # 入口文件 │ ├── main.ts # 入口文件
│ ├── vite-env.d.ts # 类型声明 │ ├── vite-env.d.ts # 类型声明
│ ├── router/ # 路由配置 │ ├── router/ # 路由配置
│ │ └── index.ts │ │ └── index.ts
│ ├── stores/ # Pinia 状态管理
│ │ └── serviceStore.ts # 服务状态存储
│ ├── components/ # 公共组件
│ │ └── LogViewer.vue # 日志查看器组件
│ ├── styles/ # 样式文件 │ ├── styles/ # 样式文件
│ │ └── main.scss # 全局样式(含主题变量) │ │ └── main.scss # 全局样式(含主题变量)
│ └── views/ # 页面视图 │ └── views/ # 页面视图
@ -232,8 +264,12 @@ phper/
│ ├── HostsManager.vue # Hosts 管理 │ ├── HostsManager.vue # Hosts 管理
│ └── Settings.vue # 设置 │ └── Settings.vue # 设置
├── scripts/ # 构建脚本
│ └── bump-version.js # 版本号自动更新脚本
├── public/ # 静态资源 ├── public/ # 静态资源
│ └── icon.svg # 应用图标 │ ├── icon.svg # 应用图标
│ └── version.json # 版本信息(构建时生成)
├── index.html # HTML 模板 ├── index.html # HTML 模板
├── package.json # 项目配置 ├── package.json # 项目配置

View File

@ -17,6 +17,7 @@ import { ServiceManager } from "./services/ServiceManager";
import { HostsManager } from "./services/HostsManager"; import { HostsManager } from "./services/HostsManager";
import { GitManager } from "./services/GitManager"; import { GitManager } from "./services/GitManager";
import { PythonManager } from "./services/PythonManager"; import { PythonManager } from "./services/PythonManager";
import { LogManager } from "./services/LogManager";
import { ConfigStore } from "./services/ConfigStore"; import { ConfigStore } from "./services/ConfigStore";
// 获取图标路径 // 获取图标路径
@ -120,6 +121,7 @@ const serviceManager = new ServiceManager(configStore);
const hostsManager = new HostsManager(); const hostsManager = new HostsManager();
const gitManager = new GitManager(configStore); const gitManager = new GitManager(configStore);
const pythonManager = new PythonManager(configStore); const pythonManager = new PythonManager(configStore);
const logManager = new LogManager(configStore);
function createWindow() { function createWindow() {
const appIcon = createWindowIcon(); const appIcon = createWindowIcon();
@ -575,9 +577,11 @@ ipcMain.handle("config:setBasePath", (_, path: string) =>
); );
// ==================== 应用设置 ==================== // ==================== 应用设置 ====================
// 设置开机自启(以管理员模式,使用任务计划程序 // 设置开机自启(以管理员模式,使用任务计划程序,静默启动
ipcMain.handle("app:setAutoLaunch", async (_, enabled: boolean) => { ipcMain.handle("app:setAutoLaunch", async (_, enabled: boolean) => {
const { execSync } = require("child_process"); const { execSync, exec } = require("child_process");
const { writeFileSync, unlinkSync, existsSync } = require("fs");
const { join } = require("path");
const exePath = app.getPath("exe"); const exePath = app.getPath("exe");
const taskName = "PHPerDevManager"; const taskName = "PHPerDevManager";
@ -601,12 +605,18 @@ ipcMain.handle("app:setAutoLaunch", async (_, enabled: boolean) => {
// 忽略删除失败(可能任务不存在) // 忽略删除失败(可能任务不存在)
} }
// 创建任务计划程序任务,以最高权限运行 // 创建 VBS 启动脚本(确保静默启动)
const command = `schtasks /create /tn "${taskName}" /tr "\\"${exePath}\\"" /sc onlogon /rl highest /f`; const appDir = require("path").dirname(exePath);
const vbsPath = join(appDir, "silent_start.vbs");
const vbsContent = `Set WshShell = CreateObject("WScript.Shell")\nWshShell.Run """${exePath.replace(/\\/g, "\\\\")}""", 0, False`;
writeFileSync(vbsPath, vbsContent);
// 创建任务计划程序任务,运行 VBS 脚本实现静默启动
const command = `schtasks /create /tn "${taskName}" /tr "wscript.exe \\"${vbsPath}\\"" /sc onlogon /rl highest /f`;
execSync(command, { encoding: "buffer", windowsHide: true }); execSync(command, { encoding: "buffer", windowsHide: true });
configStore.set("autoLaunch", true); configStore.set("autoLaunch", true);
return { success: true, message: "已启用开机自启(管理员模式)" }; return { success: true, message: "已启用开机自启(静默模式)" };
} else { } else {
// 删除任务计划程序任务 // 删除任务计划程序任务
try { try {
@ -617,6 +627,18 @@ ipcMain.handle("app:setAutoLaunch", async (_, enabled: boolean) => {
} catch (e) { } catch (e) {
// 忽略删除失败 // 忽略删除失败
} }
// 删除 VBS 脚本
const appDir = require("path").dirname(exePath);
const vbsPath = join(appDir, "silent_start.vbs");
if (existsSync(vbsPath)) {
try {
unlinkSync(vbsPath);
} catch (e) {
// 忽略删除失败
}
}
configStore.set("autoLaunch", false); configStore.set("autoLaunch", false);
return { success: true, message: "已禁用开机自启" }; return { success: true, message: "已禁用开机自启" };
} }
@ -650,6 +672,38 @@ ipcMain.handle("app:getAutoLaunch", async () => {
} }
}); });
// 获取应用版本信息
ipcMain.handle("app:getVersion", async () => {
const { existsSync, readFileSync } = require("fs");
const { join } = require("path");
const version = app.getVersion();
let buildTime = "";
let buildDate = "";
// 尝试读取版本信息文件
try {
const versionFilePath = app.isPackaged
? join(process.resourcesPath, "public", "version.json")
: join(__dirname, "..", "public", "version.json");
if (existsSync(versionFilePath)) {
const versionInfo = JSON.parse(readFileSync(versionFilePath, "utf-8"));
buildTime = versionInfo.buildTime || "";
buildDate = versionInfo.buildDate || "";
}
} catch (e) {
// 忽略错误
}
return {
version,
buildTime,
buildDate,
isPackaged: app.isPackaged
};
});
// 设置启动时最小化到托盘 // 设置启动时最小化到托盘
ipcMain.handle("app:setStartMinimized", (_, enabled: boolean) => { ipcMain.handle("app:setStartMinimized", (_, enabled: boolean) => {
configStore.set("startMinimized", enabled); configStore.set("startMinimized", enabled);
@ -666,3 +720,13 @@ ipcMain.handle("app:quit", () => {
isQuitting = true; isQuitting = true;
app.quit(); app.quit();
}); });
// ==================== 日志管理 ====================
ipcMain.handle("log:getFiles", () => logManager.getLogFiles());
ipcMain.handle("log:read", (_, logPath: string, lines?: number) =>
logManager.readLog(logPath, lines)
);
ipcMain.handle("log:clear", (_, logPath: string) => logManager.clearLog(logPath));
ipcMain.handle("log:getDirectory", (_, type: 'nginx' | 'php' | 'mysql' | 'sites', version?: string) =>
logManager.getLogDirectory(type, version)
);

View File

@ -166,12 +166,22 @@ contextBridge.exposeInMainWorld('electronAPI', {
setBasePath: (path: string) => ipcRenderer.invoke('config:setBasePath', path) setBasePath: (path: string) => ipcRenderer.invoke('config:setBasePath', path)
}, },
// 日志管理
log: {
getFiles: () => ipcRenderer.invoke('log:getFiles'),
read: (logPath: string, lines?: number) => ipcRenderer.invoke('log:read', logPath, lines),
clear: (logPath: string) => ipcRenderer.invoke('log:clear', logPath),
getDirectory: (type: 'nginx' | 'php' | 'mysql' | 'sites', version?: string) =>
ipcRenderer.invoke('log:getDirectory', type, version)
},
// 应用设置 // 应用设置
app: { app: {
setAutoLaunch: (enabled: boolean) => ipcRenderer.invoke('app:setAutoLaunch', enabled), setAutoLaunch: (enabled: boolean) => ipcRenderer.invoke('app:setAutoLaunch', enabled),
getAutoLaunch: () => ipcRenderer.invoke('app:getAutoLaunch'), getAutoLaunch: () => ipcRenderer.invoke('app:getAutoLaunch'),
setStartMinimized: (enabled: boolean) => ipcRenderer.invoke('app:setStartMinimized', enabled), setStartMinimized: (enabled: boolean) => ipcRenderer.invoke('app:setStartMinimized', enabled),
getStartMinimized: () => ipcRenderer.invoke('app:getStartMinimized'), getStartMinimized: () => ipcRenderer.invoke('app:getStartMinimized'),
getVersion: () => ipcRenderer.invoke('app:getVersion') as Promise<{ version: string; buildTime: string; buildDate: string; isPackaged: boolean }>,
setAutoStartServices: (enabled: boolean) => ipcRenderer.invoke('app:setAutoStartServices', enabled), setAutoStartServices: (enabled: boolean) => ipcRenderer.invoke('app:setAutoStartServices', enabled),
getAutoStartServices: () => ipcRenderer.invoke('app:getAutoStartServices'), getAutoStartServices: () => ipcRenderer.invoke('app:getAutoStartServices'),
quit: () => ipcRenderer.invoke('app:quit') quit: () => ipcRenderer.invoke('app:quit')

View File

@ -0,0 +1,277 @@
import { ConfigStore } from './ConfigStore'
import { existsSync, readFileSync, readdirSync, statSync } from 'fs'
import { join, basename } from 'path'
export interface LogFile {
name: string
path: string
size: number
modifiedTime: Date
type: 'nginx' | 'nginx-error' | 'nginx-access' | 'php' | 'mysql' | 'mysql-error' | 'site-access' | 'site-error'
}
export interface LogContent {
content: string
totalLines: number
fileSize: number
}
export class LogManager {
private configStore: ConfigStore
constructor(configStore: ConfigStore) {
this.configStore = configStore
}
/**
*
*/
async getLogFiles(): Promise<{ nginx: LogFile[], php: LogFile[], mysql: LogFile[], sites: LogFile[] }> {
const result = {
nginx: [] as LogFile[],
php: [] as LogFile[],
mysql: [] as LogFile[],
sites: [] as LogFile[]
}
// Nginx 日志
const nginxPath = this.configStore.getNginxPath()
const nginxLogsDir = join(nginxPath, 'logs')
if (existsSync(nginxLogsDir)) {
const files = this.scanLogDir(nginxLogsDir)
for (const file of files) {
if (file.name.includes('error')) {
result.nginx.push({ ...file, type: 'nginx-error' })
} else if (file.name.includes('access')) {
result.nginx.push({ ...file, type: 'nginx-access' })
} else {
result.nginx.push({ ...file, type: 'nginx' })
}
}
}
// PHP 日志 - 检查每个 PHP 版本的日志
const phpVersions = this.configStore.get('phpVersions') || []
for (const version of phpVersions) {
const phpPath = this.configStore.getPhpPath(version)
const phpLogsDir = join(phpPath, 'logs')
if (existsSync(phpLogsDir)) {
const files = this.scanLogDir(phpLogsDir)
for (const file of files) {
result.php.push({ ...file, type: 'php', name: `[${version}] ${file.name}` })
}
}
// 也检查 php.ini 中配置的 error_log
const phpErrorLog = join(phpPath, 'php_errors.log')
if (existsSync(phpErrorLog)) {
const stat = statSync(phpErrorLog)
result.php.push({
name: `[${version}] php_errors.log`,
path: phpErrorLog,
size: stat.size,
modifiedTime: stat.mtime,
type: 'php'
})
}
}
// MySQL 日志
const mysqlVersions = this.configStore.get('mysqlVersions') || []
for (const version of mysqlVersions) {
const mysqlPath = this.configStore.getMysqlPath(version)
// MySQL 日志通常在 data 目录下
const mysqlDataDir = join(mysqlPath, 'data')
if (existsSync(mysqlDataDir)) {
const files = readdirSync(mysqlDataDir)
for (const file of files) {
if (file.endsWith('.err') || file.endsWith('.log')) {
const filePath = join(mysqlDataDir, file)
const stat = statSync(filePath)
const logType = file.includes('error') || file.endsWith('.err') ? 'mysql-error' : 'mysql'
result.mysql.push({
name: `[${version}] ${file}`,
path: filePath,
size: stat.size,
modifiedTime: stat.mtime,
type: logType
})
}
}
}
// 也检查 logs 目录
const mysqlLogsDir = join(mysqlPath, 'logs')
if (existsSync(mysqlLogsDir)) {
const files = this.scanLogDir(mysqlLogsDir)
for (const file of files) {
result.mysql.push({ ...file, type: 'mysql', name: `[${version}] ${file.name}` })
}
}
}
// 站点日志 - Nginx sites logs
const sites = this.configStore.get('sites') || []
for (const site of sites) {
// 站点日志通常在 nginx/logs 目录下,以域名命名
const siteAccessLog = join(nginxLogsDir, `${site.domain}.access.log`)
const siteErrorLog = join(nginxLogsDir, `${site.domain}.error.log`)
if (existsSync(siteAccessLog)) {
const stat = statSync(siteAccessLog)
result.sites.push({
name: `${site.domain} - 访问日志`,
path: siteAccessLog,
size: stat.size,
modifiedTime: stat.mtime,
type: 'site-access'
})
}
if (existsSync(siteErrorLog)) {
const stat = statSync(siteErrorLog)
result.sites.push({
name: `${site.domain} - 错误日志`,
path: siteErrorLog,
size: stat.size,
modifiedTime: stat.mtime,
type: 'site-error'
})
}
}
return result
}
/**
*
* @param logPath
* @param lines 500
*/
async readLog(logPath: string, lines: number = 500): Promise<LogContent> {
if (!existsSync(logPath)) {
return { content: '日志文件不存在', totalLines: 0, fileSize: 0 }
}
try {
const stat = statSync(logPath)
const fileSize = stat.size
// 如果文件小于 1MB直接读取全部内容
if (fileSize < 1024 * 1024) {
const content = readFileSync(logPath, 'utf-8')
const allLines = content.split('\n')
const totalLines = allLines.length
// 取最后 N 行
const lastLines = allLines.slice(-lines).join('\n')
return { content: lastLines, totalLines, fileSize }
}
// 大文件:从末尾读取
const content = await this.readLastLines(logPath, lines)
return { content, totalLines: lines, fileSize }
} catch (error: any) {
return { content: `读取日志失败: ${error.message}`, totalLines: 0, fileSize: 0 }
}
}
/**
*
*/
private async readLastLines(filePath: string, lines: number): Promise<string> {
const fs = await import('fs/promises')
const stat = await fs.stat(filePath)
const fileSize = stat.size
// 估算需要读取的字节数(假设每行约 200 字节)
const bytesToRead = Math.min(fileSize, lines * 200)
const startPosition = Math.max(0, fileSize - bytesToRead)
const buffer = Buffer.alloc(bytesToRead)
const fd = await fs.open(filePath, 'r')
await fd.read(buffer, 0, bytesToRead, startPosition)
await fd.close()
const content = buffer.toString('utf-8')
const allLines = content.split('\n')
// 第一行可能不完整,跳过
const completeLines = startPosition === 0 ? allLines : allLines.slice(1)
return completeLines.slice(-lines).join('\n')
}
/**
*
*/
async clearLog(logPath: string): Promise<{ success: boolean, message: string }> {
if (!existsSync(logPath)) {
return { success: false, message: '日志文件不存在' }
}
try {
const fs = await import('fs/promises')
await fs.writeFile(logPath, '')
return { success: true, message: '日志已清空' }
} catch (error: any) {
return { success: false, message: `清空日志失败: ${error.message}` }
}
}
/**
*
*/
private scanLogDir(dir: string): LogFile[] {
const files: LogFile[] = []
if (!existsSync(dir)) {
return files
}
try {
const items = readdirSync(dir)
for (const item of items) {
const filePath = join(dir, item)
const stat = statSync(filePath)
if (stat.isFile() && (item.endsWith('.log') || item.endsWith('.err'))) {
files.push({
name: item,
path: filePath,
size: stat.size,
modifiedTime: stat.mtime,
type: 'nginx' // 默认类型,调用方会覆盖
})
}
}
} catch (error) {
console.error('扫描日志目录失败:', error)
}
return files.sort((a, b) => b.modifiedTime.getTime() - a.modifiedTime.getTime())
}
/**
*
*/
getLogDirectory(type: 'nginx' | 'php' | 'mysql' | 'sites', version?: string): string {
switch (type) {
case 'nginx':
return join(this.configStore.getNginxPath(), 'logs')
case 'php':
if (version) {
return join(this.configStore.getPhpPath(version), 'logs')
}
return join(this.configStore.getBasePath(), 'php')
case 'mysql':
if (version) {
return join(this.configStore.getMysqlPath(version), 'data')
}
return join(this.configStore.getBasePath(), 'mysql')
case 'sites':
return join(this.configStore.getNginxPath(), 'logs')
default:
return this.configStore.getBasePath()
}
}
}

View File

@ -331,9 +331,12 @@ export class NginxManager {
return { success: true, message: 'Nginx 已经在运行' } return { success: true, message: 'Nginx 已经在运行' }
} }
// 启动 Nginx // 使用 VBScript 静默启动 Nginx
const child = spawn(nginxExe, [], { const vbsPath = join(nginxPath, 'start_nginx.vbs')
cwd: nginxPath, const vbsContent = `Set WshShell = CreateObject("WScript.Shell")\nWshShell.CurrentDirectory = "${nginxPath.replace(/\\/g, '\\\\')}"\nWshShell.Run """${nginxExe.replace(/\\/g, '\\\\')}""", 0, False`
writeFileSync(vbsPath, vbsContent)
const child = spawn('wscript.exe', [vbsPath], {
detached: true, detached: true,
stdio: 'ignore', stdio: 'ignore',
windowsHide: true windowsHide: true

View File

@ -307,15 +307,16 @@ export class RedisManager {
await this.createDefaultConfig() await this.createDefaultConfig()
} }
// 使用相对路径启动(避免 Cygwin 路径问题) // 使用 VBScript 静默启动 Redis避免黑窗口闪烁
// Redis Windows 版本使用 Cygwin需要在正确的工作目录下用相对路径
const configFileName = 'redis.windows.conf' const configFileName = 'redis.windows.conf'
const child = spawn(redisServer, [configFileName], { const vbsPath = join(redisPath, 'start_redis.vbs')
cwd: redisPath, const vbsContent = `Set WshShell = CreateObject("WScript.Shell")\nWshShell.CurrentDirectory = "${redisPath.replace(/\\/g, '\\\\')}"\nWshShell.Run """${redisServer.replace(/\\/g, '\\\\')}""" & " " & "${configFileName}", 0, False`
writeFileSync(vbsPath, vbsContent)
const child = spawn('wscript.exe', [vbsPath], {
detached: true, detached: true,
stdio: 'ignore', stdio: 'ignore',
windowsHide: true, windowsHide: true
shell: false
}) })
child.unref() child.unref()

View File

@ -1,7 +1,7 @@
import { ConfigStore } from './ConfigStore' import { ConfigStore } from './ConfigStore'
import { exec, spawn } from 'child_process' import { exec, spawn } from 'child_process'
import { promisify } from 'util' import { promisify } from 'util'
import { existsSync, writeFileSync, readFileSync, mkdirSync, readdirSync } from 'fs' import { existsSync, writeFileSync, readFileSync, mkdirSync, readdirSync, unlinkSync } from 'fs'
import { join } from 'path' import { join } from 'path'
const execAsync = promisify(exec) const execAsync = promisify(exec)
@ -562,6 +562,32 @@ export class ServiceManager {
} }
private async startProcess(exe: string, args: string[], cwd: string): Promise<void> { private async startProcess(exe: string, args: string[], cwd: string): Promise<void> {
// 使用 VBScript 来完全隐藏窗口启动进程
const argsStr = args.map(a => `"${a}"`).join(' ')
const command = args.length > 0 ? `"${exe}" ${argsStr}` : `"${exe}"`
const vbsContent = `Set WshShell = CreateObject("WScript.Shell")\nWshShell.Run ${JSON.stringify(command)}, 0, False`
const vbsPath = join(cwd, `start_${Date.now()}.vbs`)
try {
writeFileSync(vbsPath, vbsContent)
await execAsync(`cscript //nologo "${vbsPath}"`, {
cwd,
windowsHide: true,
timeout: 10000
})
// 延迟删除 VBS 文件
setTimeout(() => {
try {
if (existsSync(vbsPath)) {
unlinkSync(vbsPath)
}
} catch (e) {
// 忽略删除失败
}
}, 2000)
} catch (error) {
// 如果 VBS 方式失败,回退到 spawn
const child = spawn(exe, args, { const child = spawn(exe, args, {
cwd, cwd,
detached: true, detached: true,
@ -570,5 +596,6 @@ export class ServiceManager {
}) })
child.unref() child.unref()
} }
}
} }

View File

@ -5,10 +5,14 @@
"main": "dist-electron/main.js", "main": "dist-electron/main.js",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "vue-tsc --noEmit && vite build && electron-builder", "build": "node scripts/bump-version.js && vue-tsc --noEmit && vite build && electron-builder",
"build:patch": "node scripts/bump-version.js patch && vue-tsc --noEmit && vite build && electron-builder",
"build:minor": "node scripts/bump-version.js minor && vue-tsc --noEmit && vite build && electron-builder",
"build:major": "node scripts/bump-version.js major && vue-tsc --noEmit && vite build && electron-builder",
"build:nobump": "vue-tsc --noEmit && vite build && electron-builder",
"preview": "vite preview", "preview": "vite preview",
"electron:dev": "vite", "electron:dev": "vite",
"electron:build": "vite build && electron-builder" "electron:build": "node scripts/bump-version.js && vite build && electron-builder"
}, },
"author": "PHPer", "author": "PHPer",
"license": "MIT", "license": "MIT",

58
scripts/bump-version.js Normal file
View File

@ -0,0 +1,58 @@
/**
* 自动更新版本号脚本
* 每次打包时自动增加 patch 版本号
*
* 用法:
* node scripts/bump-version.js # patch: 1.0.0 -> 1.0.1
* node scripts/bump-version.js minor # minor: 1.0.0 -> 1.1.0
* node scripts/bump-version.js major # major: 1.0.0 -> 2.0.0
*/
const fs = require('fs')
const path = require('path')
const packagePath = path.join(__dirname, '..', 'package.json')
const pkg = JSON.parse(fs.readFileSync(packagePath, 'utf-8'))
const currentVersion = pkg.version
const [major, minor, patch] = currentVersion.split('.').map(Number)
// 获取命令行参数
const bumpType = process.argv[2] || 'patch'
let newVersion
switch (bumpType) {
case 'major':
newVersion = `${major + 1}.0.0`
break
case 'minor':
newVersion = `${major}.${minor + 1}.0`
break
case 'patch':
default:
newVersion = `${major}.${minor}.${patch + 1}`
break
}
// 更新 package.json
pkg.version = newVersion
fs.writeFileSync(packagePath, JSON.stringify(pkg, null, 2) + '\n')
// 生成构建时间戳
const buildTime = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19)
console.log(`✅ 版本号已更新: ${currentVersion} -> ${newVersion}`)
console.log(`📦 构建时间: ${buildTime}`)
// 将版本信息写入一个文件,供应用读取
const versionInfo = {
version: newVersion,
buildTime: new Date().toISOString(),
buildDate: new Date().toLocaleDateString('zh-CN')
}
const versionFilePath = path.join(__dirname, '..', 'public', 'version.json')
fs.writeFileSync(versionFilePath, JSON.stringify(versionInfo, null, 2))
console.log(`📄 版本信息已写入: public/version.json`)

View File

@ -62,10 +62,10 @@
<!-- 内容区 --> <!-- 内容区 -->
<main class="content"> <main class="content">
<router-view v-slot="{ Component }"> <router-view v-slot="{ Component, route }">
<transition name="fade" mode="out-in"> <keep-alive :include="cachedViews">
<component :is="Component" /> <component :is="Component" :key="route.path" />
</transition> </keep-alive>
</router-view> </router-view>
</main> </main>
</div> </div>
@ -83,6 +83,21 @@ const isDark = ref(true)
const startingAll = ref(false) const startingAll = ref(false)
const stoppingAll = ref(false) const stoppingAll = ref(false)
// -
const cachedViews = [
'Dashboard',
'PhpManager',
'MysqlManager',
'NginxManager',
'RedisManager',
'NodeManager',
'PythonManager',
'GitManager',
'SitesManager',
'HostsManager',
'Settings'
]
// store // store
const serviceStatus = computed(() => ({ const serviceStatus = computed(() => ({
nginx: store.serviceStatus.nginx, nginx: store.serviceStatus.nginx,
@ -352,14 +367,5 @@ onUnmounted(() => {
background: var(--bg-content); background: var(--bg-content);
} }
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.15s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
</style> </style>

View File

@ -0,0 +1,512 @@
<template>
<el-dialog
v-model="visible"
:title="dialogTitle"
width="900px"
class="log-viewer-dialog"
:close-on-click-modal="false"
@closed="onClosed"
>
<!-- 日志文件选择器 -->
<div class="log-selector">
<el-tabs v-model="activeTab" @tab-change="onTabChange">
<el-tab-pane label="Nginx" name="nginx">
<div class="log-list" v-if="logFiles.nginx.length > 0">
<div
v-for="file in logFiles.nginx"
:key="file.path"
class="log-item"
:class="{ active: selectedLog?.path === file.path }"
@click="selectLog(file)"
>
<div class="log-item-info">
<el-icon :class="getLogIcon(file.type)"><Document /></el-icon>
<span class="log-name">{{ file.name }}</span>
</div>
<div class="log-item-meta">
<span class="log-size">{{ formatSize(file.size) }}</span>
<span class="log-time">{{ formatTime(file.modifiedTime) }}</span>
</div>
</div>
</div>
<div v-else class="empty-log">暂无 Nginx 日志文件</div>
</el-tab-pane>
<el-tab-pane label="PHP" name="php">
<div class="log-list" v-if="logFiles.php.length > 0">
<div
v-for="file in logFiles.php"
:key="file.path"
class="log-item"
:class="{ active: selectedLog?.path === file.path }"
@click="selectLog(file)"
>
<div class="log-item-info">
<el-icon class="php-icon"><Document /></el-icon>
<span class="log-name">{{ file.name }}</span>
</div>
<div class="log-item-meta">
<span class="log-size">{{ formatSize(file.size) }}</span>
<span class="log-time">{{ formatTime(file.modifiedTime) }}</span>
</div>
</div>
</div>
<div v-else class="empty-log">暂无 PHP 日志文件</div>
</el-tab-pane>
<el-tab-pane label="MySQL" name="mysql">
<div class="log-list" v-if="logFiles.mysql.length > 0">
<div
v-for="file in logFiles.mysql"
:key="file.path"
class="log-item"
:class="{ active: selectedLog?.path === file.path }"
@click="selectLog(file)"
>
<div class="log-item-info">
<el-icon class="mysql-icon"><Document /></el-icon>
<span class="log-name">{{ file.name }}</span>
</div>
<div class="log-item-meta">
<span class="log-size">{{ formatSize(file.size) }}</span>
<span class="log-time">{{ formatTime(file.modifiedTime) }}</span>
</div>
</div>
</div>
<div v-else class="empty-log">暂无 MySQL 日志文件</div>
</el-tab-pane>
<el-tab-pane label="站点" name="sites">
<div class="log-list" v-if="logFiles.sites.length > 0">
<div
v-for="file in logFiles.sites"
:key="file.path"
class="log-item"
:class="{ active: selectedLog?.path === file.path }"
@click="selectLog(file)"
>
<div class="log-item-info">
<el-icon :class="file.type === 'site-error' ? 'error-icon' : 'access-icon'">
<Document />
</el-icon>
<span class="log-name">{{ file.name }}</span>
</div>
<div class="log-item-meta">
<span class="log-size">{{ formatSize(file.size) }}</span>
<span class="log-time">{{ formatTime(file.modifiedTime) }}</span>
</div>
</div>
</div>
<div v-else class="empty-log">暂无站点日志文件</div>
</el-tab-pane>
</el-tabs>
</div>
<!-- 日志内容区域 -->
<div class="log-content-area" v-if="selectedLog">
<div class="log-toolbar">
<div class="toolbar-left">
<span class="selected-log-name">{{ selectedLog.name }}</span>
<el-tag size="small" type="info">{{ formatSize(logContent.fileSize) }}</el-tag>
<el-tag size="small" type="info">{{ logContent.totalLines }} </el-tag>
</div>
<div class="toolbar-right">
<el-button size="small" @click="refreshLog" :loading="loading">
<el-icon><Refresh /></el-icon>
刷新
</el-button>
<el-button size="small" @click="openInExplorer">
<el-icon><FolderOpened /></el-icon>
打开目录
</el-button>
<el-button size="small" type="danger" @click="clearLog">
<el-icon><Delete /></el-icon>
清空
</el-button>
</div>
</div>
<div class="log-content-wrapper">
<pre class="log-content" ref="logContentRef">{{ logContent.content || '(日志为空)' }}</pre>
</div>
</div>
<div class="log-content-area empty" v-else>
<el-icon class="empty-icon"><Document /></el-icon>
<p>请选择左侧的日志文件查看</p>
</div>
</el-dialog>
</template>
<script setup lang="ts">
import { ref, reactive, watch, nextTick } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Document, Refresh, FolderOpened, Delete } from '@element-plus/icons-vue'
interface LogFile {
name: string
path: string
size: number
modifiedTime: string | Date
type: string
}
interface LogContent {
content: string
totalLines: number
fileSize: number
}
const props = defineProps<{
modelValue: boolean
initialTab?: 'nginx' | 'php' | 'mysql' | 'sites'
}>()
const emit = defineEmits<{
(e: 'update:modelValue', value: boolean): void
}>()
const visible = ref(false)
const activeTab = ref<'nginx' | 'php' | 'mysql' | 'sites'>('nginx')
const loading = ref(false)
const logContentRef = ref<HTMLElement | null>(null)
const logFiles = reactive<{
nginx: LogFile[]
php: LogFile[]
mysql: LogFile[]
sites: LogFile[]
}>({
nginx: [],
php: [],
mysql: [],
sites: []
})
const selectedLog = ref<LogFile | null>(null)
const logContent = reactive<LogContent>({
content: '',
totalLines: 0,
fileSize: 0
})
const dialogTitle = ref('日志查看器')
// modelValue
watch(() => props.modelValue, async (newVal) => {
visible.value = newVal
if (newVal) {
if (props.initialTab) {
activeTab.value = props.initialTab
}
await loadLogFiles()
}
})
//
watch(visible, (newVal) => {
emit('update:modelValue', newVal)
})
//
const loadLogFiles = async () => {
loading.value = true
try {
const files = await window.electronAPI?.log.getFiles()
if (files) {
logFiles.nginx = files.nginx || []
logFiles.php = files.php || []
logFiles.mysql = files.mysql || []
logFiles.sites = files.sites || []
}
} catch (error: any) {
ElMessage.error('加载日志列表失败: ' + error.message)
} finally {
loading.value = false
}
}
//
const selectLog = async (file: LogFile) => {
selectedLog.value = file
await loadLogContent(file.path)
}
//
const loadLogContent = async (logPath: string) => {
loading.value = true
try {
const result = await window.electronAPI?.log.read(logPath, 1000)
if (result) {
logContent.content = result.content
logContent.totalLines = result.totalLines
logContent.fileSize = result.fileSize
//
await nextTick()
if (logContentRef.value) {
logContentRef.value.scrollTop = logContentRef.value.scrollHeight
}
}
} catch (error: any) {
ElMessage.error('读取日志失败: ' + error.message)
} finally {
loading.value = false
}
}
//
const refreshLog = async () => {
if (selectedLog.value) {
await loadLogContent(selectedLog.value.path)
}
await loadLogFiles()
}
//
const clearLog = async () => {
if (!selectedLog.value) return
try {
await ElMessageBox.confirm(
`确定要清空日志 "${selectedLog.value.name}" 吗?此操作不可恢复。`,
'确认清空',
{ type: 'warning' }
)
const result = await window.electronAPI?.log.clear(selectedLog.value.path)
if (result?.success) {
ElMessage.success(result.message)
await refreshLog()
} else {
ElMessage.error(result?.message || '清空失败')
}
} catch (error: any) {
if (error !== 'cancel') {
ElMessage.error('操作失败: ' + error.message)
}
}
}
//
const openInExplorer = async () => {
if (!selectedLog.value) return
//
const path = selectedLog.value.path
const dir = path.substring(0, path.lastIndexOf('\\'))
await window.electronAPI?.openPath(dir)
}
// Tab
const onTabChange = () => {
selectedLog.value = null
logContent.content = ''
logContent.totalLines = 0
logContent.fileSize = 0
}
//
const onClosed = () => {
selectedLog.value = null
logContent.content = ''
}
//
const getLogIcon = (type: string) => {
switch (type) {
case 'nginx-error':
case 'mysql-error':
case 'site-error':
return 'error-icon'
case 'nginx-access':
case 'site-access':
return 'access-icon'
default:
return ''
}
}
//
const formatSize = (bytes: number): string => {
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]
}
//
const formatTime = (time: string | Date): string => {
const date = typeof time === 'string' ? new Date(time) : time
const now = new Date()
const diff = now.getTime() - date.getTime()
if (diff < 60000) return '刚刚'
if (diff < 3600000) return Math.floor(diff / 60000) + ' 分钟前'
if (diff < 86400000) return Math.floor(diff / 3600000) + ' 小时前'
if (diff < 604800000) return Math.floor(diff / 86400000) + ' 天前'
return date.toLocaleDateString()
}
</script>
<style lang="scss" scoped>
.log-viewer-dialog {
:deep(.el-dialog__body) {
padding: 0;
display: flex;
flex-direction: column;
height: 600px;
}
}
.log-selector {
border-bottom: 1px solid var(--border-color);
padding: 16px 20px 0;
:deep(.el-tabs__nav-wrap) {
&::after {
display: none;
}
}
:deep(.el-tabs__item) {
font-size: 14px;
}
}
.log-list {
max-height: 150px;
overflow-y: auto;
padding: 12px 0;
}
.log-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 10px 16px;
border-radius: 8px;
cursor: pointer;
transition: all 0.2s;
&:hover {
background: var(--bg-hover);
}
&.active {
background: rgba(124, 58, 237, 0.1);
border-left: 3px solid var(--accent-color);
padding-left: 13px;
}
.log-item-info {
display: flex;
align-items: center;
gap: 10px;
.el-icon {
font-size: 18px;
color: var(--text-muted);
&.error-icon {
color: var(--error-color);
}
&.access-icon {
color: var(--success-color);
}
&.php-icon {
color: #777BB4;
}
&.mysql-icon {
color: #00758F;
}
}
.log-name {
font-size: 14px;
color: var(--text-primary);
}
}
.log-item-meta {
display: flex;
gap: 16px;
font-size: 12px;
color: var(--text-muted);
}
}
.empty-log {
padding: 30px;
text-align: center;
color: var(--text-muted);
font-size: 14px;
}
.log-content-area {
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
&.empty {
justify-content: center;
align-items: center;
color: var(--text-muted);
.empty-icon {
font-size: 48px;
margin-bottom: 12px;
}
}
}
.log-toolbar {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 20px;
background: var(--bg-input);
border-bottom: 1px solid var(--border-color);
.toolbar-left {
display: flex;
align-items: center;
gap: 12px;
.selected-log-name {
font-weight: 600;
font-size: 14px;
color: var(--text-primary);
}
}
.toolbar-right {
display: flex;
gap: 8px;
}
}
.log-content-wrapper {
flex: 1;
overflow: auto;
background: #0d1117;
min-height: 0;
}
.log-content {
margin: 0;
padding: 16px 20px;
font-family: 'Fira Code', 'Consolas', monospace;
font-size: 12px;
line-height: 1.6;
color: #c9d1d9;
white-space: pre-wrap;
word-break: break-all;
min-height: 100%;
}
</style>

View File

@ -248,10 +248,9 @@
class="mini-site-card" class="mini-site-card"
> >
<a <a
:href="(site.ssl ? 'https://' : 'http://') + site.domain" href="#"
target="_blank"
class="site-domain-link" class="site-domain-link"
@click.stop @click.prevent="openSite(site)"
> >
{{ site.domain }} {{ site.domain }}
<el-icon class="link-icon"><Link /></el-icon> <el-icon class="link-icon"><Link /></el-icon>
@ -277,11 +276,17 @@
<el-icon><InfoFilled /></el-icon> <el-icon><InfoFilled /></el-icon>
安装路径 安装路径
</span> </span>
<div class="card-actions">
<el-button size="small" @click="showLogViewer = true">
<el-icon><Document /></el-icon>
查看日志
</el-button>
<el-button size="small" @click="openBasePath"> <el-button size="small" @click="openBasePath">
<el-icon><FolderOpened /></el-icon> <el-icon><FolderOpened /></el-icon>
打开目录 打开目录
</el-button> </el-button>
</div> </div>
</div>
<div class="card-content"> <div class="card-content">
<div class="path-display"> <div class="path-display">
<span class="path-label">基础路径</span> <span class="path-label">基础路径</span>
@ -289,14 +294,23 @@
</div> </div>
</div> </div>
</div> </div>
<!-- 日志查看器 -->
<LogViewer v-model="showLogViewer" />
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, onMounted, watch } from 'vue' import { ref, computed, onMounted, onActivated } from 'vue'
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
import { Link, Promotion } from '@element-plus/icons-vue' import { Link, Promotion, Document } from '@element-plus/icons-vue'
import { useServiceStore } from '@/stores/serviceStore' import { useServiceStore } from '@/stores/serviceStore'
import LogViewer from '@/components/LogViewer.vue'
// 便 KeepAlive
defineOptions({
name: 'Dashboard'
})
const store = useServiceStore() const store = useServiceStore()
@ -352,6 +366,7 @@ const basePath = computed(() => store.basePath)
const settingPhp = ref('') const settingPhp = ref('')
const settingNode = ref('') const settingNode = ref('')
const showLogViewer = ref(false)
const startService = async (service: Service) => { const startService = async (service: Service) => {
serviceLoadingState.value[service.name] = true serviceLoadingState.value[service.name] = true
@ -533,6 +548,12 @@ const openBasePath = async () => {
} }
} }
//
const openSite = (site: { domain: string, ssl: boolean }) => {
const protocol = site.ssl ? 'https' : 'http'
window.electronAPI?.openExternal(`${protocol}://${site.domain}`)
}
const setActivePhp = async (version: string) => { const setActivePhp = async (version: string) => {
settingPhp.value = version settingPhp.value = version
try { try {
@ -570,8 +591,15 @@ const setActiveNode = async (version: string) => {
} }
onMounted(async () => { onMounted(async () => {
// //
if (store.phpVersions.length === 0 && store.nodeVersions.length === 0) {
await store.refreshAll() await store.refreshAll()
}
})
//
onActivated(async () => {
await store.refreshServiceStatus()
}) })
</script> </script>

View File

@ -177,6 +177,11 @@
import { ref, reactive, onMounted, onUnmounted } from 'vue' import { ref, reactive, onMounted, onUnmounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus' import { ElMessage, ElMessageBox } from 'element-plus'
// 便 KeepAlive
defineOptions({
name: 'GitManager'
})
interface GitVersion { interface GitVersion {
version: string version: string
path: string path: string

View File

@ -89,6 +89,11 @@
import { ref, reactive, onMounted } from 'vue' import { ref, reactive, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus' import { ElMessage, ElMessageBox } from 'element-plus'
// 便 KeepAlive
defineOptions({
name: 'HostsManager'
})
interface HostEntry { interface HostEntry {
ip: string ip: string
domain: string domain: string

View File

@ -87,9 +87,13 @@
密码 密码
</el-button> </el-button>
<el-button size="small" @click="showConfig(version)"> <el-button size="small" @click="showConfig(version)">
<el-icon><Document /></el-icon> <el-icon><Setting /></el-icon>
配置 配置
</el-button> </el-button>
<el-button size="small" @click="showLogViewerDialog">
<el-icon><Document /></el-icon>
日志
</el-button>
<el-button <el-button
type="danger" type="danger"
size="small" size="small"
@ -222,14 +226,23 @@
</el-button> </el-button>
</template> </template>
</el-dialog> </el-dialog>
<!-- 日志查看器 -->
<LogViewer v-model="showLogViewer" initial-tab="mysql" />
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, reactive, onMounted, onUnmounted } from 'vue' import { ref, reactive, onMounted, onUnmounted, onActivated } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus' import { ElMessage, ElMessageBox } from 'element-plus'
import { InfoFilled } from '@element-plus/icons-vue' import { InfoFilled, Setting } from '@element-plus/icons-vue'
import { useServiceStore } from '@/stores/serviceStore' import { useServiceStore } from '@/stores/serviceStore'
import LogViewer from '@/components/LogViewer.vue'
// 便 KeepAlive
defineOptions({
name: 'MysqlManager'
})
const store = useServiceStore() const store = useServiceStore()
@ -271,6 +284,13 @@ const configContent = ref('')
const savingConfig = ref(false) const savingConfig = ref(false)
const currentVersion = ref('') const currentVersion = ref('')
//
const showLogViewer = ref(false)
const showLogViewerDialog = () => {
showLogViewer.value = true
}
const loadVersions = async () => { const loadVersions = async () => {
try { try {
installedVersions.value = await window.electronAPI?.mysql.getVersions() || [] installedVersions.value = await window.electronAPI?.mysql.getVersions() || []

View File

@ -74,8 +74,12 @@
<el-icon><Refresh /></el-icon> <el-icon><Refresh /></el-icon>
重载配置 重载配置
</el-button> </el-button>
<el-button @click="showConfig"> <el-button @click="showLogViewerDialog('nginx')">
<el-icon><Document /></el-icon> <el-icon><Document /></el-icon>
查看日志
</el-button>
<el-button @click="showConfig">
<el-icon><Setting /></el-icon>
编辑配置 编辑配置
</el-button> </el-button>
</div> </div>
@ -180,14 +184,23 @@
</el-button> </el-button>
</template> </template>
</el-dialog> </el-dialog>
<!-- 日志查看器 -->
<LogViewer v-model="showLogViewer" :initial-tab="logViewerTab" />
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, reactive, onMounted, onUnmounted } from 'vue' import { ref, reactive, onMounted, onUnmounted, onActivated } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus' import { ElMessage, ElMessageBox } from 'element-plus'
import { InfoFilled } from '@element-plus/icons-vue' import { InfoFilled, Setting } from '@element-plus/icons-vue'
import { useServiceStore } from '@/stores/serviceStore' import { useServiceStore } from '@/stores/serviceStore'
import LogViewer from '@/components/LogViewer.vue'
// 便 KeepAlive
defineOptions({
name: 'NginxManager'
})
const store = useServiceStore() const store = useServiceStore()
@ -219,6 +232,15 @@ const showConfigDialog = ref(false)
const configContent = ref('') const configContent = ref('')
const savingConfig = ref(false) const savingConfig = ref(false)
//
const showLogViewer = ref(false)
const logViewerTab = ref<'nginx' | 'php' | 'mysql' | 'sites'>('nginx')
const showLogViewerDialog = (tab: 'nginx' | 'sites') => {
logViewerTab.value = tab
showLogViewer.value = true
}
const loadData = async () => { const loadData = async () => {
try { try {
const versions = await window.electronAPI?.nginx.getVersions() || [] const versions = await window.electronAPI?.nginx.getVersions() || []

View File

@ -140,6 +140,11 @@ import { ref, reactive, computed, onMounted, onUnmounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus' import { ElMessage, ElMessageBox } from 'element-plus'
import { Plus, Promotion, Box, InfoFilled, Loading } from '@element-plus/icons-vue' import { Plus, Promotion, Box, InfoFilled, Loading } from '@element-plus/icons-vue'
// 便 KeepAlive
defineOptions({
name: 'NodeManager'
})
interface NodeVersion { interface NodeVersion {
version: string version: string
path: string path: string

View File

@ -45,11 +45,47 @@
<div class="version-name"> <div class="version-name">
PHP {{ version.version }} PHP {{ version.version }}
<el-tag v-if="version.isActive" type="success" size="small" class="ml-2">当前使用</el-tag> <el-tag v-if="version.isActive" type="success" size="small" class="ml-2">当前使用</el-tag>
<el-tag
v-if="isCgiRunning(version.version)"
type="success"
size="small"
class="ml-2"
>
CGI:{{ getCgiPort(version.version) }}
</el-tag>
</div> </div>
<div class="version-path">{{ version.path }}</div> <div class="version-path">{{ version.path }}</div>
</div> </div>
</div> </div>
<div class="version-actions"> <div class="version-actions">
<!-- CGI 控制按钮 -->
<el-tooltip
:content="isCgiRunning(version.version)
? `停止 CGI (端口 ${getCgiPort(version.version)})`
: `启动 CGI (端口 ${getCgiPort(version.version)})`"
placement="top"
>
<el-button
v-if="isCgiRunning(version.version)"
type="danger"
size="small"
@click="stopCgi(version.version)"
:loading="cgiLoading[version.version]"
>
<el-icon><VideoPause /></el-icon>
CGI
</el-button>
<el-button
v-else
type="success"
size="small"
@click="startCgi(version.version)"
:loading="cgiLoading[version.version]"
>
<el-icon><VideoPlay /></el-icon>
CGI
</el-button>
</el-tooltip>
<el-button <el-button
v-if="!version.isActive" v-if="!version.isActive"
type="primary" type="primary"
@ -63,9 +99,13 @@
扩展 扩展
</el-button> </el-button>
<el-button size="small" @click="showConfig(version)"> <el-button size="small" @click="showConfig(version)">
<el-icon><Document /></el-icon> <el-icon><EditPen /></el-icon>
配置 配置
</el-button> </el-button>
<el-button size="small" @click="showLogViewerDialog">
<el-icon><Document /></el-icon>
日志
</el-button>
<el-button <el-button
type="danger" type="danger"
size="small" size="small"
@ -366,14 +406,23 @@
</el-button> </el-button>
</template> </template>
</el-dialog> </el-dialog>
<!-- 日志查看器 -->
<LogViewer v-model="showLogViewer" initial-tab="php" />
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, reactive, computed, onMounted, onUnmounted } from 'vue' import { ref, reactive, computed, onMounted, onUnmounted, onActivated } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus' import { ElMessage, ElMessageBox } from 'element-plus'
import { FolderOpened, InfoFilled } from '@element-plus/icons-vue' import { FolderOpened, InfoFilled, VideoPlay, VideoPause, EditPen } from '@element-plus/icons-vue'
import { useServiceStore } from '@/stores/serviceStore' import { useServiceStore } from '@/stores/serviceStore'
import LogViewer from '@/components/LogViewer.vue'
// 便 KeepAlive
defineOptions({
name: 'PhpManager'
})
const store = useServiceStore() const store = useServiceStore()
@ -405,6 +454,7 @@ interface AvailableExtension {
} }
const loading = ref(false) const loading = ref(false)
const initialLoaded = ref(false) //
const installedVersions = ref<PhpVersion[]>([]) const installedVersions = ref<PhpVersion[]>([])
const availableVersions = ref<AvailableVersion[]>([]) const availableVersions = ref<AvailableVersion[]>([])
const showInstallDialog = ref(false) const showInstallDialog = ref(false)
@ -416,6 +466,30 @@ const downloadProgress = reactive({
total: 0 total: 0
}) })
// CGI
interface CgiStatus {
version: string
port: number
running: boolean
}
const cgiStatus = ref<CgiStatus[]>([])
const cgiLoading = ref<Record<string, boolean>>({})
// CGI
const getCgiStatus = (version: string): CgiStatus | undefined => {
return cgiStatus.value.find(s => s.version === version)
}
// CGI
const isCgiRunning = (version: string): boolean => {
return getCgiStatus(version)?.running ?? false
}
// CGI
const getCgiPort = (version: string): number => {
return getCgiStatus(version)?.port ?? 9000
}
const showExtensionsDialog = ref(false) const showExtensionsDialog = ref(false)
const loadingExtensions = ref(false) const loadingExtensions = ref(false)
const extensions = ref<Extension[]>([]) const extensions = ref<Extension[]>([])
@ -465,6 +539,13 @@ const showConfigDialog = ref(false)
const configContent = ref('') const configContent = ref('')
const savingConfig = ref(false) const savingConfig = ref(false)
//
const showLogViewer = ref(false)
const showLogViewerDialog = () => {
showLogViewer.value = true
}
// Composer // Composer
const composerStatus = ref<{ const composerStatus = ref<{
installed: boolean installed: boolean
@ -489,6 +570,51 @@ const loadVersions = async () => {
} }
} }
// CGI
const loadCgiStatus = async () => {
try {
cgiStatus.value = await window.electronAPI?.service.getPhpCgiStatus() || []
} catch (error: any) {
console.error('加载 CGI 状态失败:', error)
}
}
// CGI
const startCgi = async (version: string) => {
cgiLoading.value[version] = true
try {
const result = await window.electronAPI?.service.startPhpCgiVersion(version)
if (result?.success) {
ElMessage.success(result.message)
await loadCgiStatus()
} else {
ElMessage.error(result?.message || '启动失败')
}
} catch (error: any) {
ElMessage.error('启动失败: ' + error.message)
} finally {
cgiLoading.value[version] = false
}
}
// CGI
const stopCgi = async (version: string) => {
cgiLoading.value[version] = true
try {
const result = await window.electronAPI?.service.stopPhpCgiVersion(version)
if (result?.success) {
ElMessage.success(result.message)
await loadCgiStatus()
} else {
ElMessage.error(result?.message || '停止失败')
}
} catch (error: any) {
ElMessage.error('停止失败: ' + error.message)
} finally {
cgiLoading.value[version] = false
}
}
// Composer // Composer
const loadComposerStatus = async () => { const loadComposerStatus = async () => {
try { try {
@ -812,10 +938,30 @@ const formatSize = (bytes: number) => {
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i] return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i]
} }
onMounted(() => { onMounted(async () => {
loadVersions() // 使 store
loadAvailableVersions() if (store.phpVersions.length > 0) {
installedVersions.value = store.phpVersions.map(v => ({
version: v.version,
path: v.path,
isActive: v.isActive
}))
}
//
loading.value = installedVersions.value.length === 0
await Promise.all([
loadVersions(),
loadCgiStatus(),
loadComposerStatus() loadComposerStatus()
])
loading.value = false
initialLoaded.value = true
//
loadAvailableVersions()
// //
window.electronAPI?.onDownloadProgress((data: any) => { window.electronAPI?.onDownloadProgress((data: any) => {
@ -831,6 +977,13 @@ onMounted(() => {
}) })
}) })
// CGI
onActivated(async () => {
if (initialLoaded.value) {
await loadCgiStatus()
}
})
onUnmounted(() => { onUnmounted(() => {
window.electronAPI?.removeDownloadProgressListener() window.electronAPI?.removeDownloadProgressListener()
}) })

View File

@ -182,6 +182,11 @@ import { ref, reactive, onMounted, onUnmounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus' import { ElMessage, ElMessageBox } from 'element-plus'
import { InfoFilled } from '@element-plus/icons-vue' import { InfoFilled } from '@element-plus/icons-vue'
// 便 KeepAlive
defineOptions({
name: 'PythonManager'
})
interface PythonVersion { interface PythonVersion {
version: string version: string
path: string path: string

View File

@ -75,9 +75,13 @@
重启 重启
</el-button> </el-button>
<el-button @click="showConfig"> <el-button @click="showConfig">
<el-icon><Document /></el-icon> <el-icon><Setting /></el-icon>
编辑配置 编辑配置
</el-button> </el-button>
<el-button @click="openLogDirectory">
<el-icon><Document /></el-icon>
日志
</el-button>
</div> </div>
</div> </div>
</div> </div>
@ -184,11 +188,16 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, reactive, onMounted, onUnmounted } from 'vue' import { ref, reactive, onMounted, onUnmounted, onActivated } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus' import { ElMessage, ElMessageBox } from 'element-plus'
import { InfoFilled } from '@element-plus/icons-vue' import { InfoFilled, Setting } from '@element-plus/icons-vue'
import { useServiceStore } from '@/stores/serviceStore' import { useServiceStore } from '@/stores/serviceStore'
// 便 KeepAlive
defineOptions({
name: 'RedisManager'
})
const store = useServiceStore() const store = useServiceStore()
interface RedisStatus { interface RedisStatus {
@ -383,6 +392,19 @@ const saveConfig = async () => {
} }
} }
// Redis
const openLogDirectory = async () => {
try {
const basePath = await window.electronAPI?.config.getBasePath()
if (basePath) {
// Redis
await window.electronAPI?.openPath(basePath + '\\redis')
}
} catch (error: any) {
ElMessage.error('打开目录失败: ' + error.message)
}
}
const formatSize = (bytes: number) => { const formatSize = (bytes: number) => {
if (bytes === 0) return '0 B' if (bytes === 0) return '0 B'
const k = 1024 const k = 1024

View File

@ -111,7 +111,13 @@
</div> </div>
<div class="app-details"> <div class="app-details">
<h2 class="app-title">PHPer 开发环境管理器</h2> <h2 class="app-title">PHPer 开发环境管理器</h2>
<p class="app-version">版本 1.0.0</p> <p class="app-version">
版本 {{ appVersion.version }}
<span v-if="appVersion.buildDate" class="build-date">
({{ appVersion.buildDate }})
</span>
<el-tag v-if="!appVersion.isPackaged" type="warning" size="small" style="margin-left: 8px">开发版</el-tag>
</p>
<p class="app-desc"> <p class="app-desc">
一站式 PHP 开发环境管理工具支持 PHPMySQLNginxRedisNode.js 的安装和管理 一站式 PHP 开发环境管理工具支持 PHPMySQLNginxRedisNode.js 的安装和管理
</p> </p>
@ -145,6 +151,11 @@ import { ref, reactive, onMounted } from 'vue'
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
import { Monitor } from '@element-plus/icons-vue' import { Monitor } from '@element-plus/icons-vue'
// 便 KeepAlive
defineOptions({
name: 'Settings'
})
interface ServiceAutoStart { interface ServiceAutoStart {
name: string name: string
displayName: string displayName: string
@ -169,6 +180,13 @@ const services = reactive<ServiceAutoStart[]>([
{ name: 'redis', displayName: 'Redis', description: '开机时自动启动 Redis 服务(独立于应用)', autoStart: false } { name: 'redis', displayName: 'Redis', description: '开机时自动启动 Redis 服务(独立于应用)', autoStart: false }
]) ])
const appVersion = reactive({
version: '1.0.0',
buildTime: '',
buildDate: '',
isPackaged: false
})
const loadSettings = async () => { const loadSettings = async () => {
try { try {
basePath.value = await window.electronAPI?.config.getBasePath() || '' basePath.value = await window.electronAPI?.config.getBasePath() || ''
@ -177,6 +195,15 @@ const loadSettings = async () => {
appSettings.autoLaunch = await window.electronAPI?.app?.getAutoLaunch() || false appSettings.autoLaunch = await window.electronAPI?.app?.getAutoLaunch() || false
appSettings.startMinimized = await window.electronAPI?.app?.getStartMinimized() || false appSettings.startMinimized = await window.electronAPI?.app?.getStartMinimized() || false
//
const versionInfo = await window.electronAPI?.app?.getVersion()
if (versionInfo) {
appVersion.version = versionInfo.version
appVersion.buildTime = versionInfo.buildTime
appVersion.buildDate = versionInfo.buildDate
appVersion.isPackaged = versionInfo.isPackaged
}
// //
for (const service of services) { for (const service of services) {
service.autoStart = await window.electronAPI?.service.getAutoStart(service.name) || false service.autoStart = await window.electronAPI?.service.getAutoStart(service.name) || false
@ -315,6 +342,15 @@ onMounted(() => {
font-size: 14px; font-size: 14px;
color: var(--text-muted); color: var(--text-muted);
margin-bottom: 8px; margin-bottom: 8px;
display: flex;
align-items: center;
gap: 4px;
.build-date {
font-size: 12px;
color: var(--text-muted);
opacity: 0.8;
}
} }
.app-desc { .app-desc {

View File

@ -16,6 +16,10 @@
站点列表 站点列表
</span> </span>
<div class="header-actions"> <div class="header-actions">
<el-button @click="showLogViewer = true">
<el-icon><Document /></el-icon>
站点日志
</el-button>
<el-button type="success" @click="showCreateLaravelDialog = true"> <el-button type="success" @click="showCreateLaravelDialog = true">
<el-icon><Promotion /></el-icon> <el-icon><Promotion /></el-icon>
创建 Laravel 项目 创建 Laravel 项目
@ -328,14 +332,23 @@
</el-button> </el-button>
</template> </template>
</el-dialog> </el-dialog>
<!-- 日志查看器 -->
<LogViewer v-model="showLogViewer" initial-tab="sites" />
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, reactive, onMounted } from 'vue' import { ref, reactive, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus' import { ElMessage, ElMessageBox } from 'element-plus'
import { FolderOpened } from '@element-plus/icons-vue' import { FolderOpened, Document } from '@element-plus/icons-vue'
import { useServiceStore } from '@/stores/serviceStore' import { useServiceStore } from '@/stores/serviceStore'
import LogViewer from '@/components/LogViewer.vue'
// 便 KeepAlive
defineOptions({
name: 'SitesManager'
})
const store = useServiceStore() const store = useServiceStore()
@ -407,6 +420,8 @@ const editForm = reactive<SiteConfig>({
// Laravel // Laravel
const showCreateLaravelDialog = ref(false) const showCreateLaravelDialog = ref(false)
const creatingLaravel = ref(false) const creatingLaravel = ref(false)
const showLogViewer = ref(false)
const laravelForm = reactive({ const laravelForm = reactive({
projectName: '', projectName: '',
targetDir: '', targetDir: '',