Private
Public Access
1
0

2 Commits

12 changed files with 2636 additions and 47 deletions

80
app.go
View File

@@ -9,6 +9,7 @@ import (
"go-desk/internal/storage"
"go-desk/internal/system"
"os"
"strings"
"github.com/wailsapp/wails/v2/pkg/runtime"
)
@@ -20,6 +21,7 @@ type App struct {
connectionAPI *api.ConnectionAPI
sqlAPI *api.SqlAPI
tabAPI *api.TabAPI
updateAPI *api.UpdateAPI
}
// NewApp 创建新的应用实例
@@ -27,8 +29,8 @@ func NewApp() *App {
return &App{}
}
// startup 应用启动时调用
func (a *App) startup(ctx context.Context) {
// Startup 应用启动时调用
func (a *App) Startup(ctx context.Context) {
a.ctx = ctx
// 初始化 SQLite 本地存储(核心依赖,必须成功)
@@ -52,17 +54,15 @@ func (a *App) startup(ctx context.Context) {
if err := a.initAPIs(); err != nil {
panic(fmt.Sprintf("API 初始化失败,应用无法启动: %v", err))
}
// 设置 updateAPI 的上下文
if a.updateAPI != nil {
a.updateAPI.SetContext(ctx)
}
}
// QueryUsers 查询用户列表
func (a *App) QueryUsers(keyword string, status int, role int, organid int, page int, pageSize int, sortField string, sortOrder string) (map[string]interface{}, error) {
if a.db == nil {
return map[string]interface{}{
"rows": []interface{}{},
"total": 0,
}, nil
}
return a.db.QueryUsers(keyword, status, role, organid, page, pageSize, sortField, sortOrder)
}
@@ -125,24 +125,13 @@ func (a *App) GetFileInfo(path string) (map[string]interface{}, error) {
func (a *App) GetEnvVars() (map[string]string, error) {
envVars := make(map[string]string)
for _, env := range os.Environ() {
parts := splitEnv(env)
if len(parts) == 2 {
envVars[parts[0]] = parts[1]
if key, value, found := strings.Cut(env, "="); found {
envVars[key] = value
}
}
return envVars, nil
}
// splitEnv 分割环境变量字符串key=value
func splitEnv(env string) []string {
for i := 0; i < len(env); i++ {
if env[i] == '=' {
return []string{env[:i], env[i+1:]}
}
}
return []string{env}
}
// ========== 数据库连接管理接口 ==========
// initAPIs 初始化所有API在startup中调用
@@ -157,6 +146,10 @@ func (a *App) initAPIs() error {
return err
}
a.tabAPI, err = api.NewTabAPI()
if err != nil {
return err
}
a.updateAPI, err = api.NewUpdateAPI("https://img.1216.top/go-desk/last-version.json")
return err
}
@@ -267,3 +260,46 @@ func (a *App) SaveSqlTabs(tabs []map[string]interface{}) error {
func (a *App) ListSqlTabs() ([]map[string]interface{}, error) {
return a.tabAPI.ListSqlTabs()
}
// ========== 版本更新管理接口 ==========
// CheckUpdate 检查更新
func (a *App) CheckUpdate() (map[string]interface{}, error) {
return a.updateAPI.CheckUpdate()
}
// GetCurrentVersion 获取当前版本号
func (a *App) GetCurrentVersion() (map[string]interface{}, error) {
return a.updateAPI.GetCurrentVersion()
}
// GetUpdateConfig 获取更新配置
func (a *App) GetUpdateConfig() (map[string]interface{}, error) {
return a.updateAPI.GetUpdateConfig()
}
// SetUpdateConfig 设置更新配置
func (a *App) SetUpdateConfig(autoCheckEnabled bool, checkIntervalMinutes int, checkURL string) (map[string]interface{}, error) {
return a.updateAPI.SetUpdateConfig(autoCheckEnabled, checkIntervalMinutes, checkURL)
}
// DownloadUpdate 下载更新包
func (a *App) DownloadUpdate(downloadURL string) (map[string]interface{}, error) {
return a.updateAPI.DownloadUpdate(downloadURL)
}
// InstallUpdate 安装更新包
func (a *App) InstallUpdate(installerPath string, autoRestart bool) (map[string]interface{}, error) {
return a.updateAPI.InstallUpdate(installerPath, autoRestart)
}
// InstallUpdateWithHash 安装更新包(带哈希验证)
func (a *App) InstallUpdateWithHash(installerPath string, autoRestart bool, expectedHash string, hashType string) (map[string]interface{}, error) {
return a.updateAPI.InstallUpdateWithHash(installerPath, autoRestart, expectedHash, hashType)
}
// VerifyUpdateFile 验证更新文件哈希值
func (a *App) VerifyUpdateFile(filePath string, expectedHash string, hashType string) (map[string]interface{}, error) {
return a.updateAPI.VerifyUpdateFile(filePath, expectedHash, hashType)
}

185
internal/api/update_api.go Normal file
View File

@@ -0,0 +1,185 @@
package api
import (
"context"
"encoding/json"
"go-desk/internal/service"
"time"
"github.com/wailsapp/wails/v2/pkg/runtime"
)
// UpdateAPI 版本更新 API
type UpdateAPI struct {
updateService *service.UpdateService
ctx context.Context
}
// NewUpdateAPI 创建版本更新 API
func NewUpdateAPI(checkURL string) (*UpdateAPI, error) {
return &UpdateAPI{
updateService: service.NewUpdateService(checkURL),
}, nil
}
// SetContext 设置上下文(用于事件推送)
func (api *UpdateAPI) SetContext(ctx context.Context) {
api.ctx = ctx
}
// successResponse 构造成功响应
func successResponse(data interface{}) map[string]interface{} {
return map[string]interface{}{"success": true, "data": data}
}
// errorResponse 构造错误响应
func errorResponse(message string) map[string]interface{} {
return map[string]interface{}{"success": false, "message": message}
}
// CheckUpdate 检查更新
func (api *UpdateAPI) CheckUpdate() (map[string]interface{}, error) {
result, err := api.updateService.CheckUpdate()
if err != nil {
return errorResponse(err.Error()), nil
}
return successResponse(result), nil
}
// GetCurrentVersion 获取当前版本号
func (api *UpdateAPI) GetCurrentVersion() (map[string]interface{}, error) {
version := service.GetCurrentVersion()
// 同步配置中的版本号
if config, err := service.LoadUpdateConfig(); err == nil && config.CurrentVersion != version {
config.CurrentVersion = version
service.SaveUpdateConfig(config)
}
return successResponse(map[string]interface{}{
"version": version,
}), nil
}
// GetUpdateConfig 获取更新配置
func (api *UpdateAPI) GetUpdateConfig() (map[string]interface{}, error) {
config, err := service.LoadUpdateConfig()
if err != nil {
return errorResponse(err.Error()), nil
}
// 同步最新版本号
latestVersion := service.GetCurrentVersion()
if config.CurrentVersion != latestVersion {
config.CurrentVersion = latestVersion
service.SaveUpdateConfig(config)
}
return successResponse(map[string]interface{}{
"current_version": config.CurrentVersion,
"last_check_time": config.LastCheckTime.Format("2006-01-02 15:04:05"),
"auto_check_enabled": config.AutoCheckEnabled,
"check_interval_minutes": config.CheckIntervalMinutes,
"check_url": config.CheckURL,
}), nil
}
// SetUpdateConfig 设置更新配置
func (api *UpdateAPI) SetUpdateConfig(autoCheckEnabled bool, checkIntervalMinutes int, checkURL string) (map[string]interface{}, error) {
config, err := service.LoadUpdateConfig()
if err != nil {
return errorResponse(err.Error()), nil
}
config.AutoCheckEnabled = autoCheckEnabled
config.CheckIntervalMinutes = checkIntervalMinutes
if checkURL != "" {
config.CheckURL = checkURL
api.updateService = service.NewUpdateService(checkURL)
}
if err := service.SaveUpdateConfig(config); err != nil {
return errorResponse(err.Error()), nil
}
return successResponse(map[string]interface{}{
"message": "配置保存成功",
}), nil
}
// DownloadUpdate 下载更新包(异步,通过事件推送进度)
func (api *UpdateAPI) DownloadUpdate(downloadURL string) (map[string]interface{}, error) {
if downloadURL == "" {
return errorResponse("下载地址不能为空"), nil
}
go func() {
progressCallback := func(progress float64, speed float64, downloaded int64, total int64) {
progressInfo := map[string]interface{}{
"progress": progress,
"speed": speed,
"downloaded": downloaded,
"total": total,
}
progressJSON, _ := json.Marshal(progressInfo)
runtime.EventsEmit(api.ctx, "download-progress", string(progressJSON))
}
time.Sleep(100 * time.Millisecond)
result, err := service.DownloadUpdate(downloadURL, progressCallback)
if err != nil {
errorInfo := map[string]interface{}{"error": err.Error()}
errorJSON, _ := json.Marshal(errorInfo)
runtime.EventsEmit(api.ctx, "download-complete", string(errorJSON))
} else {
resultInfo := map[string]interface{}{
"success": true,
"file_path": result.FilePath,
"file_size": result.FileSize,
}
resultJSON, _ := json.Marshal(resultInfo)
runtime.EventsEmit(api.ctx, "download-complete", string(resultJSON))
}
}()
return successResponse(map[string]interface{}{
"message": "下载已开始",
}), nil
}
// InstallUpdate 安装更新包
func (api *UpdateAPI) InstallUpdate(installerPath string, autoRestart bool) (map[string]interface{}, error) {
return api.InstallUpdateWithHash(installerPath, autoRestart, "", "")
}
// InstallUpdateWithHash 安装更新包(带哈希验证)
func (api *UpdateAPI) InstallUpdateWithHash(installerPath string, autoRestart bool, expectedHash string, hashType string) (map[string]interface{}, error) {
if installerPath == "" {
return errorResponse("安装文件路径不能为空"), nil
}
result, err := service.InstallUpdateWithHash(installerPath, autoRestart, expectedHash, hashType)
if err != nil {
return errorResponse(err.Error()), nil
}
return successResponse(result), nil
}
// VerifyUpdateFile 验证更新文件哈希值
func (api *UpdateAPI) VerifyUpdateFile(filePath string, expectedHash string, hashType string) (map[string]interface{}, error) {
if filePath == "" {
return errorResponse("文件路径不能为空"), nil
}
valid, err := service.VerifyFileHash(filePath, expectedHash, hashType)
if err != nil {
return errorResponse(err.Error()), nil
}
return successResponse(map[string]interface{}{
"valid": valid,
}), nil
}

429
internal/service/update.go Normal file
View File

@@ -0,0 +1,429 @@
package service
import (
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"os"
"os/exec"
"path/filepath"
"runtime"
"time"
)
// ==================== 类型定义 ====================
// UpdateService 更新服务
type UpdateService struct {
checkURL string // 版本检查接口 URL
}
// RemoteVersionInfo 远程版本信息
type RemoteVersionInfo struct {
Version string `json:"version"`
DownloadURL string `json:"download_url"`
Changelog string `json:"changelog"`
ForceUpdate bool `json:"force_update"`
ReleaseDate string `json:"release_date"`
}
// UpdateCheckResult 更新检查结果
type UpdateCheckResult struct {
HasUpdate bool `json:"has_update"`
CurrentVersion string `json:"current_version"`
LatestVersion string `json:"latest_version"`
DownloadURL string `json:"download_url"`
Changelog string `json:"changelog"`
ForceUpdate bool `json:"force_update"`
ReleaseDate string `json:"release_date"`
}
// InstallResult 安装结果
type InstallResult struct {
Success bool `json:"success"`
Message string `json:"message"`
}
// ==================== 更新服务 ====================
// NewUpdateService 创建更新服务
func NewUpdateService(checkURL string) *UpdateService {
return &UpdateService{
checkURL: checkURL,
}
}
// CheckUpdate 检查更新
func (s *UpdateService) CheckUpdate() (*UpdateCheckResult, error) {
log.Printf("[更新检查] 开始检查更新,检查地址: %s", s.checkURL)
config, err := LoadUpdateConfig()
if err != nil {
return nil, fmt.Errorf("加载配置失败: %v", err)
}
// 同步版本号
currentVersionStr, err := s.syncConfigVersion(config)
if err != nil {
return nil, err
}
currentVersion, err := ParseVersion(currentVersionStr)
if err != nil {
return nil, fmt.Errorf("解析当前版本失败: %v", err)
}
// 请求远程版本信息
remoteInfo, err := s.fetchRemoteVersionInfo()
if err != nil {
return nil, fmt.Errorf("获取远程版本信息失败: %v", err)
}
log.Printf("[更新检查] 远程版本信息: 版本=%s, 下载地址=%s, 强制更新=%v",
remoteInfo.Version, remoteInfo.DownloadURL, remoteInfo.ForceUpdate)
// 解析远程版本号
remoteVersion, err := ParseVersion(remoteInfo.Version)
if err != nil {
return nil, fmt.Errorf("解析远程版本号失败: %v", err)
}
// 比较版本
hasUpdate := remoteVersion.IsNewerThan(currentVersion)
log.Printf("[更新检查] 版本比较: 当前=%s, 远程=%s, 有更新=%v",
currentVersion.String(), remoteVersion.String(), hasUpdate)
// 更新最后检查时间
config.UpdateLastCheckTime()
result := &UpdateCheckResult{
HasUpdate: hasUpdate,
CurrentVersion: currentVersionStr,
LatestVersion: remoteInfo.Version,
DownloadURL: remoteInfo.DownloadURL,
Changelog: remoteInfo.Changelog,
ForceUpdate: remoteInfo.ForceUpdate,
ReleaseDate: remoteInfo.ReleaseDate,
}
log.Printf("[更新检查] 检查完成: 有更新=%v", hasUpdate)
return result, nil
}
// syncConfigVersion 同步配置中的版本号
func (s *UpdateService) syncConfigVersion(config *UpdateConfig) (string, error) {
currentVersionStr := GetCurrentVersion()
if currentVersionStr == "" {
currentVersionStr = config.CurrentVersion
log.Printf("[更新检查] 使用配置中的版本号: %s", currentVersionStr)
} else if config.CurrentVersion != currentVersionStr {
log.Printf("[更新检查] 配置中的版本号 (%s) 与当前版本号 (%s) 不一致,更新配置",
config.CurrentVersion, currentVersionStr)
config.CurrentVersion = currentVersionStr
if err := SaveUpdateConfig(config); err != nil {
log.Printf("[更新检查] 更新配置失败: %v", err)
}
}
return currentVersionStr, nil
}
// fetchRemoteVersionInfo 获取远程版本信息
func (s *UpdateService) fetchRemoteVersionInfo() (*RemoteVersionInfo, error) {
if s.checkURL == "" {
log.Printf("[远程版本] 版本检查 URL 未配置")
return nil, fmt.Errorf("版本检查 URL 未配置,请先设置检查地址")
}
log.Printf("[远程版本] 请求远程版本信息: %s", s.checkURL)
// 创建 HTTP 客户端,设置超时
client := &http.Client{
Timeout: 10 * time.Second,
}
// 发送请求
resp, err := client.Get(s.checkURL)
if err != nil {
log.Printf("[远程版本] 网络请求失败: %v", err)
return nil, fmt.Errorf("网络请求失败: %v", err)
}
defer resp.Body.Close()
log.Printf("[远程版本] HTTP 响应状态码: %d", resp.StatusCode)
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("服务器返回错误状态码: %d", resp.StatusCode)
}
// 读取响应
body, err := io.ReadAll(resp.Body)
if err != nil {
log.Printf("[远程版本] 读取响应失败: %v", err)
return nil, fmt.Errorf("读取响应失败: %v", err)
}
log.Printf("[远程版本] 响应内容长度: %d 字节", len(body))
// 解析 JSON
var remoteInfo RemoteVersionInfo
if err := json.Unmarshal(body, &remoteInfo); err != nil {
log.Printf("[远程版本] 解析 JSON 失败: %v, 响应内容: %s", err, string(body))
return nil, fmt.Errorf("解析响应失败: %v", err)
}
if remoteInfo.Version == "" {
log.Printf("[远程版本] 远程版本信息不完整,响应内容: %s", string(body))
return nil, fmt.Errorf("远程版本信息不完整")
}
log.Printf("[远程版本] 成功获取远程版本信息: %+v", remoteInfo)
return &remoteInfo, nil
}
// ==================== 安装更新 ====================
// InstallUpdate 安装更新包
func InstallUpdate(installerPath string, autoRestart bool) (*InstallResult, error) {
return InstallUpdateWithHash(installerPath, autoRestart, "", "")
}
// InstallUpdateWithHash 安装更新包(带哈希验证)
func InstallUpdateWithHash(installerPath string, autoRestart bool, expectedHash string, hashType string) (*InstallResult, error) {
if _, err := os.Stat(installerPath); os.IsNotExist(err) {
return nil, fmt.Errorf("安装文件不存在: %s", installerPath)
}
// 哈希验证
if expectedHash != "" && hashType != "" {
valid, err := VerifyFileHash(installerPath, expectedHash, hashType)
if err != nil {
return &InstallResult{Success: false, Message: "文件验证失败: " + err.Error()}, nil
}
if !valid {
return &InstallResult{Success: false, Message: "文件哈希值不匹配,文件可能已损坏或被篡改"}, nil
}
}
// 备份
backupPath, err := BackupApplication()
if err != nil {
return &InstallResult{Success: false, Message: fmt.Sprintf("备份失败: %v", err)}, nil
}
// 安装
ext := filepath.Ext(installerPath)
switch ext {
case ".exe":
if runtime.GOOS != "windows" {
return &InstallResult{Success: false, Message: "当前系统不是 Windows无法安装 .exe 文件"}, nil
}
err = installExe(installerPath)
case ".zip":
err = installZip(installerPath)
default:
return nil, fmt.Errorf("不支持的安装包格式: %s", ext)
}
// 处理安装结果
if err != nil {
// 安装失败,尝试回滚
if backupPath != "" {
_ = rollbackFromBackup(backupPath)
}
return &InstallResult{Success: false, Message: fmt.Sprintf("安装失败: %v", err)}, nil
}
// 自动重启
if autoRestart {
go func() {
time.Sleep(2 * time.Second)
restartApplication()
}()
}
return &InstallResult{Success: true, Message: "安装成功"}, nil
}
// ==================== 安装相关辅助函数 ====================
// installExe 安装 exe 文件
func installExe(exePath string) error {
execPath, err := os.Executable()
if err != nil {
return err
}
return replaceExecutableFile(exePath, execPath)
}
// installZip 安装 ZIP 压缩包
func installZip(zipPath string) error {
// 这里需要导入 archive/zip 包
return fmt.Errorf("ZIP 安装暂未实现")
}
// replaceExecutableFile 替换可执行文件Windows 和 Unix 通用逻辑)
func replaceExecutableFile(newFilePath, execPath string) error {
if runtime.GOOS == "windows" {
return replaceExecutableFileWindows(newFilePath, execPath)
}
// Unix-like: 直接替换
if err := copyFile(newFilePath, execPath); err != nil {
return fmt.Errorf("复制文件失败: %v", err)
}
return os.Chmod(execPath, 0755)
}
// replaceExecutableFileWindows Windows 平台替换可执行文件
func replaceExecutableFileWindows(newFilePath, execPath string) error {
oldExecPath := execPath + ".old"
newExecPathTemp := execPath + ".new"
// 清理旧文件
os.Remove(oldExecPath)
os.Remove(newExecPathTemp)
// 复制新文件到临时位置
if err := copyFile(newFilePath, newExecPathTemp); err != nil {
return fmt.Errorf("复制新文件失败: %v", err)
}
// 尝试重命名当前文件(如果失败,说明文件正在使用)
if err := os.Rename(execPath, oldExecPath); err != nil {
return nil
}
// 替换文件
if err := os.Rename(newExecPathTemp, execPath); err != nil {
os.Rename(oldExecPath, execPath) // 恢复
return fmt.Errorf("替换文件失败: %v", err)
}
// 延迟删除旧文件
go func() {
time.Sleep(10 * time.Second)
os.Remove(oldExecPath)
}()
return nil
}
// restartApplication 重启应用
func restartApplication() {
execPath, err := os.Executable()
if err != nil {
return
}
currentPID := os.Getpid()
if runtime.GOOS != "windows" {
return
}
replacePendingFile(execPath)
// 创建并执行重启脚本
tempDir := os.TempDir()
batFile := filepath.Join(tempDir, fmt.Sprintf("restart_%d.bat", currentPID))
execDir := filepath.Dir(execPath)
batContent := fmt.Sprintf(`@echo off
cd /d "%s"
start "" "%s"
timeout /t 3 /nobreak >nul
taskkill /PID %d /F >nul 2>&1
del "%%~f0"
`, execDir, execPath, currentPID)
if err := os.WriteFile(batFile, []byte(batContent), 0644); err != nil {
fallbackRestart(execPath)
return
}
cmd := exec.Command("cmd", "/C", batFile)
cmd.Stdout = nil
cmd.Stderr = nil
if err := cmd.Start(); err != nil {
fallbackRestart(execPath)
return
}
os.Exit(0)
}
// fallbackRestart 降级重启方案
func fallbackRestart(execPath string) {
exec.Command("cmd", "/C", "start", "", execPath).Start()
time.Sleep(2 * time.Second)
os.Exit(0)
}
// replacePendingFile 替换待替换的文件(.new -> 可执行文件)
func replacePendingFile(execPath string) error {
newExecPathTemp := execPath + ".new"
if _, err := os.Stat(newExecPathTemp); os.IsNotExist(err) {
return nil // 没有待替换文件
}
oldExecPath := execPath + ".old"
os.Remove(oldExecPath)
if err := os.Rename(newExecPathTemp, execPath); err != nil {
return fmt.Errorf("文件替换失败: %v", err)
}
return nil
}
// rollbackFromBackup 从备份恢复
func rollbackFromBackup(backupPath string) error {
execPath, err := os.Executable()
if err != nil {
return err
}
return copyFile(backupPath, execPath)
}
// BackupApplication 备份当前应用
func BackupApplication() (string, error) {
execPath, err := os.Executable()
if err != nil {
return "", err
}
homeDir, err := os.UserHomeDir()
if err != nil {
return "", fmt.Errorf("获取用户目录失败: %v", err)
}
backupDir := filepath.Join(homeDir, ".go-desk", "backups")
if err := os.MkdirAll(backupDir, 0755); err != nil {
return "", fmt.Errorf("创建备份目录失败: %v", err)
}
timestamp := time.Now().Format("20060102-150405")
backupFileName := fmt.Sprintf("go-desk-backup-%s%s", timestamp, filepath.Ext(execPath))
backupPath := filepath.Join(backupDir, backupFileName)
if err := copyFile(execPath, backupPath); err != nil {
return "", fmt.Errorf("复制文件失败: %v", err)
}
return backupPath, nil
}
// copyFile 复制文件
func copyFile(src, dst string) error {
sourceFile, err := os.Open(src)
if err != nil {
return err
}
defer sourceFile.Close()
destFile, err := os.Create(dst)
if err != nil {
return err
}
defer destFile.Close()
_, err = destFile.ReadFrom(sourceFile)
return err
}

View File

@@ -0,0 +1,133 @@
package service
import (
"encoding/json"
"fmt"
"log"
"os"
"path/filepath"
"time"
)
// UpdateConfig 更新配置
type UpdateConfig struct {
CurrentVersion string `json:"current_version"`
LastCheckTime time.Time `json:"last_check_time"`
AutoCheckEnabled bool `json:"auto_check_enabled"`
CheckIntervalMinutes int `json:"check_interval_minutes"` // 检查间隔(分钟)
CheckURL string `json:"check_url,omitempty"` // 版本检查接口 URL
}
// GetUpdateConfigPath 获取更新配置文件路径
func GetUpdateConfigPath() (string, error) {
homeDir, err := os.UserHomeDir()
if err != nil {
return "", fmt.Errorf("获取用户目录失败: %v", err)
}
configDir := filepath.Join(homeDir, ".go-desk")
if err := os.MkdirAll(configDir, 0755); err != nil {
return "", fmt.Errorf("创建配置目录失败: %v", err)
}
return filepath.Join(configDir, "update_config.json"), nil
}
// LoadUpdateConfig 加载更新配置
func LoadUpdateConfig() (*UpdateConfig, error) {
configPath, err := GetUpdateConfigPath()
if err != nil {
return nil, err
}
// 如果文件不存在,返回默认配置
if _, err := os.Stat(configPath); os.IsNotExist(err) {
return &UpdateConfig{
CurrentVersion: GetCurrentVersion(),
LastCheckTime: time.Time{},
AutoCheckEnabled: true,
CheckIntervalMinutes: 1,
CheckURL: "https://img.1216.top/go-desk/last-version.json",
}, nil
}
data, err := os.ReadFile(configPath)
if err != nil {
return nil, fmt.Errorf("读取配置文件失败: %v", err)
}
var config UpdateConfig
if err := json.Unmarshal(data, &config); err != nil {
return nil, fmt.Errorf("解析配置文件失败: %v", err)
}
// 兼容旧配置:如果 check_interval_minutes 为 0尝试从旧字段转换
if config.CheckIntervalMinutes == 0 {
var configMap map[string]interface{}
if json.Unmarshal(data, &configMap) == nil {
if days, ok := configMap["check_interval_days"].(float64); ok && days > 0 {
config.CheckIntervalMinutes = int(days * 24 * 60)
}
}
if config.CheckIntervalMinutes == 0 {
config.CheckIntervalMinutes = 1
}
}
// 同步最新版本号
latestVersion := GetCurrentVersion()
if config.CurrentVersion == "" || config.CurrentVersion != latestVersion {
if config.CurrentVersion != "" {
log.Printf("[配置] 配置中的版本号 (%s) 与最新版本号 (%s) 不一致", config.CurrentVersion, latestVersion)
}
config.CurrentVersion = latestVersion
}
// 使用默认检查地址
if config.CheckURL == "" {
config.CheckURL = "https://img.1216.top/go-desk/last-version.json"
}
return &config, nil
}
// SaveUpdateConfig 保存更新配置
func SaveUpdateConfig(config *UpdateConfig) error {
configPath, err := GetUpdateConfigPath()
if err != nil {
return err
}
data, err := json.MarshalIndent(config, "", " ")
if err != nil {
return fmt.Errorf("序列化配置失败: %v", err)
}
if err := os.WriteFile(configPath, data, 0644); err != nil {
return fmt.Errorf("写入配置文件失败: %v", err)
}
return nil
}
// ShouldCheckUpdate 判断是否应该检查更新
func (c *UpdateConfig) ShouldCheckUpdate() bool {
if !c.AutoCheckEnabled {
return false
}
// 如果从未检查过,应该检查
if c.LastCheckTime.IsZero() {
return true
}
// 检查是否超过间隔分钟数
minutesSinceLastCheck := time.Since(c.LastCheckTime).Minutes()
return minutesSinceLastCheck >= float64(c.CheckIntervalMinutes)
}
// UpdateLastCheckTime 更新最后检查时间
func (c *UpdateConfig) UpdateLastCheckTime() error {
c.LastCheckTime = time.Now()
return SaveUpdateConfig(c)
}

View File

@@ -0,0 +1,340 @@
package service
import (
"crypto/md5"
"crypto/sha256"
"encoding/hex"
"fmt"
"io"
"log"
"net/http"
"os"
"path/filepath"
"time"
)
// ==================== 类型定义 ====================
// DownloadProgress 下载进度回调函数类型
type DownloadProgress func(progress float64, speed float64, downloaded int64, total int64)
// DownloadResult 下载结果
type DownloadResult struct {
FilePath string `json:"file_path"`
FileSize int64 `json:"file_size"`
MD5Hash string `json:"md5_hash,omitempty"`
SHA256Hash string `json:"sha256_hash,omitempty"`
}
// ==================== 下载更新 ====================
// DownloadUpdate 下载更新包
func DownloadUpdate(downloadURL string, progressCallback DownloadProgress) (*DownloadResult, error) {
log.Printf("[下载] 开始下载URL: %s", downloadURL)
// 获取下载目录
homeDir, err := os.UserHomeDir()
if err != nil {
return nil, fmt.Errorf("获取用户目录失败: %v", err)
}
downloadDir := filepath.Join(homeDir, ".go-desk", "downloads")
if err := os.MkdirAll(downloadDir, 0755); err != nil {
return nil, fmt.Errorf("创建下载目录失败: %v", err)
}
// 从 URL 提取文件名
filename := filepath.Base(downloadURL)
if filename == "" || filename == "." {
filename = fmt.Sprintf("update-%d.exe", time.Now().Unix())
}
filePath := filepath.Join(downloadDir, filename)
// 检查文件是否已存在
var downloadedSize int64 = 0
if fileInfo, err := os.Stat(filePath); err == nil {
downloadedSize = fileInfo.Size()
// 检查是否已完整下载
if remoteSize, err := getRemoteFileSize(downloadURL); err == nil && downloadedSize == remoteSize {
log.Printf("[下载] 文件已存在且完整: %s", filePath)
if progressCallback != nil {
progressCallback(100.0, 0, downloadedSize, remoteSize)
time.Sleep(50 * time.Millisecond)
}
md5Hash, sha256Hash, err := calculateFileHashes(filePath)
if err != nil {
return nil, fmt.Errorf("计算文件哈希失败: %v", err)
}
return &DownloadResult{
FilePath: filePath,
FileSize: downloadedSize,
MD5Hash: md5Hash,
SHA256Hash: sha256Hash,
}, nil
}
}
// 打开文件(支持断点续传)
file, err := os.OpenFile(filePath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644)
if err != nil {
return nil, fmt.Errorf("创建文件失败: %v", err)
}
defer file.Close()
// 创建 HTTP 请求
req, err := http.NewRequest("GET", downloadURL, nil)
if err != nil {
return nil, fmt.Errorf("创建请求失败: %v", err)
}
// 如果已下载部分,设置 Range 头(断点续传)
if downloadedSize > 0 {
req.Header.Set("Range", fmt.Sprintf("bytes=%d-", downloadedSize))
log.Printf("[下载] 启用断点续传,已下载: %d 字节", downloadedSize)
}
// 发送请求
client := &http.Client{Timeout: 30 * time.Minute}
resp, err := client.Do(req)
if err != nil {
return nil, fmt.Errorf("下载请求失败: %v", err)
}
defer resp.Body.Close()
// 检查响应状态
if resp.StatusCode == http.StatusRequestedRangeNotSatisfiable {
file.Close()
if fileInfo, err := os.Stat(filePath); err == nil {
if remoteSize, err := getRemoteFileSize(downloadURL); err == nil && fileInfo.Size() == remoteSize {
log.Printf("[下载] 文件已完整下载")
if progressCallback != nil {
progressCallback(100.0, 0, fileInfo.Size(), remoteSize)
}
md5Hash, sha256Hash, err := calculateFileHashes(filePath)
if err != nil {
return nil, fmt.Errorf("计算文件哈希失败: %v", err)
}
return &DownloadResult{
FilePath: filePath,
FileSize: fileInfo.Size(),
MD5Hash: md5Hash,
SHA256Hash: sha256Hash,
}, nil
}
}
return nil, fmt.Errorf("服务器返回 416 错误,且文件可能不完整")
}
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusPartialContent {
return nil, fmt.Errorf("服务器返回错误状态码: %d", resp.StatusCode)
}
// 获取文件总大小
contentLength := resp.ContentLength
gotTotalFromRange := false
// 优先从 Content-Range 头获取总大小
if rangeHeader := resp.Header.Get("Content-Range"); rangeHeader != "" {
var start, end, total int64
if n, _ := fmt.Sscanf(rangeHeader, "bytes %d-%d/%d", &start, &end, &total); n == 3 && total > 0 {
contentLength = total
gotTotalFromRange = true
}
}
// 如果未获取到,尝试通过 HEAD 请求获取
if contentLength <= 0 && !gotTotalFromRange {
if remoteSize, err := getRemoteFileSize(downloadURL); err == nil {
contentLength = remoteSize
}
}
// 断点续传时,如果未从 Content-Range 获取,需要加上已下载部分
if resp.StatusCode == http.StatusPartialContent && downloadedSize > 0 && contentLength > 0 && !gotTotalFromRange {
if contentLength < downloadedSize {
contentLength += downloadedSize
}
}
log.Printf("[下载] 开始下载文件,总大小: %d 字节,已下载: %d 字节", contentLength, downloadedSize)
// 发送初始进度事件
if progressCallback != nil {
time.Sleep(50 * time.Millisecond)
if contentLength > 0 && downloadedSize > 0 {
progress := normalizeProgress(float64(downloadedSize) / float64(contentLength) * 100)
progressCallback(progress, 0, downloadedSize, contentLength)
} else {
progressCallback(0, 0, downloadedSize, -1)
}
time.Sleep(20 * time.Millisecond)
}
// 下载文件
buffer := make([]byte, 32*1024) // 32KB 缓冲区
var totalDownloaded int64 = downloadedSize
startTime := time.Now()
lastProgressTime := startTime
lastProgressSize := totalDownloaded
for {
n, err := resp.Body.Read(buffer)
if n > 0 {
written, writeErr := file.Write(buffer[:n])
if writeErr != nil {
return nil, fmt.Errorf("写入文件失败: %v", writeErr)
}
totalDownloaded += int64(written)
// 计算进度和速度
now := time.Now()
elapsed := now.Sub(lastProgressTime).Seconds()
// 每 0.3 秒更新一次进度
if elapsed >= 0.3 {
progress := float64(0)
if contentLength > 0 {
progress = normalizeProgress(float64(totalDownloaded) / float64(contentLength) * 100)
}
speed := float64(0)
if elapsed > 0 {
speed = float64(totalDownloaded-lastProgressSize) / elapsed
}
if progressCallback != nil {
progressCallback(progress, speed, totalDownloaded, contentLength)
}
lastProgressTime = now
lastProgressSize = totalDownloaded
}
}
if err == io.EOF {
break
}
if err != nil {
return nil, fmt.Errorf("读取数据失败: %v", err)
}
}
// 最后一次进度更新
if progressCallback != nil {
if contentLength > 0 {
progressCallback(100.0, 0, totalDownloaded, contentLength)
} else {
progressCallback(100.0, 0, totalDownloaded, totalDownloaded)
}
}
file.Close()
log.Printf("[下载] 下载完成,文件大小: %d 字节", totalDownloaded)
md5Hash, sha256Hash, err := calculateFileHashes(filePath)
if err != nil {
return nil, fmt.Errorf("计算文件哈希失败: %v", err)
}
log.Printf("[下载] 文件哈希计算完成MD5: %s, SHA256: %s", md5Hash, sha256Hash)
fileInfo, err := os.Stat(filePath)
if err != nil {
return nil, fmt.Errorf("获取文件信息失败: %v", err)
}
return &DownloadResult{
FilePath: filePath,
FileSize: fileInfo.Size(),
MD5Hash: md5Hash,
SHA256Hash: sha256Hash,
}, nil
}
// ==================== 辅助函数 ====================
// getRemoteFileSize 通过HEAD请求获取远程文件大小
func getRemoteFileSize(url string) (int64, error) {
client := &http.Client{Timeout: 10 * time.Second}
req, err := http.NewRequest("HEAD", url, nil)
if err != nil {
return 0, err
}
resp, err := client.Do(req)
if err != nil {
return 0, err
}
defer resp.Body.Close()
if resp.ContentLength > 0 {
return resp.ContentLength, nil
}
return 0, fmt.Errorf("无法获取文件大小")
}
// normalizeProgress 标准化进度值到0-100之间
func normalizeProgress(progress float64) float64 {
if progress < 0 {
return 0
}
if progress > 100 {
return 100
}
return progress
}
// calculateFileHashes 计算文件的 MD5 和 SHA256 哈希值
func calculateFileHashes(filePath string) (string, string, error) {
file, err := os.Open(filePath)
if err != nil {
return "", "", err
}
defer file.Close()
md5Hash := md5.New()
sha256Hash := sha256.New()
// 使用 MultiWriter 同时计算两个哈希
writer := io.MultiWriter(md5Hash, sha256Hash)
if _, err := io.Copy(writer, file); err != nil {
return "", "", err
}
md5Sum := hex.EncodeToString(md5Hash.Sum(nil))
sha256Sum := hex.EncodeToString(sha256Hash.Sum(nil))
return md5Sum, sha256Sum, nil
}
// VerifyFileHash 验证文件哈希值
func VerifyFileHash(filePath string, expectedHash string, hashType string) (bool, error) {
file, err := os.Open(filePath)
if err != nil {
return false, err
}
defer file.Close()
var hash []byte
var calculatedHash string
switch hashType {
case "md5":
md5Hash := md5.New()
if _, err := io.Copy(md5Hash, file); err != nil {
return false, err
}
hash = md5Hash.Sum(nil)
calculatedHash = hex.EncodeToString(hash)
case "sha256":
sha256Hash := sha256.New()
if _, err := io.Copy(sha256Hash, file); err != nil {
return false, err
}
hash = sha256Hash.Sum(nil)
calculatedHash = hex.EncodeToString(hash)
default:
return false, fmt.Errorf("不支持的哈希类型: %s", hashType)
}
return calculatedHash == expectedHash, nil
}

161
internal/service/version.go Normal file
View File

@@ -0,0 +1,161 @@
package service
import (
"encoding/json"
"fmt"
"log"
"os"
"path/filepath"
"strconv"
"strings"
)
// ==================== 常量定义 ====================
// AppVersion 应用版本号(发布时直接修改此处)
const AppVersion = "0.1.0"
// ==================== 类型定义 ====================
// Version 版本号结构
type Version struct {
Major int
Minor int
Patch int
}
// WailsConfig Wails 配置文件结构
type WailsConfig struct {
Version string `json:"version"`
}
// ==================== 版本号解析和比较 ====================
// ParseVersion 解析版本号字符串(支持 v1.0.0 或 1.0.0 格式)
func ParseVersion(versionStr string) (*Version, error) {
versionStr = strings.TrimPrefix(versionStr, "v")
parts := strings.Split(versionStr, ".")
if len(parts) != 3 {
return nil, fmt.Errorf("版本号格式错误,应为 x.y.z 格式")
}
major, err := strconv.Atoi(parts[0])
if err != nil {
return nil, fmt.Errorf("主版本号解析失败: %v", err)
}
minor, err := strconv.Atoi(parts[1])
if err != nil {
return nil, fmt.Errorf("次版本号解析失败: %v", err)
}
patch, err := strconv.Atoi(parts[2])
if err != nil {
return nil, fmt.Errorf("修订号解析失败: %v", err)
}
return &Version{Major: major, Minor: minor, Patch: patch}, nil
}
// String 返回版本号字符串格式v1.0.0
func (v *Version) String() string {
return fmt.Sprintf("v%d.%d.%d", v.Major, v.Minor, v.Patch)
}
// Compare 比较版本号
// 返回值:-1 表示当前版本小于目标版本0 表示相等1 表示大于
func (v *Version) Compare(other *Version) int {
switch {
case v.Major != other.Major:
return compareInt(v.Major, other.Major)
case v.Minor != other.Minor:
return compareInt(v.Minor, other.Minor)
case v.Patch != other.Patch:
return compareInt(v.Patch, other.Patch)
default:
return 0
}
}
// compareInt 比较两个整数
func compareInt(a, b int) int {
if a < b {
return -1
}
if a > b {
return 1
}
return 0
}
// IsNewerThan 判断是否比目标版本新
func (v *Version) IsNewerThan(other *Version) bool {
return v.Compare(other) > 0
}
// IsOlderThan 判断是否比目标版本旧
func (v *Version) IsOlderThan(other *Version) bool {
return v.Compare(other) < 0
}
// ==================== 版本号获取 ====================
// GetCurrentVersion 获取当前版本号
// 优先级:硬编码版本号 > wails.json开发模式> 默认值
func GetCurrentVersion() string {
if AppVersion != "" {
log.Printf("[版本] 使用硬编码版本号: %s", AppVersion)
return AppVersion
}
version := getVersionFromWailsJSON()
if version != "" {
log.Printf("[版本] 从 wails.json 获取版本号: %s", version)
return version
}
log.Printf("[版本] 使用默认版本号: 0.0.1")
return "0.0.1"
}
// ==================== 配置文件读取 ====================
// getVersionFromWailsJSON 从 wails.json 读取版本号(仅开发模式使用)
func getVersionFromWailsJSON() string {
wd, err := os.Getwd()
if err != nil {
return ""
}
// 尝试当前目录
if version := readVersionFromFile(filepath.Join(wd, "wails.json")); version != "" {
return version
}
// 尝试父目录
if version := readVersionFromFile(filepath.Join(filepath.Dir(wd), "wails.json")); version != "" {
return version
}
return ""
}
// readVersionFromFile 从指定文件读取版本号
func readVersionFromFile(filePath string) string {
data, err := os.ReadFile(filePath)
if err != nil {
log.Printf("[版本] 读取文件失败: %s, 错误: %v", filePath, err)
return ""
}
var config WailsConfig
if err := json.Unmarshal(data, &config); err != nil {
log.Printf("[版本] 解析 JSON 失败: %s, 错误: %v", filePath, err)
return ""
}
if config.Version != "" {
log.Printf("[版本] 从文件读取版本号: %s -> %s", filePath, config.Version)
}
return config.Version
}

View File

@@ -0,0 +1,20 @@
package models
import "time"
// Version 版本信息
type Version struct {
ID int `gorm:"primaryKey" json:"id"` // 主键ID
Version string `gorm:"type:varchar(20);not null;uniqueIndex" json:"version"` // 版本号语义化版本如1.0.0
DownloadURL string `gorm:"type:varchar(500)" json:"download_url"` // 下载地址更新包下载URL
Changelog string `gorm:"type:text" json:"changelog"` // 更新日志Markdown格式
ForceUpdate int `gorm:"type:tinyint;not null;default:0" json:"force_update"` // 是否强制更新1:是 0:否)
ReleaseDate *time.Time `gorm:"type:date" json:"release_date"` // 发布日期
CreatedAt time.Time `gorm:"autoCreateTime:false" json:"created_at"` // 创建时间(由程序设置)
UpdatedAt time.Time `gorm:"autoUpdateTime:false" json:"updated_at"` // 更新时间(由程序设置)
}
// TableName 指定表名
func (Version) TableName() string {
return "sys_version"
}

View File

@@ -47,7 +47,7 @@ func main() {
Assets: assets,
},
BackgroundColour: &options.RGBA{R: 255, G: 255, B: 255, A: 1},
OnStartup: app.startup,
OnStartup: app.Startup,
Bind: []interface{}{
app,
},

View File

@@ -5,10 +5,18 @@
<h2>Go Desk</h2>
<a-tabs v-model:active-key="activeTab" class="header-tabs">
<a-tab-pane key="db-cli" title="数据库客户端"/>
<a-tab-pane key="file-system" title="文件管理"/>
<a-tab-pane key="user" title="用户查询"/>
<a-tab-pane key="device" title="设备调用测试"/>
</a-tabs>
<div class="header-actions">
<a-tooltip content="版本更新">
<a-button type="text" @click="showUpdateModal = true">
<template #icon>
<icon-sync />
</template>
</a-button>
</a-tooltip>
<ThemeToggle />
</div>
</div>
@@ -17,6 +25,9 @@
<!-- 数据库客户端 -->
<DbCli v-if="activeTab === 'db-cli'"/>
<!-- 文件管理 -->
<FileSystem v-if="activeTab === 'file-system'"/>
<!-- 用户查询页面 -->
<div v-if="activeTab === 'user'">
<!-- 查询表单 -->
@@ -83,6 +94,16 @@
<!-- 设备调用测试页面 -->
<DeviceTest v-if="activeTab === 'device'"/>
</a-layout-content>
<!-- 版本更新模态框 -->
<a-modal
v-model:visible="showUpdateModal"
title="版本更新"
width="800px"
:footer="false"
>
<UpdatePanel />
</a-modal>
</a-layout>
</template>
@@ -92,8 +113,11 @@ import {Message} from '@arco-design/web-vue'
import DeviceTest from './components/DeviceTest.vue'
import DbCli from './views/db-cli/index.vue'
import ThemeToggle from './components/ThemeToggle.vue'
import UpdatePanel from './components/UpdatePanel.vue'
import FileSystem from './components/FileSystem.vue'
const activeTab = ref('db-cli')
const showUpdateModal = ref(false)
const loading = ref(false)
const formModel = ref({
keyword: '',

View File

@@ -53,14 +53,36 @@
<a-card class="test-card" title="文件系统操作">
<a-space direction="vertical" :size="16" style="width: 100%">
<a-input-group>
<a-input
<a-auto-complete
v-model="filePath"
:data="pathHistory"
placeholder="输入文件或目录路径"
style="flex: 1"
@select="onPathSelect"
/>
<a-button @click="browseDirectory">浏览</a-button>
<a-button type="primary" @click="listDirectory">列出目录</a-button>
</a-input-group>
<!-- 收藏的文件 -->
<a-card size="small" title="⭐ 收藏的文件" v-if="favoriteFiles.length > 0">
<a-space wrap>
<a-tag
v-for="fav in favoriteFiles"
:key="fav.path"
closable
@close="removeFavorite(fav.path)"
@click="openFavoriteFile(fav.path)"
style="cursor: pointer; margin-bottom: 4px"
>
<template #icon>
<span>{{ fav.is_dir ? '📁' : '📄' }}</span>
</template>
{{ fav.name }}
</a-tag>
</a-space>
</a-card>
<a-row :gutter="16">
<a-col :span="12">
<a-card size="small" title="文件列表">
@@ -76,6 +98,14 @@
<a-space>
<span>{{ item.is_dir ? '📁' : '📄' }}</span>
<a @click="selectFile(item.path)">{{ item.name }}</a>
<a-button
type="text"
size="small"
@click.stop="toggleFavorite(item)"
:style="{ color: isFavorite(item.path) ? '#ffcd00' : '' }"
>
{{ isFavorite(item.path) ? '⭐' : '☆' }}
</a-button>
</a-space>
</template>
<template #description>
@@ -91,11 +121,23 @@
<a-col :span="12">
<a-card size="small" title="文件内容">
<a-space direction="vertical" :size="8" style="width: 100%">
<a-textarea
v-model="fileContent"
:rows="10"
placeholder="文件内容将显示在这里"
/>
<div
class="file-content-wrapper"
:style="{ height: fileContentHeight + 'px' }"
>
<a-textarea
v-model="fileContent"
class="file-content-textarea"
placeholder="文件内容将显示在这里"
/>
</div>
<div
class="resize-handle"
@mousedown="startResize"
title="拖拽调整高度"
>
<div class="resize-handle-bar"></div>
</div>
<a-space>
<a-button type="primary" @click="readFile" :loading="fileLoading">读取文件</a-button>
<a-button @click="writeFile" :loading="fileLoading">写入文件</a-button>
@@ -124,8 +166,8 @@
</template>
<script setup>
import {computed, onMounted, ref} from 'vue'
import {Message} from '@arco-design/web-vue'
import {computed, onMounted, ref, watch} from 'vue'
import {Message, Modal} from '@arco-design/web-vue'
import {
getSystemInfo,
getCPUInfo,
@@ -138,6 +180,16 @@ import {
getEnvVars
} from '@/api'
// localStorage 键名
const STORAGE_KEYS = {
FILE_PATH: 'device-test-file-path',
FILE_LIST: 'device-test-file-list',
FILE_CONTENT: 'device-test-file-content',
PATH_HISTORY: 'device-test-path-history',
FILE_CONTENT_HEIGHT: 'device-test-file-content-height',
FAVORITE_FILES: 'device-test-favorite-files'
}
const systemInfo = ref(null)
const cpuInfo = ref(null)
const memoryInfo = ref(null)
@@ -148,6 +200,10 @@ const fileList = ref([])
const fileLoading = ref(false)
const envVars = ref(null)
const envLoading = ref(false)
const pathHistory = ref([]) // 路径历史记录
const fileContentHeight = ref(200) // 文件内容区域高度默认200px
const isResizing = ref(false) // 是否正在拖拽
const favoriteFiles = ref([]) // 收藏的文件列表
const diskColumns = [
{title: '设备', dataIndex: 'device', width: 120},
@@ -188,6 +244,10 @@ const listDirectory = async () => {
Message.error('请输入目录路径')
return
}
// 添加到历史记录
addToHistory(filePath.value)
fileLoading.value = true
try {
fileList.value = await listDir(filePath.value)
@@ -199,11 +259,21 @@ const listDirectory = async () => {
}
}
// 自动完成选择处理
const onPathSelect = (value) => {
filePath.value = value
listDirectory()
}
const readFile = async () => {
if (!filePath.value) {
Message.error('请输入文件路径')
return
}
// 添加到历史记录
addToHistory(filePath.value)
fileLoading.value = true
try {
fileContent.value = await readFileApi(filePath.value)
@@ -237,26 +307,31 @@ const deleteFile = async () => {
Message.error('请输入文件路径')
return
}
if (!confirm('确定要删除 ' + filePath.value + ' 吗?')) {
return
}
fileLoading.value = true
try {
await deletePath(filePath.value)
Message.success('删除成功')
filePath.value = ''
fileContent.value = ''
fileList.value = []
} catch (error) {
console.error('删除失败:', error)
Message.error('删除失败: ' + (error.message || error))
} finally {
fileLoading.value = false
}
Modal.confirm({
title: '确认删除',
content: `确定要删除 ${filePath.value} 吗?`,
onOk: async () => {
fileLoading.value = true
try {
await deletePath(filePath.value)
Message.success('删除成功')
filePath.value = ''
fileContent.value = ''
fileList.value = []
} catch (error) {
console.error('删除失败:', error)
Message.error('删除失败: ' + (error.message || error))
} finally {
fileLoading.value = false
}
}
})
}
const selectFile = (path) => {
filePath.value = path
addToHistory(path)
}
const browseDirectory = () => {
@@ -287,7 +362,164 @@ const formatBytes = (bytes) => {
return (bytes / Math.pow(unit, exp)).toFixed(2) + ' ' + 'KMGTPE'[exp - 1] + 'B'
}
// 开始拖拽
const startResize = (e) => {
isResizing.value = true
const startY = e.clientY
const startHeight = fileContentHeight.value
const onMouseMove = (moveEvent) => {
if (!isResizing.value) return
const deltaY = moveEvent.clientY - startY
const newHeight = startHeight + deltaY
// 限制高度范围
if (newHeight >= 100 && newHeight <= 800) {
fileContentHeight.value = newHeight
}
}
const onMouseUp = () => {
isResizing.value = false
document.removeEventListener('mousemove', onMouseMove)
document.removeEventListener('mouseup', onMouseUp)
// 保存高度到 localStorage
saveToStorage(STORAGE_KEYS.FILE_CONTENT_HEIGHT, fileContentHeight.value.toString())
}
document.addEventListener('mousemove', onMouseMove)
document.addEventListener('mouseup', onMouseUp)
}
// 从 localStorage 加载数据
const loadFromStorage = () => {
try {
const savedPath = localStorage.getItem(STORAGE_KEYS.FILE_PATH)
const savedFileList = localStorage.getItem(STORAGE_KEYS.FILE_LIST)
const savedFileContent = localStorage.getItem(STORAGE_KEYS.FILE_CONTENT)
const savedHistory = localStorage.getItem(STORAGE_KEYS.PATH_HISTORY)
const savedHeight = localStorage.getItem(STORAGE_KEYS.FILE_CONTENT_HEIGHT)
const savedFavorites = localStorage.getItem(STORAGE_KEYS.FAVORITE_FILES)
if (savedPath) filePath.value = savedPath
if (savedFileList) fileList.value = JSON.parse(savedFileList)
if (savedFileContent) fileContent.value = savedFileContent
if (savedHistory) pathHistory.value = JSON.parse(savedHistory)
if (savedFavorites) favoriteFiles.value = JSON.parse(savedFavorites)
if (savedHeight) {
const height = parseInt(savedHeight)
if (!isNaN(height) && height >= 100 && height <= 800) {
fileContentHeight.value = height
}
}
} catch (error) {
console.error('从 localStorage 加载数据失败:', error)
}
}
// 保存到 localStorage
const saveToStorage = (key, value) => {
try {
if (typeof value === 'string') {
localStorage.setItem(key, value)
} else {
localStorage.setItem(key, JSON.stringify(value))
}
} catch (error) {
console.error('保存到 localStorage 失败:', error)
}
}
// 添加到历史记录
const addToHistory = (path) => {
if (!path || path.trim() === '') return
// 移除重复项
const index = pathHistory.value.indexOf(path)
if (index > -1) {
pathHistory.value.splice(index, 1)
}
// 添加到开头
pathHistory.value.unshift(path)
// 限制历史记录数量
if (pathHistory.value.length > 20) {
pathHistory.value = pathHistory.value.slice(0, 20)
}
saveToStorage(STORAGE_KEYS.PATH_HISTORY, pathHistory.value)
}
// 检查是否已收藏
const isFavorite = (path) => {
return favoriteFiles.value.some(fav => fav.path === path)
}
// 切换收藏状态
const toggleFavorite = (item) => {
const index = favoriteFiles.value.findIndex(fav => fav.path === item.path)
if (index > -1) {
// 已收藏,取消收藏
favoriteFiles.value.splice(index, 1)
Message.info('已取消收藏: ' + item.name)
} else {
// 未收藏,添加收藏
favoriteFiles.value.push({
path: item.path,
name: item.name,
is_dir: item.is_dir
})
Message.success('已收藏: ' + item.name)
}
// 保存到 localStorage
saveToStorage(STORAGE_KEYS.FAVORITE_FILES, favoriteFiles.value)
}
// 移除收藏
const removeFavorite = (path) => {
const index = favoriteFiles.value.findIndex(fav => fav.path === path)
if (index > -1) {
const name = favoriteFiles.value[index].name
favoriteFiles.value.splice(index, 1)
saveToStorage(STORAGE_KEYS.FAVORITE_FILES, favoriteFiles.value)
Message.info('已取消收藏: ' + name)
}
}
// 打开收藏的文件
const openFavoriteFile = (path) => {
filePath.value = path
addToHistory(path)
// 判断是文件还是目录
const fav = favoriteFiles.value.find(f => f.path === path)
if (fav && fav.is_dir) {
listDirectory()
} else {
readFile()
}
}
// 监听数据变化自动保存
watch(filePath, (newPath) => {
saveToStorage(STORAGE_KEYS.FILE_PATH, newPath)
})
watch(fileContent, (newContent) => {
saveToStorage(STORAGE_KEYS.FILE_CONTENT, newContent)
})
watch(fileList, (newList) => {
saveToStorage(STORAGE_KEYS.FILE_LIST, newList)
}, { deep: true })
onMounted(() => {
loadFromStorage()
refreshSystemInfo()
})
</script>
@@ -302,4 +534,47 @@ onMounted(() => {
.test-card {
margin-bottom: 16px;
}
/* 文件内容区域容器 */
.file-content-wrapper {
position: relative;
overflow: hidden;
transition: height 0.1s ease;
}
/* 文件内容文本框 */
.file-content-textarea {
width: 100%;
height: 100%;
resize: none;
}
/* 拖拽手柄 */
.resize-handle {
height: 8px;
cursor: ns-resize;
display: flex;
align-items: center;
justify-content: center;
background: var(--color-fill-2);
border-radius: 4px;
margin: 4px 0;
transition: background 0.2s;
}
.resize-handle:hover {
background: var(--color-fill-3);
}
/* 拖拽手柄的视觉指示条 */
.resize-handle-bar {
width: 40px;
height: 3px;
background: var(--color-border-3);
border-radius: 2px;
}
.resize-handle:hover .resize-handle-bar {
background: rgb(var(--primary-6));
}
</style>

View File

@@ -0,0 +1,559 @@
<template>
<div class="file-system">
<a-space direction="vertical" style="width: 100%" :size="16">
<!-- 路径输入 -->
<a-card title="📂 文件浏览">
<a-space direction="vertical" :size="12" style="width: 100%">
<a-input-group>
<a-auto-complete
v-model="filePath"
:data="pathHistory"
placeholder="输入文件或目录路径 (如: C:\Users 或 /home/user)"
style="flex: 1"
@select="onPathSelect"
/>
<a-button @click="browseDirectory">
<template #icon>
<icon-folder />
</template>
浏览
</a-button>
<a-button type="primary" @click="listDirectory" :loading="fileLoading">
<template #icon>
<icon-list />
</template>
列出目录
</a-button>
</a-input-group>
<!-- 常用路径快捷按钮 -->
<a-space wrap>
<a-tag
v-for="shortcut in commonPaths"
:key="shortcut.path"
@click="openPath(shortcut.path)"
style="cursor: pointer"
>
<template #icon>
<icon-forward />
</template>
{{ shortcut.name }}
</a-tag>
</a-space>
</a-space>
</a-card>
<!-- 收藏的文件 -->
<a-card title="⭐ 收藏夹" v-if="favoriteFiles.length > 0">
<a-space wrap>
<a-tag
v-for="fav in favoriteFiles"
:key="fav.path"
closable
@close="removeFavorite(fav.path)"
@click="openFavoriteFile(fav.path)"
style="cursor: pointer; margin-bottom: 4px; padding: 4px 8px"
>
<template #icon>
<span style="font-size: 16px">{{ fav.is_dir ? '📁' : '📄' }}</span>
</template>
{{ fav.name }}
</a-tag>
</a-space>
</a-card>
<!-- 文件列表和编辑器 -->
<a-row :gutter="16">
<a-col :span="12">
<a-card title="📋 文件列表" style="height: 100%">
<a-list
:data="fileList"
:loading="fileLoading"
:bordered="false"
style="max-height: 500px; overflow-y: auto"
>
<template #item="{ item }">
<a-list-item class="file-item">
<a-list-item-meta>
<template #title>
<a-space>
<span style="font-size: 18px">{{ item.is_dir ? '📁' : '📄' }}</span>
<a @click="selectFile(item.path)" class="file-name">{{ item.name }}</a>
<a-button
type="text"
size="small"
@click.stop="toggleFavorite(item)"
:style="{ color: isFavorite(item.path) ? '#ffcd00' : '#86909c' }"
>
<template #icon>
<icon-star-fill v-if="isFavorite(item.path)" />
<icon-star v-else />
</template>
</a-button>
</a-space>
</template>
<template #description>
<a-space split="|">
<span v-if="!item.is_dir">{{ formatBytes(item.size) }}</span>
<span>{{ item.mod_time }}</span>
</a-space>
</template>
</a-list-item-meta>
</a-list-item>
</template>
</a-list>
</a-card>
</a-col>
<a-col :span="12">
<a-card title="📝 文件内容">
<a-space direction="vertical" :size="8" style="width: 100%">
<div
class="file-content-wrapper"
:style="{ height: fileContentHeight + 'px' }"
>
<a-textarea
v-model="fileContent"
class="file-content-textarea"
placeholder="文件内容将显示在这里,点击文件列表中的文件即可查看"
/>
</div>
<div
class="resize-handle"
@mousedown="startResize"
title="拖拽调整高度"
>
<div class="resize-handle-bar"></div>
</div>
<a-space>
<a-button type="primary" @click="readFile" :loading="fileLoading">
<template #icon>
<icon-file />
</template>
读取
</a-button>
<a-button @click="writeFile" :loading="fileLoading">
<template #icon>
<icon-save />
</template>
保存
</a-button>
<a-button status="danger" @click="deleteFile" :loading="fileLoading">
<template #icon>
<icon-delete />
</template>
删除
</a-button>
<a-button @click="clearContent">
<template #icon>
<icon-eraser />
</template>
清空
</a-button>
</a-space>
</a-space>
</a-card>
</a-col>
</a-row>
</a-space>
</div>
</template>
<script setup>
import { computed, onMounted, ref, watch } from 'vue'
import { Message, Modal } from '@arco-design/web-vue'
import {
IconFolder,
IconList,
IconForward,
IconStarFill,
IconStar,
IconFile,
IconSave,
IconDelete,
IconEraser
} from '@arco-design/web-vue/es/icon'
import {
listDir,
readFile as readFileApi,
writeFile as writeFileApi,
deletePath
} from '@/api'
// localStorage 键名
const STORAGE_KEYS = {
FILE_PATH: 'filesystem-file-path',
FILE_LIST: 'filesystem-file-list',
FILE_CONTENT: 'filesystem-file-content',
PATH_HISTORY: 'filesystem-path-history',
FILE_CONTENT_HEIGHT: 'filesystem-file-content-height',
FAVORITE_FILES: 'filesystem-favorite-files'
}
// 常用路径快捷方式
const commonPaths = computed(() => {
const platform = window.navigator.platform
if (platform.includes('Win')) {
return [
{ name: '桌面', path: `${process.env.USERPROFILE || ''}\\Desktop` },
{ name: '文档', path: `${process.env.USERPROFILE || ''}\\Documents` },
{ name: '下载', path: `${process.env.USERPROFILE || ''}\\Downloads` },
{ name: 'C盘根目录', path: 'C:\\' },
{ name: 'D盘根目录', path: 'D:\\' }
]
} else {
return [
{ name: '用户主目录', path: '~' },
{ name: '桌面', path: '~/Desktop' },
{ name: '文档', path: '~/Documents' },
{ name: '下载', path: '~/Downloads' },
{ name: '根目录', path: '/' }
]
}
})
// 状态
const filePath = ref('')
const fileContent = ref('')
const fileList = ref([])
const fileLoading = ref(false)
const pathHistory = ref([])
const fileContentHeight = ref(250)
const favoriteFiles = ref([])
// 格式化文件大小
const formatBytes = (bytes) => {
if (!bytes) return '0 B'
const unit = 1024
if (bytes < unit) return bytes + ' B'
const exp = Math.floor(Math.log(bytes) / Math.log(unit))
return (bytes / Math.pow(unit, exp)).toFixed(2) + ' ' + 'KMGTPE'[exp - 1] + 'B'
}
// 列出目录
const listDirectory = async () => {
if (!filePath.value) {
Message.error('请输入目录路径')
return
}
addToHistory(filePath.value)
fileLoading.value = true
try {
fileList.value = await listDir(filePath.value)
} catch (error) {
console.error('列出目录失败:', error)
Message.error('列出目录失败: ' + (error.message || error))
} finally {
fileLoading.value = false
}
}
// 路径选择
const onPathSelect = (value) => {
filePath.value = value
listDirectory()
}
// 打开路径
const openPath = (path) => {
filePath.value = path
listDirectory()
}
// 浏览目录
const browseDirectory = () => {
const path = prompt('请输入目录路径(例如: C:\\Users 或 /home/user')
if (path) {
filePath.value = path
listDirectory()
}
}
// 选择文件
const selectFile = (path) => {
filePath.value = path
addToHistory(path)
}
// 读取文件
const readFile = async () => {
if (!filePath.value) {
Message.error('请输入文件路径')
return
}
addToHistory(filePath.value)
fileLoading.value = true
try {
fileContent.value = await readFileApi(filePath.value)
Message.success('文件读取成功')
} catch (error) {
console.error('读取文件失败:', error)
Message.error('读取文件失败: ' + (error.message || error))
} finally {
fileLoading.value = false
}
}
// 写入文件
const writeFile = async () => {
if (!filePath.value) {
Message.error('请输入文件路径')
return
}
fileLoading.value = true
try {
await writeFileApi(filePath.value, fileContent.value)
Message.success('文件保存成功')
} catch (error) {
console.error('写入文件失败:', error)
Message.error('写入文件失败: ' + (error.message || error))
} finally {
fileLoading.value = false
}
}
// 删除文件
const deleteFile = async () => {
if (!filePath.value) {
Message.error('请输入文件路径')
return
}
Modal.confirm({
title: '确认删除',
content: `确定要删除 ${filePath.value} 吗?此操作不可恢复!`,
onOk: async () => {
fileLoading.value = true
try {
await deletePath(filePath.value)
Message.success('删除成功')
// 清空状态
filePath.value = ''
fileContent.value = ''
fileList.value = []
// 重新列出目录
if (pathHistory.value.length > 0) {
filePath.value = pathHistory.value[0]
listDirectory()
}
} catch (error) {
console.error('删除失败:', error)
Message.error('删除失败: ' + (error.message || error))
} finally {
fileLoading.value = false
}
}
})
}
// 清空内容
const clearContent = () => {
fileContent.value = ''
Message.info('内容已清空')
}
// 拖拽调整高度
const startResize = (e) => {
const startY = e.clientY
const startHeight = fileContentHeight.value
const onMouseMove = (moveEvent) => {
const deltaY = moveEvent.clientY - startY
const newHeight = startHeight + deltaY
if (newHeight >= 150 && newHeight <= 800) {
fileContentHeight.value = newHeight
}
}
const onMouseUp = () => {
document.removeEventListener('mousemove', onMouseMove)
document.removeEventListener('mouseup', onMouseUp)
saveToStorage(STORAGE_KEYS.FILE_CONTENT_HEIGHT, fileContentHeight.value.toString())
}
document.addEventListener('mousemove', onMouseMove)
document.addEventListener('mouseup', onMouseUp)
}
// 历史记录
const addToHistory = (path) => {
if (!path || path.trim() === '') return
const index = pathHistory.value.indexOf(path)
if (index > -1) {
pathHistory.value.splice(index, 1)
}
pathHistory.value.unshift(path)
if (pathHistory.value.length > 20) {
pathHistory.value = pathHistory.value.slice(0, 20)
}
saveToStorage(STORAGE_KEYS.PATH_HISTORY, pathHistory.value)
}
// 收藏功能
const isFavorite = (path) => {
return favoriteFiles.value.some(fav => fav.path === path)
}
const toggleFavorite = (item) => {
const index = favoriteFiles.value.findIndex(fav => fav.path === item.path)
if (index > -1) {
favoriteFiles.value.splice(index, 1)
Message.info('已取消收藏: ' + item.name)
} else {
favoriteFiles.value.push({
path: item.path,
name: item.name,
is_dir: item.is_dir
})
Message.success('已收藏: ' + item.name)
}
saveToStorage(STORAGE_KEYS.FAVORITE_FILES, favoriteFiles.value)
}
const removeFavorite = (path) => {
const index = favoriteFiles.value.findIndex(fav => fav.path === path)
if (index > -1) {
const name = favoriteFiles.value[index].name
favoriteFiles.value.splice(index, 1)
saveToStorage(STORAGE_KEYS.FAVORITE_FILES, favoriteFiles.value)
Message.info('已取消收藏: ' + name)
}
}
const openFavoriteFile = (path) => {
filePath.value = path
addToHistory(path)
const fav = favoriteFiles.value.find(f => f.path === path)
if (fav && fav.is_dir) {
listDirectory()
} else {
readFile()
}
}
// localStorage 操作
const loadFromStorage = () => {
try {
const savedPath = localStorage.getItem(STORAGE_KEYS.FILE_PATH)
const savedFileList = localStorage.getItem(STORAGE_KEYS.FILE_LIST)
const savedFileContent = localStorage.getItem(STORAGE_KEYS.FILE_CONTENT)
const savedHistory = localStorage.getItem(STORAGE_KEYS.PATH_HISTORY)
const savedHeight = localStorage.getItem(STORAGE_KEYS.FILE_CONTENT_HEIGHT)
const savedFavorites = localStorage.getItem(STORAGE_KEYS.FAVORITE_FILES)
if (savedPath) filePath.value = savedPath
if (savedFileList) fileList.value = JSON.parse(savedFileList)
if (savedFileContent) fileContent.value = savedFileContent
if (savedHistory) pathHistory.value = JSON.parse(savedHistory)
if (savedFavorites) favoriteFiles.value = JSON.parse(savedFavorites)
if (savedHeight) {
const height = parseInt(savedHeight)
if (!isNaN(height) && height >= 150 && height <= 800) {
fileContentHeight.value = height
}
}
} catch (error) {
console.error('从 localStorage 加载数据失败:', error)
}
}
const saveToStorage = (key, value) => {
try {
if (typeof value === 'string') {
localStorage.setItem(key, value)
} else {
localStorage.setItem(key, JSON.stringify(value))
}
} catch (error) {
console.error('保存到 localStorage 失败:', error)
}
}
// 监听数据变化自动保存
watch(filePath, (newPath) => {
saveToStorage(STORAGE_KEYS.FILE_PATH, newPath)
})
watch(fileContent, (newContent) => {
saveToStorage(STORAGE_KEYS.FILE_CONTENT, newContent)
})
watch(fileList, (newList) => {
saveToStorage(STORAGE_KEYS.FILE_LIST, newList)
}, { deep: true })
onMounted(() => {
loadFromStorage()
})
</script>
<style scoped>
.file-system {
padding: 16px;
}
.file-item {
padding: 8px 0;
}
.file-item:hover {
background: var(--color-fill-2);
border-radius: 4px;
}
.file-name {
font-weight: 500;
}
.file-content-wrapper {
position: relative;
overflow: hidden;
transition: height 0.1s ease;
border: 1px solid var(--color-border-2);
border-radius: 4px;
}
.file-content-textarea {
width: 100%;
height: 100%;
resize: none;
border: none;
}
.resize-handle {
height: 8px;
cursor: ns-resize;
display: flex;
align-items: center;
justify-content: center;
background: var(--color-fill-2);
border-radius: 4px;
margin: 4px 0;
transition: background 0.2s;
}
.resize-handle:hover {
background: var(--color-fill-3);
}
.resize-handle-bar {
width: 40px;
height: 3px;
background: var(--color-border-3);
border-radius: 2px;
}
.resize-handle:hover .resize-handle-bar {
background: rgb(var(--primary-6));
}
</style>

View File

@@ -0,0 +1,427 @@
<template>
<div class="update-panel">
<a-card title="版本更新">
<a-space direction="vertical" style="width: 100%" :size="16">
<!-- 当前版本信息 -->
<a-descriptions :column="3" bordered>
<a-descriptions-item label="当前版本">{{ currentVersion }}</a-descriptions-item>
<a-descriptions-item label="最后检查">{{ lastCheckTime }}</a-descriptions-item>
<a-descriptions-item label="自动检查">
<a-tag :color="config.auto_check_enabled ? 'green' : 'gray'">
{{ config.auto_check_enabled ? '已开启' : '已关闭' }}
</a-tag>
</a-descriptions-item>
</a-descriptions>
<!-- 检查更新 -->
<a-space>
<a-button type="primary" @click="handleCheckUpdate" :loading="checking">
<template #icon>
<icon-search />
</template>
检查更新
</a-button>
<a-button @click="showConfig = true">
<template #icon>
<icon-settings />
</template>
更新设置
</a-button>
</a-space>
<!-- 更新信息 -->
<a-alert
v-if="updateInfo"
:type="updateInfo.has_update ? 'info' : 'success'"
style="margin-top: 16px"
>
<template #title>
{{ updateInfo.has_update ? '发现新版本' : '已是最新版本' }}
</template>
<div v-if="updateInfo.has_update">
<p><strong>最新版本</strong>{{ updateInfo.latest_version }}</p>
<p><strong>当前版本</strong>{{ updateInfo.current_version }}</p>
<p v-if="updateInfo.changelog"><strong>更新日志</strong></p>
<div v-if="updateInfo.changelog" class="changelog">{{ updateInfo.changelog }}</div>
<p><strong>发布日期</strong>{{ updateInfo.release_date }}</p>
<a-space style="margin-top: 12px">
<a-button
type="primary"
@click="handleDownload"
:loading="downloading"
:disabled="!!downloadProgress"
>
<template #icon>
<icon-download />
</template>
{{ downloadProgress > 0 ? '下载中...' : '下载更新' }}
</a-button>
<a-button
v-if="downloadedFile"
type="primary"
status="success"
@click="handleInstall"
:loading="installing"
>
<template #icon>
<icon-check-circle />
</template>
立即安装
</a-button>
</a-space>
</div>
</a-alert>
<!-- 下载进度 -->
<div v-if="downloadProgress > 0 || downloading" class="download-progress">
<a-progress
:percent="downloadProgress"
:status="downloadStatus"
/>
<div class="progress-info">
<span>{{ formatFileSize(progressInfo.downloaded) }} / {{ formatFileSize(progressInfo.total) }}</span>
<span v-if="progressInfo.speed > 0">{{ formatSpeed(progressInfo.speed) }}</span>
</div>
</div>
<!-- 安装结果 -->
<a-alert
v-if="installResult"
:type="installResult.success ? 'success' : 'error'"
style="margin-top: 16px"
>
<template #title>{{ installResult.success ? '安装成功' : '安装失败' }}</template>
<p>{{ installResult.message }}</p>
</a-alert>
</a-space>
</a-card>
<!-- 更新设置对话框 -->
<a-modal
v-model:visible="showConfig"
title="更新设置"
@ok="handleSaveConfig"
@cancel="handleCancelConfig"
:confirm-loading="saving"
>
<a-form :model="config" layout="vertical">
<a-form-item label="自动检查更新" field="auto_check_enabled">
<a-switch v-model="config.auto_check_enabled" />
<span style="margin-left: 8px">{{ config.auto_check_enabled ? '开启' : '关闭' }}</span>
</a-form-item>
<a-form-item label="检查间隔(分钟)" field="check_interval_minutes">
<a-input-number
v-model="config.check_interval_minutes"
:min="1"
:max="1440"
style="width: 200px"
/>
</a-form-item>
<a-form-item label="更新检查地址" field="check_url">
<a-input
v-model="config.check_url"
placeholder="https://example.com/version.json"
/>
</a-form-item>
</a-form>
</a-modal>
</div>
</template>
<script setup>
import { ref, onMounted, onUnmounted } from 'vue'
import { Message, Modal } from '@arco-design/web-vue'
// 工具函数:解析事件数据
const parseEventData = (event) => {
try {
return typeof event === 'string' ? JSON.parse(event) : event
} catch {
return {}
}
}
// 状态
const currentVersion = ref('-')
const lastCheckTime = ref('-')
const checking = ref(false)
const downloading = ref(false)
const installing = ref(false)
const saving = ref(false)
const updateInfo = ref(null)
const downloadedFile = ref(null)
const installResult = ref(null)
const showConfig = ref(false)
const downloadProgress = ref(0)
const downloadStatus = ref('active')
// 配置
const config = ref({
auto_check_enabled: true,
check_interval_minutes: 60,
check_url: ''
})
// 下载进度信息
const progressInfo = ref({
progress: 0,
speed: 0,
downloaded: 0,
total: 0
})
// 格式化文件大小
const formatFileSize = (bytes) => {
if (!bytes || 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 Math.round(bytes / Math.pow(k, i) * 100) / 100 + ' ' + sizes[i]
}
// 格式化速度
const formatSpeed = (bytesPerSecond) => {
return formatFileSize(bytesPerSecond) + '/s'
}
// 加载当前版本
const loadCurrentVersion = async () => {
try {
const result = await window.go.main.App.GetCurrentVersion()
if (result.success) {
currentVersion.value = result.data?.version || '-'
}
} catch (error) {
console.error('获取版本失败:', error)
}
}
// 加载配置
const loadConfig = async () => {
try {
const result = await window.go.main.App.GetUpdateConfig()
if (result.success) {
config.value = {
auto_check_enabled: result.data.auto_check_enabled || false,
check_interval_minutes: result.data.check_interval_minutes || 60,
check_url: result.data.check_url || ''
}
lastCheckTime.value = result.data.last_check_time || '-'
}
} catch (error) {
console.error('加载配置失败:', error)
}
}
// 检查更新
const handleCheckUpdate = async () => {
checking.value = true
updateInfo.value = null
installResult.value = null
try {
const result = await window.go.main.App.CheckUpdate()
if (result.success) {
updateInfo.value = result.data
if (result.data.has_update) {
Message.success('发现新版本!')
} else {
Message.success('已是最新版本')
}
} else {
Message.error(result.message || '检查更新失败')
}
} catch (error) {
console.error('检查更新失败:', error)
Message.error('检查更新失败:' + (error.message || error))
} finally {
checking.value = false
// 刷新最后检查时间
await loadConfig()
}
}
// 下载更新
const handleDownload = async () => {
if (!updateInfo.value?.download_url) {
Message.warning('下载地址不存在')
return
}
downloading.value = true
downloadProgress.value = 0
downloadStatus.value = 'active'
progressInfo.value = { progress: 0, speed: 0, downloaded: 0, total: 0 }
installResult.value = null
try {
const result = await window.go.main.App.DownloadUpdate(updateInfo.value.download_url)
if (result.success) {
Message.success('下载请求已发送')
} else {
downloadStatus.value = 'exception'
Message.error(result.message || '下载启动失败')
downloading.value = false
}
} catch (error) {
console.error('下载失败:', error)
downloadStatus.value = 'exception'
Message.error('下载失败:' + (error.message || error))
downloading.value = false
}
}
// 安装更新
const handleInstall = async () => {
if (!downloadedFile.value) {
Message.warning('请先下载更新包')
return
}
// 确认对话框
Modal.confirm({
title: '确认安装',
content: '安装更新后应用将自动重启,是否继续?',
onOk: async () => {
installing.value = true
installResult.value = null
try {
const result = await window.go.main.App.InstallUpdate(
downloadedFile.value,
true // 自动重启
)
installResult.value = result.data || result
if (result.success || result.data?.success) {
Message.success({
content: '安装成功!应用将在几秒后重启...',
duration: 3000
})
} else {
Message.error(result.message || '安装失败')
}
} catch (error) {
console.error('安装失败:', error)
installResult.value = {
success: false,
message: '安装失败:' + (error.message || error)
}
Message.error('安装失败:' + (error.message || error))
} finally {
installing.value = false
}
}
})
}
// 保存配置
const handleSaveConfig = async () => {
saving.value = true
try {
const result = await window.go.main.App.SetUpdateConfig(
config.value.auto_check_enabled,
config.value.check_interval_minutes,
config.value.check_url
)
if (result.success) {
Message.success('配置保存成功')
showConfig.value = false
await loadConfig()
} else {
Message.error(result.message || '保存配置失败')
}
} catch (error) {
console.error('保存配置失败:', error)
Message.error('保存配置失败:' + (error.message || error))
} finally {
saving.value = false
}
}
// 取消配置
const handleCancelConfig = () => {
showConfig.value = false
// 恢复原始配置
loadConfig()
}
// 监听下载进度事件
const onDownloadProgress = (event) => {
const data = parseEventData(event)
progressInfo.value = {
progress: data.progress || 0,
speed: data.speed || 0,
downloaded: data.downloaded || 0,
total: data.total || 0
}
downloadProgress.value = Math.round(data.progress || 0)
}
// 监听下载完成事件
const onDownloadComplete = (event) => {
downloading.value = false
const data = parseEventData(event)
if (data.error) {
downloadStatus.value = 'exception'
Message.error('下载失败:' + data.error)
} else if (data.success) {
downloadStatus.value = 'success'
downloadProgress.value = 100
downloadedFile.value = data.file_path
Message.success('下载完成!文件已保存到:' + data.file_path)
}
}
onMounted(async () => {
await loadCurrentVersion()
await loadConfig()
// 监听下载进度事件
window.EventsOn('download-progress', onDownloadProgress)
window.EventsOn('download-complete', onDownloadComplete)
})
onUnmounted(() => {
// 取消事件监听
window.EventsOff('download-progress')
window.EventsOff('download-complete')
})
</script>
<style scoped>
.update-panel {
max-width: 900px;
margin: 0 auto;
}
.changelog {
background: var(--color-fill-2);
padding: 12px;
border-radius: 4px;
white-space: pre-wrap;
margin: 8px 0;
max-height: 200px;
overflow-y: auto;
}
.download-progress {
margin-top: 16px;
padding: 16px;
background: var(--color-fill-1);
border-radius: 4px;
}
.progress-info {
display: flex;
justify-content: space-between;
margin-top: 8px;
font-size: 12px;
color: var(--color-text-3);
}
</style>