diff --git a/server/internal/http/router.go b/server/internal/http/router.go index 33edfc7..ed32909 100644 --- a/server/internal/http/router.go +++ b/server/internal/http/router.go @@ -68,6 +68,7 @@ func NewRouter(deps RouterDependencies) *gin.Engine { system := api.Group("/system") system.Use(AuthMiddleware(deps.JWTManager)) system.GET("/info", systemHandler.Info) + system.GET("/update-check", systemHandler.CheckUpdate) storageTargets := api.Group("/storage-targets") storageTargets.Use(AuthMiddleware(deps.JWTManager)) diff --git a/server/internal/http/system_handler.go b/server/internal/http/system_handler.go index d3cc78f..206b99b 100644 --- a/server/internal/http/system_handler.go +++ b/server/internal/http/system_handler.go @@ -17,3 +17,17 @@ func NewSystemHandler(systemService *service.SystemService) *SystemHandler { func (h *SystemHandler) Info(c *gin.Context) { response.Success(c, h.systemService.GetInfo(c.Request.Context())) } + +func (h *SystemHandler) CheckUpdate(c *gin.Context) { + result, err := h.systemService.CheckUpdate(c.Request.Context()) + if err != nil { + // 即使检查失败也返回当前版本信息 + response.Success(c, gin.H{ + "currentVersion": result.CurrentVersion, + "hasUpdate": false, + "error": err.Error(), + }) + return + } + response.Success(c, result) +} diff --git a/server/internal/service/system_service.go b/server/internal/service/system_service.go index 7bd5949..17bc344 100644 --- a/server/internal/service/system_service.go +++ b/server/internal/service/system_service.go @@ -2,7 +2,12 @@ package service import ( "context" + "encoding/json" + "fmt" + "net/http" "path/filepath" + "runtime" + "strings" "syscall" "time" @@ -30,6 +35,82 @@ func NewSystemService(cfg config.Config, version string, startedAt time.Time) *S return &SystemService{cfg: cfg, version: version, startedAt: startedAt} } +// UpdateCheckResult 描述版本更新检查结果。 +type UpdateCheckResult struct { + CurrentVersion string `json:"currentVersion"` + LatestVersion string `json:"latestVersion"` + HasUpdate bool `json:"hasUpdate"` + ReleaseURL string `json:"releaseUrl,omitempty"` + ReleaseNotes string `json:"releaseNotes,omitempty"` + PublishedAt string `json:"publishedAt,omitempty"` + DownloadURL string `json:"downloadUrl,omitempty"` + DockerImage string `json:"dockerImage,omitempty"` +} + +const githubRepoAPI = "https://api.github.com/repos/Awuqing/BackupX/releases/latest" + +// CheckUpdate 从 GitHub Releases 检查是否有新版本。 +func (s *SystemService) CheckUpdate(ctx context.Context) (*UpdateCheckResult, error) { + result := &UpdateCheckResult{ + CurrentVersion: s.version, + DockerImage: "awuqing/backupx", + } + req, err := http.NewRequestWithContext(ctx, http.MethodGet, githubRepoAPI, nil) + if err != nil { + return result, fmt.Errorf("create request: %w", err) + } + req.Header.Set("Accept", "application/vnd.github.v3+json") + req.Header.Set("User-Agent", "BackupX/"+s.version) + + client := &http.Client{Timeout: 15 * time.Second} + resp, err := client.Do(req) + if err != nil { + return result, fmt.Errorf("fetch latest release: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != 200 { + return result, fmt.Errorf("github api returned %d", resp.StatusCode) + } + + var release struct { + TagName string `json:"tag_name"` + HTMLURL string `json:"html_url"` + Body string `json:"body"` + Published string `json:"published_at"` + Assets []struct { + Name string `json:"name"` + BrowserDownloadURL string `json:"browser_download_url"` + } `json:"assets"` + } + if err := json.NewDecoder(resp.Body).Decode(&release); err != nil { + return result, fmt.Errorf("decode release: %w", err) + } + + result.LatestVersion = release.TagName + result.ReleaseURL = release.HTMLURL + result.ReleaseNotes = release.Body + result.PublishedAt = release.Published + + // 比较版本号(去 v 前缀后字符串比较) + current := strings.TrimPrefix(s.version, "v") + latest := strings.TrimPrefix(release.TagName, "v") + result.HasUpdate = latest > current && current != "dev" + + // 匹配当前平台的下载链接 + goos := runtime.GOOS + goarch := runtime.GOARCH + suffix := fmt.Sprintf("%s-%s.tar.gz", goos, goarch) + for _, asset := range release.Assets { + if strings.HasSuffix(asset.Name, suffix) { + result.DownloadURL = asset.BrowserDownloadURL + break + } + } + + return result, nil +} + func (s *SystemService) GetInfo(_ context.Context) *SystemInfo { now := time.Now().UTC() info := &SystemInfo{ diff --git a/web/src/pages/settings/SettingsPage.tsx b/web/src/pages/settings/SettingsPage.tsx index f119b19..b3ccb63 100644 --- a/web/src/pages/settings/SettingsPage.tsx +++ b/web/src/pages/settings/SettingsPage.tsx @@ -1,88 +1,137 @@ -import { Card, Descriptions, Grid, PageHeader, Space, Typography } from '@arco-design/web-react' +import { Badge, Button, Card, Descriptions, Grid, Link, PageHeader, Space, Tag, Typography } from '@arco-design/web-react' import { useEffect, useState } from 'react' -import { fetchSystemInfo, type SystemInfo } from '../../services/system' +import { fetchSystemInfo, checkUpdate, type SystemInfo, type UpdateCheckResult } from '../../services/system' import { resolveErrorMessage } from '../../utils/error' import { formatDuration } from '../../utils/format' const { Row, Col } = Grid -const deploySteps = [ - '1. 构建前端:cd web && npm run build', - '2. 编译后端:cd server && go build -o backupx ./cmd/backupx', - '3. 部署静态资源与二进制,并按 deploy/ 目录提供的配置接入 Nginx 与 systemd', - '4. 首次启动后访问 Web 控制台,完成管理员初始化与存储目标配置', -] +function formatBytes(bytes: number | undefined): string { + if (!bytes || bytes <= 0) return '-' + const units = ['B', 'KB', 'MB', 'GB', 'TB'] + let i = 0 + let size = bytes + while (size >= 1024 && i < units.length - 1) { + size /= 1024 + i++ + } + return `${size.toFixed(1)} ${units[i]}` +} export function SettingsPage() { const [info, setInfo] = useState(null) const [loading, setLoading] = useState(true) const [error, setError] = useState('') + const [updateResult, setUpdateResult] = useState(null) + const [checking, setChecking] = useState(false) useEffect(() => { let active = true void (async () => { try { const result = await fetchSystemInfo() - if (active) { - setInfo(result) - setError('') - } + if (active) { setInfo(result); setError('') } } catch (loadError) { - if (active) { - setError(resolveErrorMessage(loadError, '加载系统设置失败')) - } + if (active) setError(resolveErrorMessage(loadError, '加载系统信息失败')) } finally { - if (active) { - setLoading(false) - } + if (active) setLoading(false) } })() - return () => { - active = false - } + return () => { active = false } }, []) + async function handleCheckUpdate() { + setChecking(true) + try { + const result = await checkUpdate() + setUpdateResult(result) + } catch (e) { + setUpdateResult({ currentVersion: info?.version || '-', latestVersion: '-', hasUpdate: false, error: resolveErrorMessage(e, '检查更新失败') }) + } finally { + setChecking(false) + } + } + return ( - + {error ? {error} : null} - + {info?.version ?? '-'} }, + { label: '运行模式', value: info?.mode === 'release' ? 生产 : {info?.mode ?? '-'} }, + { label: '运行时长', value: formatDuration(info?.uptimeSeconds) }, + { label: '启动时间', value: info?.startedAt ?? '-' }, + { label: '数据库路径', value: {info?.databasePath ?? '-'} }, + ]} /> - - - `deploy/nginx.conf`:静态资源托管与 `/api` 反向代理示例。 - `deploy/backupx.service`:systemd 服务单元,负责守护 API 进程。 - `deploy/install.sh`:一键安装示例脚本,用于创建目录、复制文件并启动服务。 - `README.md`:包含完整部署与使用文档。 - + + - -
{deploySteps.join('\n')}
-
+ {/* 更新检查结果 */} + {updateResult && ( + + {updateResult.error ? ( + {updateResult.error} + ) : updateResult.hasUpdate ? ( + + + + + 有新版本可用:{updateResult.latestVersion} + + (当前:{updateResult.currentVersion}) + + {updateResult.publishedAt && ( + 发布时间:{new Date(updateResult.publishedAt).toLocaleString()} + )} + {updateResult.releaseNotes && ( + + {updateResult.releaseNotes} + + )} + + {updateResult.downloadUrl && ( + + + + )} + {updateResult.releaseUrl && ( + + + + )} + + {updateResult.dockerImage && ( + + + {`docker pull ${updateResult.dockerImage}:${updateResult.latestVersion} && docker compose up -d`} + + + )} + + ) : ( + + + 当前已是最新版本 ({updateResult.currentVersion}) + + )} + + )} ) } diff --git a/web/src/services/system.ts b/web/src/services/system.ts index 6456321..c2eaa45 100644 --- a/web/src/services/system.ts +++ b/web/src/services/system.ts @@ -11,11 +11,28 @@ export interface SystemInfo { diskUsed: number } +export interface UpdateCheckResult { + currentVersion: string + latestVersion: string + hasUpdate: boolean + releaseUrl?: string + releaseNotes?: string + publishedAt?: string + downloadUrl?: string + dockerImage?: string + error?: string +} + export async function fetchSystemInfo() { const response = await http.get<{ code: string; message: string; data: SystemInfo }>('/system/info') return response.data.data } +export async function checkUpdate() { + const response = await http.get<{ code: string; message: string; data: UpdateCheckResult }>('/system/update-check') + return response.data.data +} + export async function fetchSettings() { const response = await http.get<{ code: string; message: string; data: Record }>('/settings') return response.data.data