Compare commits
2 Commits
1cea2b340f
...
d8faf27108
| Author | SHA1 | Date | |
|---|---|---|---|
| d8faf27108 | |||
| dac4cc805e |
52
README.md
52
README.md
@ -63,13 +63,16 @@
|
||||
### 🐘 PHP 版本管理
|
||||
|
||||
| 功能 | 说明 |
|
||||
| ---------- | ---------------------------------------------------------- |
|
||||
| ------------ | ---------------------------------------------------------- |
|
||||
| 多版本管理 | 支持同时安装 PHP 8.1、8.2、8.3、8.4、8.5 等多个版本 |
|
||||
| CGI 独立控制 | 每个 PHP 版本可独立启动/停止 CGI 进程,支持多版本并行运行 |
|
||||
| 端口自动分配 | 各版本自动分配端口(如 8.4→9084, 8.3→9083) |
|
||||
| 一键切换 | 点击即可切换 PHP 版本,自动配置系统环境变量 |
|
||||
| 扩展管理 | 可视化管理 PHP 扩展,支持在线安装(从 PECL) |
|
||||
| 配置编辑 | 在线编辑 php.ini,无需手动查找配置文件 |
|
||||
| 自动配置 | 安装时自动启用常用扩展(curl、gd、mbstring、pdo_mysql 等) |
|
||||
| Composer | 集成 Composer 管理,支持镜像源切换(阿里云、腾讯云等) |
|
||||
| 日志查看 | 直接查看 PHP 错误日志 |
|
||||
| 下载源 | 从 [windows.php.net](https://windows.php.net) 官方下载 |
|
||||
|
||||
### 🐬 MySQL 管理
|
||||
@ -139,15 +142,31 @@
|
||||
- 🎯 **Laravel 一键配置** - 自动配置 public 目录和伪静态规则
|
||||
- 🔒 **SSL 证书申请** - 集成 Let's Encrypt 自动申请
|
||||
- 📝 **Hosts 自动配置** - 自动添加域名到系统 hosts 文件
|
||||
- 📋 **站点日志查看** - 查看每个站点的访问日志和错误日志
|
||||
- 🌐 **一键打开站点** - 点击域名在默认浏览器打开
|
||||
|
||||
### 📋 日志查看
|
||||
|
||||
| 功能 | 说明 |
|
||||
| ------------ | ---------------------------------------------- |
|
||||
| 多服务日志 | 支持查看 Nginx、PHP、MySQL、Redis 日志 |
|
||||
| 站点日志 | 查看各站点的访问日志和错误日志 |
|
||||
| 实时刷新 | 支持刷新日志内容,查看最新记录 |
|
||||
| 行数控制 | 可配置显示的日志行数(100-5000 行) |
|
||||
| 快速清空 | 一键清空指定日志文件 |
|
||||
| 打开目录 | 快速在文件管理器中打开日志目录 |
|
||||
|
||||
### ⚙️ 其他功能
|
||||
|
||||
- 🚀 **开机自启动** - 可配置各服务开机自动启动
|
||||
- 🚀 **开机自启动** - 可配置各服务开机自动启动(静默模式,无弹窗)
|
||||
- 🔇 **静默启动** - 所有服务启动无黑色窗口闪烁
|
||||
- 📋 **Hosts 管理** - 可视化管理系统 hosts 文件
|
||||
- 🌙 **深色/浅色主题** - 支持主题切换
|
||||
- 📊 **服务状态监控** - 实时显示各服务运行状态
|
||||
- ⏳ **加载状态提示** - 版本列表加载时显示 Loading 状态
|
||||
- ⚡ **页面切换优化** - 使用 KeepAlive 缓存页面,切换无闪烁
|
||||
- 🔢 **自动版本号** - 打包时自动更新版本号
|
||||
- 📥 **下载源说明** - 清晰显示各软件的下载来源
|
||||
- 🌐 **默认浏览器打开** - 站点链接自动在默认浏览器打开
|
||||
|
||||
## 🛠️ 技术栈
|
||||
|
||||
@ -186,8 +205,16 @@ npm run electron:dev
|
||||
### 构建生产版本
|
||||
|
||||
```bash
|
||||
# 构建 Windows 安装包
|
||||
npm run electron:build
|
||||
# 构建 Windows 安装包(自动更新 patch 版本号 +0.0.1)
|
||||
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` 目录中。
|
||||
@ -209,14 +236,19 @@ phper/
|
||||
│ ├── PythonManager.ts # Python 版本管理器
|
||||
│ ├── GitManager.ts # Git 管理器
|
||||
│ ├── ServiceManager.ts # 开机自启服务管理器
|
||||
│ └── HostsManager.ts # Hosts 文件管理器
|
||||
│ ├── HostsManager.ts # Hosts 文件管理器
|
||||
│ └── LogManager.ts # 日志管理器
|
||||
│
|
||||
├── src/ # Vue 前端源码
|
||||
│ ├── App.vue # 根组件
|
||||
│ ├── App.vue # 根组件(含 KeepAlive 缓存)
|
||||
│ ├── main.ts # 入口文件
|
||||
│ ├── vite-env.d.ts # 类型声明
|
||||
│ ├── router/ # 路由配置
|
||||
│ │ └── index.ts
|
||||
│ ├── stores/ # Pinia 状态管理
|
||||
│ │ └── serviceStore.ts # 服务状态存储
|
||||
│ ├── components/ # 公共组件
|
||||
│ │ └── LogViewer.vue # 日志查看器组件
|
||||
│ ├── styles/ # 样式文件
|
||||
│ │ └── main.scss # 全局样式(含主题变量)
|
||||
│ └── views/ # 页面视图
|
||||
@ -232,8 +264,12 @@ phper/
|
||||
│ ├── HostsManager.vue # Hosts 管理
|
||||
│ └── Settings.vue # 设置
|
||||
│
|
||||
├── scripts/ # 构建脚本
|
||||
│ └── bump-version.js # 版本号自动更新脚本
|
||||
│
|
||||
├── public/ # 静态资源
|
||||
│ └── icon.svg # 应用图标
|
||||
│ ├── icon.svg # 应用图标
|
||||
│ └── version.json # 版本信息(构建时生成)
|
||||
│
|
||||
├── index.html # HTML 模板
|
||||
├── package.json # 项目配置
|
||||
|
||||
@ -17,6 +17,7 @@ import { ServiceManager } from "./services/ServiceManager";
|
||||
import { HostsManager } from "./services/HostsManager";
|
||||
import { GitManager } from "./services/GitManager";
|
||||
import { PythonManager } from "./services/PythonManager";
|
||||
import { LogManager } from "./services/LogManager";
|
||||
import { ConfigStore } from "./services/ConfigStore";
|
||||
|
||||
// 获取图标路径
|
||||
@ -120,6 +121,7 @@ const serviceManager = new ServiceManager(configStore);
|
||||
const hostsManager = new HostsManager();
|
||||
const gitManager = new GitManager(configStore);
|
||||
const pythonManager = new PythonManager(configStore);
|
||||
const logManager = new LogManager(configStore);
|
||||
|
||||
function createWindow() {
|
||||
const appIcon = createWindowIcon();
|
||||
@ -575,9 +577,11 @@ ipcMain.handle("config:setBasePath", (_, path: string) =>
|
||||
);
|
||||
|
||||
// ==================== 应用设置 ====================
|
||||
// 设置开机自启(以管理员模式,使用任务计划程序)
|
||||
// 设置开机自启(以管理员模式,使用任务计划程序,静默启动)
|
||||
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 taskName = "PHPerDevManager";
|
||||
|
||||
@ -601,12 +605,18 @@ ipcMain.handle("app:setAutoLaunch", async (_, enabled: boolean) => {
|
||||
// 忽略删除失败(可能任务不存在)
|
||||
}
|
||||
|
||||
// 创建任务计划程序任务,以最高权限运行
|
||||
const command = `schtasks /create /tn "${taskName}" /tr "\\"${exePath}\\"" /sc onlogon /rl highest /f`;
|
||||
// 创建 VBS 启动脚本(确保静默启动)
|
||||
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 });
|
||||
|
||||
configStore.set("autoLaunch", true);
|
||||
return { success: true, message: "已启用开机自启(管理员模式)" };
|
||||
return { success: true, message: "已启用开机自启(静默模式)" };
|
||||
} else {
|
||||
// 删除任务计划程序任务
|
||||
try {
|
||||
@ -617,6 +627,18 @@ ipcMain.handle("app:setAutoLaunch", async (_, enabled: boolean) => {
|
||||
} 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);
|
||||
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) => {
|
||||
configStore.set("startMinimized", enabled);
|
||||
@ -666,3 +720,13 @@ ipcMain.handle("app:quit", () => {
|
||||
isQuitting = true;
|
||||
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)
|
||||
);
|
||||
|
||||
@ -166,12 +166,22 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
||||
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: {
|
||||
setAutoLaunch: (enabled: boolean) => ipcRenderer.invoke('app:setAutoLaunch', enabled),
|
||||
getAutoLaunch: () => ipcRenderer.invoke('app:getAutoLaunch'),
|
||||
setStartMinimized: (enabled: boolean) => ipcRenderer.invoke('app:setStartMinimized', enabled),
|
||||
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),
|
||||
getAutoStartServices: () => ipcRenderer.invoke('app:getAutoStartServices'),
|
||||
quit: () => ipcRenderer.invoke('app:quit')
|
||||
|
||||
277
electron/services/LogManager.ts
Normal file
277
electron/services/LogManager.ts
Normal 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -331,9 +331,12 @@ export class NginxManager {
|
||||
return { success: true, message: 'Nginx 已经在运行' }
|
||||
}
|
||||
|
||||
// 启动 Nginx
|
||||
const child = spawn(nginxExe, [], {
|
||||
cwd: nginxPath,
|
||||
// 使用 VBScript 静默启动 Nginx
|
||||
const vbsPath = join(nginxPath, 'start_nginx.vbs')
|
||||
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,
|
||||
stdio: 'ignore',
|
||||
windowsHide: true
|
||||
|
||||
@ -307,15 +307,16 @@ export class RedisManager {
|
||||
await this.createDefaultConfig()
|
||||
}
|
||||
|
||||
// 使用相对路径启动(避免 Cygwin 路径问题)
|
||||
// Redis Windows 版本使用 Cygwin,需要在正确的工作目录下用相对路径
|
||||
// 使用 VBScript 静默启动 Redis(避免黑窗口闪烁)
|
||||
const configFileName = 'redis.windows.conf'
|
||||
const child = spawn(redisServer, [configFileName], {
|
||||
cwd: redisPath,
|
||||
const vbsPath = join(redisPath, 'start_redis.vbs')
|
||||
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,
|
||||
stdio: 'ignore',
|
||||
windowsHide: true,
|
||||
shell: false
|
||||
windowsHide: true
|
||||
})
|
||||
child.unref()
|
||||
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import { ConfigStore } from './ConfigStore'
|
||||
import { exec, spawn } from 'child_process'
|
||||
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'
|
||||
|
||||
const execAsync = promisify(exec)
|
||||
@ -562,6 +562,32 @@ export class ServiceManager {
|
||||
}
|
||||
|
||||
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, {
|
||||
cwd,
|
||||
detached: true,
|
||||
@ -571,4 +597,5 @@ export class ServiceManager {
|
||||
child.unref()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -5,10 +5,14 @@
|
||||
"main": "dist-electron/main.js",
|
||||
"scripts": {
|
||||
"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",
|
||||
"electron:dev": "vite",
|
||||
"electron:build": "vite build && electron-builder"
|
||||
"electron:build": "node scripts/bump-version.js && vite build && electron-builder"
|
||||
},
|
||||
"author": "PHPer",
|
||||
"license": "MIT",
|
||||
|
||||
58
scripts/bump-version.js
Normal file
58
scripts/bump-version.js
Normal 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`)
|
||||
|
||||
32
src/App.vue
32
src/App.vue
@ -62,10 +62,10 @@
|
||||
|
||||
<!-- 内容区 -->
|
||||
<main class="content">
|
||||
<router-view v-slot="{ Component }">
|
||||
<transition name="fade" mode="out-in">
|
||||
<component :is="Component" />
|
||||
</transition>
|
||||
<router-view v-slot="{ Component, route }">
|
||||
<keep-alive :include="cachedViews">
|
||||
<component :is="Component" :key="route.path" />
|
||||
</keep-alive>
|
||||
</router-view>
|
||||
</main>
|
||||
</div>
|
||||
@ -83,6 +83,21 @@ const isDark = ref(true)
|
||||
const startingAll = ref(false)
|
||||
const stoppingAll = ref(false)
|
||||
|
||||
// 缓存的视图列表 - 避免页面切换闪烁
|
||||
const cachedViews = [
|
||||
'Dashboard',
|
||||
'PhpManager',
|
||||
'MysqlManager',
|
||||
'NginxManager',
|
||||
'RedisManager',
|
||||
'NodeManager',
|
||||
'PythonManager',
|
||||
'GitManager',
|
||||
'SitesManager',
|
||||
'HostsManager',
|
||||
'Settings'
|
||||
]
|
||||
|
||||
// 从 store 获取服务状态
|
||||
const serviceStatus = computed(() => ({
|
||||
nginx: store.serviceStatus.nginx,
|
||||
@ -352,14 +367,5 @@ onUnmounted(() => {
|
||||
background: var(--bg-content);
|
||||
}
|
||||
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition: opacity 0.15s ease;
|
||||
}
|
||||
|
||||
.fade-enter-from,
|
||||
.fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
|
||||
|
||||
512
src/components/LogViewer.vue
Normal file
512
src/components/LogViewer.vue
Normal 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>
|
||||
|
||||
@ -248,10 +248,9 @@
|
||||
class="mini-site-card"
|
||||
>
|
||||
<a
|
||||
:href="(site.ssl ? 'https://' : 'http://') + site.domain"
|
||||
target="_blank"
|
||||
href="#"
|
||||
class="site-domain-link"
|
||||
@click.stop
|
||||
@click.prevent="openSite(site)"
|
||||
>
|
||||
{{ site.domain }}
|
||||
<el-icon class="link-icon"><Link /></el-icon>
|
||||
@ -277,11 +276,17 @@
|
||||
<el-icon><InfoFilled /></el-icon>
|
||||
安装路径
|
||||
</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-icon><FolderOpened /></el-icon>
|
||||
打开目录
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-content">
|
||||
<div class="path-display">
|
||||
<span class="path-label">基础路径:</span>
|
||||
@ -289,14 +294,23 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 日志查看器 -->
|
||||
<LogViewer v-model="showLogViewer" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, watch } from 'vue'
|
||||
import { ref, computed, onMounted, onActivated } from 'vue'
|
||||
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 LogViewer from '@/components/LogViewer.vue'
|
||||
|
||||
// 定义组件名称以便 KeepAlive 正确缓存
|
||||
defineOptions({
|
||||
name: 'Dashboard'
|
||||
})
|
||||
|
||||
const store = useServiceStore()
|
||||
|
||||
@ -352,6 +366,7 @@ const basePath = computed(() => store.basePath)
|
||||
|
||||
const settingPhp = ref('')
|
||||
const settingNode = ref('')
|
||||
const showLogViewer = ref(false)
|
||||
|
||||
const startService = async (service: Service) => {
|
||||
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) => {
|
||||
settingPhp.value = version
|
||||
try {
|
||||
@ -570,8 +591,15 @@ const setActiveNode = async (version: string) => {
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
// 总是刷新数据以确保数据最新
|
||||
// 首次加载:如果没有数据则全量刷新
|
||||
if (store.phpVersions.length === 0 && store.nodeVersions.length === 0) {
|
||||
await store.refreshAll()
|
||||
}
|
||||
})
|
||||
|
||||
// 从缓存激活时静默刷新状态(不会闪烁因为已有数据)
|
||||
onActivated(async () => {
|
||||
await store.refreshServiceStatus()
|
||||
})
|
||||
</script>
|
||||
|
||||
|
||||
@ -177,6 +177,11 @@
|
||||
import { ref, reactive, onMounted, onUnmounted } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
|
||||
// 定义组件名称以便 KeepAlive 正确缓存
|
||||
defineOptions({
|
||||
name: 'GitManager'
|
||||
})
|
||||
|
||||
interface GitVersion {
|
||||
version: string
|
||||
path: string
|
||||
|
||||
@ -89,6 +89,11 @@
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
|
||||
// 定义组件名称以便 KeepAlive 正确缓存
|
||||
defineOptions({
|
||||
name: 'HostsManager'
|
||||
})
|
||||
|
||||
interface HostEntry {
|
||||
ip: string
|
||||
domain: string
|
||||
|
||||
@ -87,9 +87,13 @@
|
||||
密码
|
||||
</el-button>
|
||||
<el-button size="small" @click="showConfig(version)">
|
||||
<el-icon><Document /></el-icon>
|
||||
<el-icon><Setting /></el-icon>
|
||||
配置
|
||||
</el-button>
|
||||
<el-button size="small" @click="showLogViewerDialog">
|
||||
<el-icon><Document /></el-icon>
|
||||
日志
|
||||
</el-button>
|
||||
<el-button
|
||||
type="danger"
|
||||
size="small"
|
||||
@ -222,14 +226,23 @@
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 日志查看器 -->
|
||||
<LogViewer v-model="showLogViewer" initial-tab="mysql" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<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 { InfoFilled } from '@element-plus/icons-vue'
|
||||
import { InfoFilled, Setting } from '@element-plus/icons-vue'
|
||||
import { useServiceStore } from '@/stores/serviceStore'
|
||||
import LogViewer from '@/components/LogViewer.vue'
|
||||
|
||||
// 定义组件名称以便 KeepAlive 正确缓存
|
||||
defineOptions({
|
||||
name: 'MysqlManager'
|
||||
})
|
||||
|
||||
const store = useServiceStore()
|
||||
|
||||
@ -271,6 +284,13 @@ const configContent = ref('')
|
||||
const savingConfig = ref(false)
|
||||
const currentVersion = ref('')
|
||||
|
||||
// 日志查看器
|
||||
const showLogViewer = ref(false)
|
||||
|
||||
const showLogViewerDialog = () => {
|
||||
showLogViewer.value = true
|
||||
}
|
||||
|
||||
const loadVersions = async () => {
|
||||
try {
|
||||
installedVersions.value = await window.electronAPI?.mysql.getVersions() || []
|
||||
|
||||
@ -74,8 +74,12 @@
|
||||
<el-icon><Refresh /></el-icon>
|
||||
重载配置
|
||||
</el-button>
|
||||
<el-button @click="showConfig">
|
||||
<el-button @click="showLogViewerDialog('nginx')">
|
||||
<el-icon><Document /></el-icon>
|
||||
查看日志
|
||||
</el-button>
|
||||
<el-button @click="showConfig">
|
||||
<el-icon><Setting /></el-icon>
|
||||
编辑配置
|
||||
</el-button>
|
||||
</div>
|
||||
@ -180,14 +184,23 @@
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 日志查看器 -->
|
||||
<LogViewer v-model="showLogViewer" :initial-tab="logViewerTab" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<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 { InfoFilled } from '@element-plus/icons-vue'
|
||||
import { InfoFilled, Setting } from '@element-plus/icons-vue'
|
||||
import { useServiceStore } from '@/stores/serviceStore'
|
||||
import LogViewer from '@/components/LogViewer.vue'
|
||||
|
||||
// 定义组件名称以便 KeepAlive 正确缓存
|
||||
defineOptions({
|
||||
name: 'NginxManager'
|
||||
})
|
||||
|
||||
const store = useServiceStore()
|
||||
|
||||
@ -219,6 +232,15 @@ const showConfigDialog = ref(false)
|
||||
const configContent = ref('')
|
||||
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 () => {
|
||||
try {
|
||||
const versions = await window.electronAPI?.nginx.getVersions() || []
|
||||
|
||||
@ -140,6 +140,11 @@ import { ref, reactive, computed, onMounted, onUnmounted } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { Plus, Promotion, Box, InfoFilled, Loading } from '@element-plus/icons-vue'
|
||||
|
||||
// 定义组件名称以便 KeepAlive 正确缓存
|
||||
defineOptions({
|
||||
name: 'NodeManager'
|
||||
})
|
||||
|
||||
interface NodeVersion {
|
||||
version: string
|
||||
path: string
|
||||
|
||||
@ -45,11 +45,47 @@
|
||||
<div class="version-name">
|
||||
PHP {{ version.version }}
|
||||
<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 class="version-path">{{ version.path }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<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
|
||||
v-if="!version.isActive"
|
||||
type="primary"
|
||||
@ -63,9 +99,13 @@
|
||||
扩展
|
||||
</el-button>
|
||||
<el-button size="small" @click="showConfig(version)">
|
||||
<el-icon><Document /></el-icon>
|
||||
<el-icon><EditPen /></el-icon>
|
||||
配置
|
||||
</el-button>
|
||||
<el-button size="small" @click="showLogViewerDialog">
|
||||
<el-icon><Document /></el-icon>
|
||||
日志
|
||||
</el-button>
|
||||
<el-button
|
||||
type="danger"
|
||||
size="small"
|
||||
@ -366,14 +406,23 @@
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 日志查看器 -->
|
||||
<LogViewer v-model="showLogViewer" initial-tab="php" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<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 { FolderOpened, InfoFilled } from '@element-plus/icons-vue'
|
||||
import { FolderOpened, InfoFilled, VideoPlay, VideoPause, EditPen } from '@element-plus/icons-vue'
|
||||
import { useServiceStore } from '@/stores/serviceStore'
|
||||
import LogViewer from '@/components/LogViewer.vue'
|
||||
|
||||
// 定义组件名称以便 KeepAlive 正确缓存
|
||||
defineOptions({
|
||||
name: 'PhpManager'
|
||||
})
|
||||
|
||||
const store = useServiceStore()
|
||||
|
||||
@ -405,6 +454,7 @@ interface AvailableExtension {
|
||||
}
|
||||
|
||||
const loading = ref(false)
|
||||
const initialLoaded = ref(false) // 标记是否已完成首次加载
|
||||
const installedVersions = ref<PhpVersion[]>([])
|
||||
const availableVersions = ref<AvailableVersion[]>([])
|
||||
const showInstallDialog = ref(false)
|
||||
@ -416,6 +466,30 @@ const downloadProgress = reactive({
|
||||
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 loadingExtensions = ref(false)
|
||||
const extensions = ref<Extension[]>([])
|
||||
@ -465,6 +539,13 @@ const showConfigDialog = ref(false)
|
||||
const configContent = ref('')
|
||||
const savingConfig = ref(false)
|
||||
|
||||
// 日志查看器
|
||||
const showLogViewer = ref(false)
|
||||
|
||||
const showLogViewerDialog = () => {
|
||||
showLogViewer.value = true
|
||||
}
|
||||
|
||||
// Composer 相关
|
||||
const composerStatus = ref<{
|
||||
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 相关方法
|
||||
const loadComposerStatus = async () => {
|
||||
try {
|
||||
@ -812,10 +938,30 @@ const formatSize = (bytes: number) => {
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i]
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadVersions()
|
||||
loadAvailableVersions()
|
||||
onMounted(async () => {
|
||||
// 使用 store 中的缓存数据进行首次渲染(避免闪烁)
|
||||
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()
|
||||
])
|
||||
|
||||
loading.value = false
|
||||
initialLoaded.value = true
|
||||
|
||||
// 异步加载可用版本列表(不阻塞首屏)
|
||||
loadAvailableVersions()
|
||||
|
||||
// 监听下载进度
|
||||
window.electronAPI?.onDownloadProgress((data: any) => {
|
||||
@ -831,6 +977,13 @@ onMounted(() => {
|
||||
})
|
||||
})
|
||||
|
||||
// 从缓存激活时静默刷新 CGI 状态
|
||||
onActivated(async () => {
|
||||
if (initialLoaded.value) {
|
||||
await loadCgiStatus()
|
||||
}
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
window.electronAPI?.removeDownloadProgressListener()
|
||||
})
|
||||
|
||||
@ -182,6 +182,11 @@ import { ref, reactive, onMounted, onUnmounted } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { InfoFilled } from '@element-plus/icons-vue'
|
||||
|
||||
// 定义组件名称以便 KeepAlive 正确缓存
|
||||
defineOptions({
|
||||
name: 'PythonManager'
|
||||
})
|
||||
|
||||
interface PythonVersion {
|
||||
version: string
|
||||
path: string
|
||||
|
||||
@ -75,9 +75,13 @@
|
||||
重启
|
||||
</el-button>
|
||||
<el-button @click="showConfig">
|
||||
<el-icon><Document /></el-icon>
|
||||
<el-icon><Setting /></el-icon>
|
||||
编辑配置
|
||||
</el-button>
|
||||
<el-button @click="openLogDirectory">
|
||||
<el-icon><Document /></el-icon>
|
||||
日志
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -184,11 +188,16 @@
|
||||
</template>
|
||||
|
||||
<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 { InfoFilled } from '@element-plus/icons-vue'
|
||||
import { InfoFilled, Setting } from '@element-plus/icons-vue'
|
||||
import { useServiceStore } from '@/stores/serviceStore'
|
||||
|
||||
// 定义组件名称以便 KeepAlive 正确缓存
|
||||
defineOptions({
|
||||
name: 'RedisManager'
|
||||
})
|
||||
|
||||
const store = useServiceStore()
|
||||
|
||||
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) => {
|
||||
if (bytes === 0) return '0 B'
|
||||
const k = 1024
|
||||
|
||||
@ -111,7 +111,13 @@
|
||||
</div>
|
||||
<div class="app-details">
|
||||
<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">
|
||||
一站式 PHP 开发环境管理工具,支持 PHP、MySQL、Nginx、Redis、Node.js 的安装和管理。
|
||||
</p>
|
||||
@ -145,6 +151,11 @@ import { ref, reactive, onMounted } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { Monitor } from '@element-plus/icons-vue'
|
||||
|
||||
// 定义组件名称以便 KeepAlive 正确缓存
|
||||
defineOptions({
|
||||
name: 'Settings'
|
||||
})
|
||||
|
||||
interface ServiceAutoStart {
|
||||
name: string
|
||||
displayName: string
|
||||
@ -169,6 +180,13 @@ const services = reactive<ServiceAutoStart[]>([
|
||||
{ name: 'redis', displayName: 'Redis', description: '开机时自动启动 Redis 服务(独立于应用)', autoStart: false }
|
||||
])
|
||||
|
||||
const appVersion = reactive({
|
||||
version: '1.0.0',
|
||||
buildTime: '',
|
||||
buildDate: '',
|
||||
isPackaged: false
|
||||
})
|
||||
|
||||
const loadSettings = async () => {
|
||||
try {
|
||||
basePath.value = await window.electronAPI?.config.getBasePath() || ''
|
||||
@ -177,6 +195,15 @@ const loadSettings = async () => {
|
||||
appSettings.autoLaunch = await window.electronAPI?.app?.getAutoLaunch() || 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) {
|
||||
service.autoStart = await window.electronAPI?.service.getAutoStart(service.name) || false
|
||||
@ -315,6 +342,15 @@ onMounted(() => {
|
||||
font-size: 14px;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
|
||||
.build-date {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
opacity: 0.8;
|
||||
}
|
||||
}
|
||||
|
||||
.app-desc {
|
||||
|
||||
@ -16,6 +16,10 @@
|
||||
站点列表
|
||||
</span>
|
||||
<div class="header-actions">
|
||||
<el-button @click="showLogViewer = true">
|
||||
<el-icon><Document /></el-icon>
|
||||
站点日志
|
||||
</el-button>
|
||||
<el-button type="success" @click="showCreateLaravelDialog = true">
|
||||
<el-icon><Promotion /></el-icon>
|
||||
创建 Laravel 项目
|
||||
@ -328,14 +332,23 @@
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 日志查看器 -->
|
||||
<LogViewer v-model="showLogViewer" initial-tab="sites" />
|
||||
</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'
|
||||
import { FolderOpened, Document } from '@element-plus/icons-vue'
|
||||
import { useServiceStore } from '@/stores/serviceStore'
|
||||
import LogViewer from '@/components/LogViewer.vue'
|
||||
|
||||
// 定义组件名称以便 KeepAlive 正确缓存
|
||||
defineOptions({
|
||||
name: 'SitesManager'
|
||||
})
|
||||
|
||||
const store = useServiceStore()
|
||||
|
||||
@ -407,6 +420,8 @@ const editForm = reactive<SiteConfig>({
|
||||
// 创建 Laravel 项目
|
||||
const showCreateLaravelDialog = ref(false)
|
||||
const creatingLaravel = ref(false)
|
||||
const showLogViewer = ref(false)
|
||||
|
||||
const laravelForm = reactive({
|
||||
projectName: '',
|
||||
targetDir: '',
|
||||
|
||||
Loading…
Reference in New Issue
Block a user