Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5ef483c830 | |||
| cc50de0323 |
80
app.go
80
app.go
@@ -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
185
internal/api/update_api.go
Normal 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
429
internal/service/update.go
Normal 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
|
||||
}
|
||||
133
internal/service/update_config.go
Normal file
133
internal/service/update_config.go
Normal 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)
|
||||
}
|
||||
340
internal/service/update_download.go
Normal file
340
internal/service/update_download.go
Normal 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
161
internal/service/version.go
Normal 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
|
||||
}
|
||||
20
internal/storage/models/version.go
Normal file
20
internal/storage/models/version.go
Normal 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"
|
||||
}
|
||||
2
main.go
2
main.go
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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: '',
|
||||
|
||||
@@ -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>
|
||||
|
||||
559
web/src/components/FileSystem.vue
Normal file
559
web/src/components/FileSystem.vue
Normal 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>
|
||||
427
web/src/components/UpdatePanel.vue
Normal file
427
web/src/components/UpdatePanel.vue
Normal 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>
|
||||
Reference in New Issue
Block a user