diff --git a/README.md b/README.md index 330c4f5a..8dddd465 100644 --- a/README.md +++ b/README.md @@ -108,11 +108,12 @@ curl -fsSL https://raw.githubusercontent.com/Rain-kl/ATSFlare/main/scripts/insta --agent-token YOUR_AGENT_TOKEN ``` -说明: - -* `--server-url` 替换为实际控制面地址,例如 `http://192.168.1.10:3000` -* Linux 默认安装到 `/opt/atsflare-agent`,并创建 `atsflare-agent` systemd 服务 -* 重复执行相同命令可用于升级 Agent 到最新 Release +说明: + +* `--server-url` 替换为实际控制面地址,例如 `http://192.168.1.10:3000` +* Linux 默认安装到 `/opt/atsflare-agent`,并创建 `atsflare-agent` systemd 服务 +* 重复执行相同命令可用于升级 Agent 到最新 Release +* Root 用户可在管理端顶栏点击“版本”检查最新 Release,并在 Release 二进制部署场景下直接触发 Server 自升级 ## 部署说明 diff --git a/atsf_server/controller/update.go b/atsf_server/controller/update.go index 22621052..98cc26b3 100644 --- a/atsf_server/controller/update.go +++ b/atsf_server/controller/update.go @@ -1,27 +1,12 @@ package controller import ( - "encoding/json" - "fmt" + "atsflare/service" "net/http" - "time" "github.com/gin-gonic/gin" ) -const latestReleaseURL = "https://api.github.com/repos/Rain-kl/ATSFlare/releases/latest" - -var updateHTTPClient = &http.Client{ - Timeout: 10 * time.Second, -} - -type latestReleaseResponse struct { - TagName string `json:"tag_name"` - Body string `json:"body"` - HTMLURL string `json:"html_url"` - PublishedAt string `json:"published_at"` -} - // GetLatestRelease godoc // @Summary Get latest GitHub release // @Tags Update @@ -29,40 +14,11 @@ type latestReleaseResponse struct { // @Success 200 {object} map[string]interface{} // @Router /api/update/latest-release [get] func GetLatestRelease(c *gin.Context) { - req, err := http.NewRequestWithContext(c.Request.Context(), http.MethodGet, latestReleaseURL, nil) + release, err := service.GetLatestServerRelease(c.Request.Context()) if err != nil { c.JSON(http.StatusOK, gin.H{ "success": false, - "message": "创建更新请求失败", - }) - return - } - req.Header.Set("Accept", "application/vnd.github+json") - req.Header.Set("User-Agent", "ATSFlare-Server") - - resp, err := updateHTTPClient.Do(req) - if err != nil { - c.JSON(http.StatusOK, gin.H{ - "success": false, - "message": fmt.Sprintf("获取最新版本失败: %v", err), - }) - return - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - c.JSON(http.StatusOK, gin.H{ - "success": false, - "message": fmt.Sprintf("GitHub 返回异常状态: %s", resp.Status), - }) - return - } - - var release latestReleaseResponse - if err = json.NewDecoder(resp.Body).Decode(&release); err != nil { - c.JSON(http.StatusOK, gin.H{ - "success": false, - "message": "解析最新版本信息失败", + "message": err.Error(), }) return } @@ -74,10 +30,25 @@ func GetLatestRelease(c *gin.Context) { }) } -func UpdateHTTPClientForTest() *http.Client { - return updateHTTPClient -} +// UpgradeServer godoc +// @Summary Upgrade server binary from latest GitHub release +// @Tags Update +// @Produce json +// @Success 200 {object} map[string]interface{} +// @Router /api/update/upgrade [post] +func UpgradeServer(c *gin.Context) { + release, err := service.ScheduleServerUpgrade() + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": err.Error(), + }) + return + } -func SetUpdateHTTPClientForTest(client *http.Client) { - updateHTTPClient = client + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "服务升级任务已启动,下载完成后将自动重启。", + "data": release, + }) } diff --git a/atsf_server/router/api-router.go b/atsf_server/router/api-router.go index 8e862c75..4310143e 100644 --- a/atsf_server/router/api-router.go +++ b/atsf_server/router/api-router.go @@ -59,6 +59,7 @@ func SetApiRouter(router *gin.Engine) { updateRoute.Use(middleware.RootAuth(), middleware.NoTokenAuth()) { updateRoute.GET("/latest-release", controller.GetLatestRelease) + updateRoute.POST("/upgrade", controller.UpgradeServer) } fileRoute := apiRouter.Group("/file") fileRoute.Use(middleware.AdminAuth()) diff --git a/atsf_server/router/update_test.go b/atsf_server/router/update_test.go index d59c16bb..4fa10bf9 100644 --- a/atsf_server/router/update_test.go +++ b/atsf_server/router/update_test.go @@ -2,8 +2,8 @@ package router_test import ( "atsflare/common" - "atsflare/controller" "atsflare/router" + "atsflare/service" "bytes" "encoding/json" "io" @@ -28,8 +28,8 @@ func TestLatestReleaseProxy(t *testing.T) { common.RedisEnabled = false setupTestDB(t) - originalClient := controller.UpdateHTTPClientForTest() - controller.SetUpdateHTTPClientForTest(&http.Client{ + originalClient := service.UpdateHTTPClientForTest() + service.SetUpdateHTTPClientForTest(&http.Client{ Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { if req.URL.String() != "https://api.github.com/repos/Rain-kl/ATSFlare/releases/latest" { t.Fatalf("unexpected request url: %s", req.URL.String()) @@ -53,7 +53,7 @@ func TestLatestReleaseProxy(t *testing.T) { }), }) t.Cleanup(func() { - controller.SetUpdateHTTPClientForTest(originalClient) + service.SetUpdateHTTPClientForTest(originalClient) }) engine := gin.New() @@ -101,4 +101,7 @@ func TestLatestReleaseProxy(t *testing.T) { if data["tag_name"] != "v1.2.3" { t.Fatalf("unexpected tag_name: %#v", data["tag_name"]) } + if data["current_version"] != common.Version { + t.Fatalf("unexpected current_version: %#v", data["current_version"]) + } } diff --git a/atsf_server/service/update.go b/atsf_server/service/update.go new file mode 100644 index 00000000..f9758e36 --- /dev/null +++ b/atsf_server/service/update.go @@ -0,0 +1,326 @@ +package service + +import ( + "atsflare/common" + "context" + "encoding/json" + "fmt" + "io" + "log" + "net/http" + "os" + "path/filepath" + "runtime" + "strconv" + "strings" + "sync" + "time" +) + +const latestReleaseURL = "https://api.github.com/repos/Rain-kl/ATSFlare/releases/latest" + +var updateHTTPClient = &http.Client{ + Timeout: 30 * time.Second, +} + +var serverUpgradeState struct { + sync.Mutex + inProgress bool +} + +type LatestServerRelease struct { + TagName string `json:"tag_name"` + Body string `json:"body"` + HTMLURL string `json:"html_url"` + PublishedAt string `json:"published_at"` + CurrentVersion string `json:"current_version"` + HasUpdate bool `json:"has_update"` + UpgradeSupported bool `json:"upgrade_supported"` + InProgress bool `json:"in_progress"` +} + +type githubReleaseResponse struct { + TagName string `json:"tag_name"` + Body string `json:"body"` + HTMLURL string `json:"html_url"` + PublishedAt string `json:"published_at"` + Assets []githubAsset `json:"assets"` +} + +type githubAsset struct { + Name string `json:"name"` + BrowserDownloadURL string `json:"browser_download_url"` +} + +type preparedServerUpgrade struct { + release *LatestServerRelease + downloadURL string + execPath string +} + +func GetLatestServerRelease(ctx context.Context) (*LatestServerRelease, error) { + release, err := fetchLatestRelease(ctx) + if err != nil { + return nil, err + } + return buildLatestServerReleaseView(release), nil +} + +func ScheduleServerUpgrade() (*LatestServerRelease, error) { + serverUpgradeState.Lock() + if serverUpgradeState.inProgress { + serverUpgradeState.Unlock() + return nil, fmt.Errorf("服务升级已在执行中,请稍后再试") + } + + prepared, err := prepareServerUpgrade(context.Background()) + if err != nil { + serverUpgradeState.Unlock() + return nil, err + } + + serverUpgradeState.inProgress = true + serverUpgradeState.Unlock() + + prepared.release.InProgress = true + + go func(task *preparedServerUpgrade) { + time.Sleep(500 * time.Millisecond) + if err := executeServerUpgrade(task); err != nil { + log.Printf("server self-update failed: %v", err) + serverUpgradeState.Lock() + serverUpgradeState.inProgress = false + serverUpgradeState.Unlock() + } + }(prepared) + + return prepared.release, nil +} + +func fetchLatestRelease(ctx context.Context) (*githubReleaseResponse, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, latestReleaseURL, nil) + if err != nil { + return nil, fmt.Errorf("创建更新请求失败") + } + req.Header.Set("Accept", "application/vnd.github+json") + req.Header.Set("User-Agent", "ATSFlare-Server") + + resp, err := updateHTTPClient.Do(req) + if err != nil { + return nil, fmt.Errorf("获取最新版本失败: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("GitHub 返回异常状态: %s", resp.Status) + } + + var release githubReleaseResponse + if err = json.NewDecoder(resp.Body).Decode(&release); err != nil { + return nil, fmt.Errorf("解析最新版本信息失败") + } + return &release, nil +} + +func buildLatestServerReleaseView(release *githubReleaseResponse) *LatestServerRelease { + currentVersion := strings.TrimSpace(common.Version) + hasUpdate := false + if release != nil { + hasUpdate = isVersionNewer(currentVersion, release.TagName) + } + + serverUpgradeState.Lock() + inProgress := serverUpgradeState.inProgress + serverUpgradeState.Unlock() + + view := &LatestServerRelease{ + CurrentVersion: currentVersion, + HasUpdate: hasUpdate, + UpgradeSupported: runtime.GOOS != "windows", + InProgress: inProgress, + } + if release != nil { + view.TagName = release.TagName + view.Body = release.Body + view.HTMLURL = release.HTMLURL + view.PublishedAt = release.PublishedAt + } + return view +} + +func prepareServerUpgrade(ctx context.Context) (*preparedServerUpgrade, error) { + release, err := fetchLatestRelease(ctx) + if err != nil { + return nil, err + } + + view := buildLatestServerReleaseView(release) + if !view.HasUpdate { + return nil, fmt.Errorf("当前已是最新版本") + } + if !view.UpgradeSupported { + return nil, fmt.Errorf("当前平台暂不支持自动升级") + } + + assetName := serverAssetName(runtime.GOOS, runtime.GOARCH) + var downloadURL string + for _, asset := range release.Assets { + if asset.Name == assetName { + downloadURL = asset.BrowserDownloadURL + break + } + } + if downloadURL == "" { + return nil, fmt.Errorf("最新版本缺少当前平台的服务端二进制: %s", assetName) + } + + execPath, err := os.Executable() + if err != nil { + return nil, fmt.Errorf("获取当前服务程序路径失败: %v", err) + } + if err = verifyExecutableDirectoryWritable(execPath); err != nil { + return nil, err + } + + return &preparedServerUpgrade{ + release: view, + downloadURL: downloadURL, + execPath: execPath, + }, nil +} + +func verifyExecutableDirectoryWritable(execPath string) error { + dir := filepath.Dir(execPath) + tempFile, err := os.CreateTemp(dir, "atsflare-server-upgrade-check-*") + if err != nil { + return fmt.Errorf("当前服务二进制目录不可写,无法升级: %v", err) + } + tempPath := tempFile.Name() + if closeErr := tempFile.Close(); closeErr != nil { + _ = os.Remove(tempPath) + return fmt.Errorf("校验服务升级目录失败: %v", closeErr) + } + if err = os.Remove(tempPath); err != nil { + return fmt.Errorf("清理升级校验文件失败: %v", err) + } + return nil +} + +func executeServerUpgrade(task *preparedServerUpgrade) error { + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + defer cancel() + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, task.downloadURL, nil) + if err != nil { + return err + } + req.Header.Set("Accept", "application/octet-stream") + req.Header.Set("User-Agent", "ATSFlare-Server") + + resp, err := updateHTTPClient.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("下载服务端升级包失败: %s", resp.Status) + } + + tmpPath := task.execPath + ".update" + tmpFile, err := os.OpenFile(tmpPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o755) + if err != nil { + return err + } + if _, err = io.Copy(tmpFile, resp.Body); err != nil { + _ = tmpFile.Close() + _ = os.Remove(tmpPath) + return err + } + if err = tmpFile.Close(); err != nil { + _ = os.Remove(tmpPath) + return err + } + + common.SysLog("server self-update starting: from=" + strings.TrimSpace(common.Version) + " to=" + task.release.TagName) + return replaceAndRestartServer(task.execPath, tmpPath) +} + +func serverAssetName(goos string, goarch string) string { + name := fmt.Sprintf("atsflare-server-%s-%s", goos, goarch) + if goos == "windows" { + return name + ".exe" + } + return name +} + +func isVersionNewer(current string, latest string) bool { + currentParts := parseVersionParts(current) + latestParts := parseVersionParts(latest) + maxLen := len(currentParts) + if len(latestParts) > maxLen { + maxLen = len(latestParts) + } + + for i := 0; i < maxLen; i++ { + currentPart := 0 + latestPart := 0 + if i < len(currentParts) { + currentPart = currentParts[i] + } + if i < len(latestParts) { + latestPart = latestParts[i] + } + if latestPart > currentPart { + return true + } + if latestPart < currentPart { + return false + } + } + return false +} + +func parseVersionParts(version string) []int { + normalized := strings.TrimSpace(strings.TrimPrefix(version, "v")) + if normalized == "" || normalized == "dev" { + return nil + } + + segments := strings.Split(normalized, ".") + parts := make([]int, 0, len(segments)) + for _, segment := range segments { + segment = strings.TrimSpace(segment) + if segment == "" { + parts = append(parts, 0) + continue + } + + numeric := strings.Builder{} + for _, r := range segment { + if r < '0' || r > '9' { + break + } + numeric.WriteRune(r) + } + if numeric.Len() == 0 { + parts = append(parts, 0) + continue + } + value, err := strconv.Atoi(numeric.String()) + if err != nil { + parts = append(parts, 0) + continue + } + parts = append(parts, value) + } + return parts +} + +func UpdateHTTPClientForTest() *http.Client { + return updateHTTPClient +} + +func SetUpdateHTTPClientForTest(client *http.Client) { + updateHTTPClient = client +} diff --git a/atsf_server/service/update_restart_unix.go b/atsf_server/service/update_restart_unix.go new file mode 100644 index 00000000..96093a6a --- /dev/null +++ b/atsf_server/service/update_restart_unix.go @@ -0,0 +1,27 @@ +//go:build !windows + +package service + +import ( + "fmt" + "os" + "syscall" +) + +func replaceAndRestartServer(execPath string, tmpPath string) error { + backupPath := execPath + ".bak" + _ = os.Remove(backupPath) + if err := os.Rename(execPath, backupPath); err != nil { + _ = os.Remove(tmpPath) + return fmt.Errorf("备份当前服务端二进制失败: %w", err) + } + if err := os.Rename(tmpPath, execPath); err != nil { + _ = os.Rename(backupPath, execPath) + return fmt.Errorf("替换服务端二进制失败: %w", err) + } + _ = os.Remove(backupPath) + if err := syscall.Exec(execPath, os.Args, os.Environ()); err != nil { + return fmt.Errorf("重启服务失败: %w", err) + } + return fmt.Errorf("unreachable after exec") +} diff --git a/atsf_server/service/update_restart_windows.go b/atsf_server/service/update_restart_windows.go new file mode 100644 index 00000000..34756c4d --- /dev/null +++ b/atsf_server/service/update_restart_windows.go @@ -0,0 +1,55 @@ +//go:build windows + +package service + +import ( + "fmt" + "os" + "os/exec" + "strings" +) + +func replaceAndRestartServer(execPath string, tmpPath string) error { + backupPath := execPath + ".bak" + scriptPath := execPath + ".update.cmd" + script := fmt.Sprintf(`@echo off +setlocal +:waitloop +move /Y "%s" "%s" >nul 2>nul +if errorlevel 1 ( + ping 127.0.0.1 -n 2 >nul + goto waitloop +) +move /Y "%s" "%s" >nul 2>nul +if errorlevel 1 exit /b 1 +start "" %s +del /Q "%s" >nul 2>nul +del /Q "%%~f0" >nul 2>nul +`, execPath, backupPath, tmpPath, execPath, buildWindowsCommandLine(execPath, os.Args[1:]), backupPath) + if err := os.WriteFile(scriptPath, []byte(script), 0o700); err != nil { + _ = os.Remove(tmpPath) + return fmt.Errorf("写入升级重启脚本失败: %w", err) + } + + cmd := exec.Command("cmd", "/C", "start", "", scriptPath) + if err := cmd.Start(); err != nil { + _ = os.Remove(scriptPath) + _ = os.Remove(tmpPath) + return fmt.Errorf("调度升级重启失败: %w", err) + } + + os.Exit(0) + return nil +} + +func buildWindowsCommandLine(execPath string, args []string) string { + parts := []string{quoteWindowsArg(execPath)} + for _, arg := range args { + parts = append(parts, quoteWindowsArg(arg)) + } + return strings.Join(parts, " ") +} + +func quoteWindowsArg(value string) string { + return `"` + strings.ReplaceAll(value, `"`, `""`) + `"` +} diff --git a/atsf_server/service/update_test.go b/atsf_server/service/update_test.go new file mode 100644 index 00000000..cc0e46aa --- /dev/null +++ b/atsf_server/service/update_test.go @@ -0,0 +1,65 @@ +package service + +import ( + "atsflare/common" + "testing" +) + +func TestIsVersionNewer(t *testing.T) { + testCases := []struct { + name string + current string + latest string + expected bool + }{ + {name: "newer patch", current: "v1.2.3", latest: "v1.2.4", expected: true}, + {name: "same version", current: "v1.2.3", latest: "v1.2.3", expected: false}, + {name: "older remote", current: "v1.3.0", latest: "v1.2.9", expected: false}, + {name: "double digit segment", current: "v1.9.9", latest: "v1.10.0", expected: true}, + {name: "dev build", current: "dev", latest: "v0.4.0", expected: true}, + } + + for _, testCase := range testCases { + t.Run(testCase.name, func(t *testing.T) { + actual := isVersionNewer(testCase.current, testCase.latest) + if actual != testCase.expected { + t.Fatalf("unexpected compare result: current=%s latest=%s actual=%v expected=%v", testCase.current, testCase.latest, actual, testCase.expected) + } + }) + } +} + +func TestBuildLatestServerReleaseView(t *testing.T) { + originalVersion := common.Version + common.Version = "v0.4.0" + t.Cleanup(func() { + common.Version = originalVersion + serverUpgradeState.Lock() + serverUpgradeState.inProgress = false + serverUpgradeState.Unlock() + }) + + serverUpgradeState.Lock() + serverUpgradeState.inProgress = true + serverUpgradeState.Unlock() + + view := buildLatestServerReleaseView(&githubReleaseResponse{ + TagName: "v0.5.0", + Body: "release notes", + HTMLURL: "https://github.com/Rain-kl/ATSFlare/releases/tag/v0.5.0", + PublishedAt: "2026-03-11T00:00:00Z", + }) + + if view.CurrentVersion != "v0.4.0" { + t.Fatalf("unexpected current version: %s", view.CurrentVersion) + } + if !view.HasUpdate { + t.Fatal("expected has_update to be true") + } + if !view.InProgress { + t.Fatal("expected in_progress to reflect upgrade state") + } + if view.TagName != "v0.5.0" { + t.Fatalf("unexpected tag name: %s", view.TagName) + } +} diff --git a/atsf_server/web/components/layout/dashboard-topbar.tsx b/atsf_server/web/components/layout/dashboard-topbar.tsx index 70a3695e..afb306ff 100644 --- a/atsf_server/web/components/layout/dashboard-topbar.tsx +++ b/atsf_server/web/components/layout/dashboard-topbar.tsx @@ -1,10 +1,14 @@ 'use client'; +import { useMutation, useQuery } from '@tanstack/react-query'; import { useEffect, useRef, useState } from 'react'; import { useRouter } from 'next/navigation'; import { useAuth } from '@/components/providers/auth-provider'; import { ThemeToggle } from '@/components/ui/theme-toggle'; +import { getPublicStatus } from '@/features/auth/api/public'; +import { getLatestRelease, upgradeServer } from '@/features/update/api/update'; +import { VersionUpgradeModal } from '@/features/update/components/version-upgrade-modal'; import { publicEnv } from '@/lib/env/public-env'; import { useAppShellStore } from '@/store/app-shell'; @@ -12,11 +16,45 @@ export function DashboardTopbar() { const router = useRouter(); const { logout, user } = useAuth(); const toggleSidebar = useAppShellStore((state) => state.toggleSidebar); - const isMobileSidebarOpen = useAppShellStore((state) => state.isMobileSidebarOpen); - const setMobileSidebarOpen = useAppShellStore((state) => state.setMobileSidebarOpen); + const isMobileSidebarOpen = useAppShellStore( + (state) => state.isMobileSidebarOpen, + ); + const setMobileSidebarOpen = useAppShellStore( + (state) => state.setMobileSidebarOpen, + ); const [isLoggingOut, setIsLoggingOut] = useState(false); const [isUserMenuOpen, setIsUserMenuOpen] = useState(false); + const [isVersionModalOpen, setIsVersionModalOpen] = useState(false); + const [versionFeedback, setVersionFeedback] = useState(null); const menuRef = useRef(null); + const isRoot = (user?.role ?? 0) >= 100; + + const publicStatusQuery = useQuery({ + queryKey: ['public-status'], + queryFn: getPublicStatus, + }); + + const latestReleaseQuery = useQuery({ + queryKey: ['update', 'latest-release'], + queryFn: getLatestRelease, + enabled: isRoot, + refetchInterval: 60 * 60 * 1000, + }); + + const upgradeMutation = useMutation({ + mutationFn: upgradeServer, + onSuccess: (release) => { + setVersionFeedback( + `服务升级任务已启动,目标版本 ${release.tag_name}。页面可能短暂不可用。`, + ); + void latestReleaseQuery.refetch(); + }, + onError: (error) => { + setVersionFeedback( + error instanceof Error ? error.message : '升级失败,请稍后重试。', + ); + }, + }); useEffect(() => { if (!isUserMenuOpen) { @@ -60,60 +98,127 @@ export function DashboardTopbar() { toggleSidebar(); }; + const handleOpenVersionModal = () => { + setVersionFeedback(null); + setIsVersionModalOpen(true); + if (isRoot) { + void latestReleaseQuery.refetch(); + } + }; + + const handleUpgrade = () => { + setVersionFeedback(null); + upgradeMutation.mutate(); + }; + + const release = latestReleaseQuery.data; + const hasUpdate = Boolean(isRoot && release?.has_update); + const currentVersion = publicStatusQuery.data?.version || 'unknown'; + const versionLabel = hasUpdate + ? `版本 ${publicEnv.appVersion} · 可升级` + : `版本 ${publicEnv.appVersion}`; + const versionButtonClassName = hasUpdate + ? 'border-[var(--status-warning-border)] bg-[var(--status-warning-soft)] text-[var(--status-warning-foreground)]' + : 'border-[var(--border-default)]'; + const versionErrorMessage = + versionFeedback || + (latestReleaseQuery.isError + ? latestReleaseQuery.error instanceof Error + ? latestReleaseQuery.error.message + : '版本检查失败,请稍后重试。' + : undefined); + return ( -
-
- + <> +
+
+ -
- - 版本 {publicEnv.appVersion} - - -
+
+ +
+ - {isUserMenuOpen ? ( -
-
-

- {user?.display_name || user?.username || '用户'} -

- {user?.username ? ( -

@{user.username}

- ) : null} + {isUserMenuOpen ? ( +
+
+

+ {user?.display_name || user?.username || '用户'} +

+ {user?.username ? ( +

+ @{user.username} +

+ ) : null} +
+
- -
- ) : null} + ) : null} +
-
-
+
+ + setIsVersionModalOpen(false)} + currentVersion={currentVersion} + frontendVersion={publicEnv.appVersion} + startTime={publicStatusQuery.data?.start_time} + release={release} + isLoading={latestReleaseQuery.isLoading && !release && isRoot} + errorMessage={versionErrorMessage} + canUpgrade={isRoot} + isChecking={latestReleaseQuery.isFetching} + isUpgrading={upgradeMutation.isPending} + onRefresh={() => { + setVersionFeedback(null); + if (isRoot) { + void latestReleaseQuery.refetch(); + } + }} + onUpgrade={handleUpgrade} + /> + ); } diff --git a/atsf_server/web/components/layout/public-shell.tsx b/atsf_server/web/components/layout/public-shell.tsx index 3ee87950..4d228458 100644 --- a/atsf_server/web/components/layout/public-shell.tsx +++ b/atsf_server/web/components/layout/public-shell.tsx @@ -14,14 +14,9 @@ export function PublicShell({ children }: PublicShellProps) {
-

ATSFlare

-

欢迎使用 ATSFlare 管理端

-

- 当前已接入认证入口、核心业务模块以及阶段 4 的设置、用户、文件与关于页迁移。 -

+

ATSFlare

- {publicEnv.appVersion} @@ -33,10 +28,10 @@ export function PublicShell({ children }: PublicShellProps) {
- 返回新版总览 + 返回 - 查看关于页 + 关于
diff --git a/atsf_server/web/features/about/components/about-page.tsx b/atsf_server/web/features/about/components/about-page.tsx index bc15680b..dc6d3ca2 100644 --- a/atsf_server/web/features/about/components/about-page.tsx +++ b/atsf_server/web/features/about/components/about-page.tsx @@ -94,9 +94,6 @@ export function AboutPage() { )}
- - 返回新版总览 - 前往登录 diff --git a/atsf_server/web/features/auth/components/login-form.tsx b/atsf_server/web/features/auth/components/login-form.tsx index 6511fb08..239a8f80 100644 --- a/atsf_server/web/features/auth/components/login-form.tsx +++ b/atsf_server/web/features/auth/components/login-form.tsx @@ -79,7 +79,7 @@ export function LoginForm() { return ( - +
@@ -115,11 +115,11 @@ export function LoginForm() {
- 忘记密码?点击重置 + 忘记密码? · - 没有账户?点击注册 + 注册
diff --git a/atsf_server/web/features/nodes/components/nodes-page.tsx b/atsf_server/web/features/nodes/components/nodes-page.tsx index 4c015174..3a282303 100644 --- a/atsf_server/web/features/nodes/components/nodes-page.tsx +++ b/atsf_server/web/features/nodes/components/nodes-page.tsx @@ -1,588 +1,655 @@ -'use client'; - -import Link from 'next/link'; -import { zodResolver } from '@hookform/resolvers/zod'; -import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; -import { useEffect, useMemo, useState } from 'react'; -import { useForm, useWatch } from 'react-hook-form'; -import { z } from 'zod'; - -import { EmptyState } from '@/components/feedback/empty-state'; -import { ErrorState } from '@/components/feedback/error-state'; -import { InlineMessage } from '@/components/feedback/inline-message'; -import { LoadingState } from '@/components/feedback/loading-state'; -import { PageHeader } from '@/components/layout/page-header'; -import { AppModal } from '@/components/ui/app-modal'; -import { AppCard } from '@/components/ui/app-card'; -import { StatusBadge } from '@/components/ui/status-badge'; -import { - createNode, - deleteNode, - getNodeBootstrapToken, - getNodes, - requestNodeAgentUpdate, - rotateNodeBootstrapToken, - updateNode, -} from '@/features/nodes/api/nodes'; -import type { NodeItem, NodeMutationPayload } from '@/features/nodes/types'; -import { - CodeBlock, - DangerButton, - PrimaryButton, - ResourceField, - ResourceInput, - SecondaryButton, - ToggleField, -} from '@/features/shared/components/resource-primitives'; -import { formatDateTime, formatRelativeTime } from '@/lib/utils/date'; - -const nodesQueryKey = ['nodes']; -const bootstrapQueryKey = ['nodes', 'bootstrap-token']; -const installerScriptUrl = - 'https://raw.githubusercontent.com/Rain-kl/ATSFlare/main/scripts/install-agent.sh'; - -const nodeSchema = z.object({ - name: z.string().trim().min(1, '请输入节点名').max(128, '节点名不能超过 128 个字符'), - auto_update_enabled: z.boolean(), -}); - -type NodeFormValues = z.infer; - -type FeedbackState = { - tone: 'info' | 'success' | 'danger'; - message: string; -}; - -const defaultValues: NodeFormValues = { - name: '', - auto_update_enabled: false, -}; - -function getErrorMessage(error: unknown) { - return error instanceof Error ? error.message : '请求失败,请稍后重试。'; -} - -function toPayload(values: NodeFormValues): NodeMutationPayload { - return { - name: values.name.trim(), - auto_update_enabled: values.auto_update_enabled, - }; -} - -function isMeaningfulTime(value: string | null | undefined) { - return Boolean(value) && !String(value).startsWith('0001-01-01'); -} - -function getStatusVariant(status: NodeItem['status']) { - if (status === 'online') { - return 'success'; - } - - if (status === 'pending') { - return 'warning'; - } - - return 'danger'; -} - -function getStatusLabel(status: NodeItem['status']) { - if (status === 'online') { - return '在线'; - } - - if (status === 'pending') { - return '待接入'; - } - - return '离线'; -} - -function getApplyVariant(result: NodeItem['latest_apply_result']) { - if (result === 'success') { - return 'success'; - } - - if (result === 'failed') { - return 'danger'; - } - - return 'warning'; -} - -function getApplyLabel(result: NodeItem['latest_apply_result']) { - if (result === 'success') { - return '成功'; - } - - if (result === 'failed') { - return '失败'; - } - - return '暂无'; -} - -function getUpdateMode(node: NodeItem) { - if (node.update_requested) { - return { label: '等待更新', variant: 'warning' as const }; - } - - if (node.auto_update_enabled) { - return { label: '自动更新', variant: 'success' as const }; - } - - return { label: '手动更新', variant: 'info' as const }; -} - -function getServerUrl(value: string) { - return value.trim().replace(/\/+$/, ''); -} - -function buildDiscoveryInstallCommand(serverUrl: string, discoveryToken: string) { - return [ - `curl -fsSL ${installerScriptUrl} | bash -s -- \\`, - ` --server-url ${serverUrl} \\`, - ` --discovery-token ${discoveryToken}`, - ].join('\n'); -} - -function buildNodeInstallCommand(serverUrl: string, agentToken: string) { - return [ - `curl -fsSL ${installerScriptUrl} | bash -s -- \\`, - ` --server-url ${serverUrl} \\`, - ` --agent-token ${agentToken}`, - ].join('\n'); -} - -async function copyToClipboard(value: string) { - await navigator.clipboard.writeText(value); -} - -export function NodesPage() { - const queryClient = useQueryClient(); - const [feedback, setFeedback] = useState(null); - const [editingNodeId, setEditingNodeId] = useState(null); - const [isEditorOpen, setIsEditorOpen] = useState(false); - const [selectedNode, setSelectedNode] = useState(null); - const [serverUrl, setServerUrl] = useState(''); - const [, setRefreshTick] = useState(0); - - const form = useForm({ - resolver: zodResolver(nodeSchema), - defaultValues, - }); - - const watchedAutoUpdate = useWatch({ - control: form.control, - name: 'auto_update_enabled', - }); - - const nodesQuery = useQuery({ - queryKey: nodesQueryKey, - queryFn: getNodes, - }); - - const bootstrapQuery = useQuery({ - queryKey: bootstrapQueryKey, - queryFn: getNodeBootstrapToken, - }); - - useEffect(() => { - if (typeof window !== 'undefined' && !serverUrl) { - setServerUrl(window.location.origin); - } - }, [serverUrl]); - - useEffect(() => { - const timer = window.setInterval(() => { - void queryClient.invalidateQueries({ queryKey: nodesQueryKey }); - setRefreshTick((value) => value + 1); - }, 30000); - - return () => { - window.clearInterval(timer); - }; - }, [queryClient]); - - const saveMutation = useMutation({ - mutationFn: async (values: NodeFormValues) => { - const payload = toPayload(values); - return editingNodeId ? updateNode(editingNodeId, payload) : createNode(payload); - }, - onSuccess: async (node) => { - setFeedback({ - tone: 'success', - message: editingNodeId ? '节点已更新。' : '节点已创建。', - }); - setEditingNodeId(null); - setIsEditorOpen(false); - setSelectedNode(node); - form.reset(defaultValues); - await Promise.all([ - queryClient.invalidateQueries({ queryKey: nodesQueryKey }), - queryClient.invalidateQueries({ queryKey: bootstrapQueryKey }), - ]); - }, - onError: (error) => { - setFeedback({ tone: 'danger', message: getErrorMessage(error) }); - }, - }); - - const rotateMutation = useMutation({ - mutationFn: rotateNodeBootstrapToken, - onSuccess: async () => { - setFeedback({ tone: 'success', message: '全局 Discovery Token 已重新生成。' }); - await queryClient.invalidateQueries({ queryKey: bootstrapQueryKey }); - }, - onError: (error) => { - setFeedback({ tone: 'danger', message: getErrorMessage(error) }); - }, - }); - - const updateAgentMutation = useMutation({ - mutationFn: requestNodeAgentUpdate, - onSuccess: async (node) => { - setFeedback({ tone: 'success', message: `已向节点 ${node.name} 下发更新指令。` }); - setSelectedNode(node); - await queryClient.invalidateQueries({ queryKey: nodesQueryKey }); - }, - onError: (error) => { - setFeedback({ tone: 'danger', message: getErrorMessage(error) }); - }, - }); - - const deleteMutation = useMutation({ - mutationFn: deleteNode, - onSuccess: async () => { - setFeedback({ tone: 'success', message: '节点已删除。' }); - setEditingNodeId(null); - setSelectedNode(null); - form.reset(defaultValues); - await queryClient.invalidateQueries({ queryKey: nodesQueryKey }); - }, - onError: (error) => { - setFeedback({ tone: 'danger', message: getErrorMessage(error) }); - }, - }); - - const nodes = useMemo(() => nodesQuery.data ?? [], [nodesQuery.data]); - const bootstrapToken = bootstrapQuery.data?.discovery_token ?? ''; - const normalizedServerUrl = getServerUrl(serverUrl); - - const selectedNodeFromList = useMemo(() => { - if (!selectedNode) { - return null; - } - - return nodes.find((item) => item.id === selectedNode.id) ?? selectedNode; - }, [nodes, selectedNode]); - - const handleReset = () => { - setFeedback(null); - setEditingNodeId(null); - setIsEditorOpen(false); - form.reset(defaultValues); - }; - - const handleCreate = () => { - setFeedback(null); - setEditingNodeId(null); - form.reset(defaultValues); - setIsEditorOpen(true); - }; - - const handleEdit = (node: NodeItem) => { - setFeedback(null); - setEditingNodeId(node.id); - setSelectedNode(node); - form.reset({ - name: node.name, - auto_update_enabled: node.auto_update_enabled, - }); - setIsEditorOpen(true); - }; - - const handleDelete = (node: NodeItem) => { - if ( - !window.confirm(`确认删除节点“${node.name}”吗?删除后该节点需要重新创建并重新接入。`) - ) { - return; - } - - setFeedback(null); - deleteMutation.mutate(node.id); - }; - - const handleRotateToken = () => { - if (!window.confirm('确认重新生成全局 Discovery Token 吗?现有批量部署命令需要同步更新。')) { - return; - } - - setFeedback(null); - rotateMutation.mutate(); - }; - - const handleCopy = async (value: string, successMessage: string) => { - try { - await copyToClipboard(value); - setFeedback({ tone: 'success', message: successMessage }); - } catch (error) { - setFeedback({ tone: 'danger', message: getErrorMessage(error) }); - } - }; - - const handleSubmit = form.handleSubmit((values) => { - setFeedback(null); - saveMutation.mutate(values); - }); - - const discoveryCommand = - normalizedServerUrl && bootstrapToken - ? buildDiscoveryInstallCommand(normalizedServerUrl, bootstrapToken) - : ''; - - const nodeInstallCommand = - normalizedServerUrl && selectedNodeFromList?.agent_token - ? buildNodeInstallCommand(normalizedServerUrl, selectedNodeFromList.agent_token) - : ''; - - return ( - <> -
- - - 新增节点 - - - 应用记录 - - - } - /> - - {feedback ? : null} - -
- void handleCopy(nodeInstallCommand, '节点专属部署命令已复制。')} - > - 复制命令 - - ) : null - } - > - {selectedNodeFromList ? ( -
-
-
-
-

{selectedNodeFromList.name}

- -
-

Node ID:{selectedNodeFromList.node_id}

-
-
-

Agent Token

-

- {selectedNodeFromList.agent_token || '暂无'} -

-
-
- - {nodeInstallCommand ? {nodeInstallCommand} : null} -
- ) : ( - - )} -
-
- - void queryClient.invalidateQueries({ queryKey: nodesQueryKey })} - > - 刷新列表 - - } - > - {nodesQuery.isLoading ? ( - - ) : nodesQuery.isError ? ( - - ) : nodes.length === 0 ? ( - - ) : ( -
- - - - - - - - - - - - - - - - {nodes.map((node) => { - const updateMode = getUpdateMode(node); - - return ( - - - - - - - - - - - - ); - })} - -
节点状态更新模式Agent / Nginx当前版本最近应用最近心跳错误操作
-
-

{node.name}

-

Node ID:{node.node_id}

-

- Token:{node.agent_token || '暂无'} -

-

IP:{node.ip || '暂无'}

-
-
-
- - -
-
- - - {node.agent_version || 'unknown'} / {node.nginx_version || 'unknown'} - - {node.current_version || '未应用'} - -
- -

- {node.latest_apply_message || '暂无记录'} -

-

- {isMeaningfulTime(node.latest_apply_at) - ? `${formatRelativeTime(node.latest_apply_at)} · ${formatDateTime(node.latest_apply_at)}` - : '暂无'} -

-
-
- {isMeaningfulTime(node.last_seen_at) - ? `${formatRelativeTime(node.last_seen_at)} · ${formatDateTime(node.last_seen_at)}` - : '暂无'} - -

- {node.last_error || '无'} -

-
-
- updateAgentMutation.mutate(node.id)} - disabled={updateAgentMutation.isPending || node.update_requested} - className='px-3 py-2 text-xs' - > - 立即更新 - - setSelectedNode(node)} - className='px-3 py-2 text-xs' - > - 部署命令 - - handleEdit(node)} - className='px-3 py-2 text-xs' - > - 编辑 - - handleDelete(node)} - disabled={deleteMutation.isPending} - className='px-3 py-2 text-xs' - > - 删除 - -
-
-
- )} -
-
- - - 取消 - - - {saveMutation.isPending ? '保存中...' : editingNodeId ? '保存修改' : '新增节点'} - -
- } - > - - - - - - - form.setValue('auto_update_enabled', checked, { - shouldDirty: true, - shouldValidate: true, - }) - } - /> - - - - ); -} +'use client'; + +import Link from 'next/link'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { useEffect, useMemo, useState } from 'react'; +import { useForm, useWatch } from 'react-hook-form'; +import { z } from 'zod'; + +import { EmptyState } from '@/components/feedback/empty-state'; +import { ErrorState } from '@/components/feedback/error-state'; +import { InlineMessage } from '@/components/feedback/inline-message'; +import { LoadingState } from '@/components/feedback/loading-state'; +import { PageHeader } from '@/components/layout/page-header'; +import { AppModal } from '@/components/ui/app-modal'; +import { AppCard } from '@/components/ui/app-card'; +import { StatusBadge } from '@/components/ui/status-badge'; +import { + createNode, + deleteNode, + getNodes, + requestNodeAgentUpdate, + updateNode, +} from '@/features/nodes/api/nodes'; +import type { NodeItem, NodeMutationPayload } from '@/features/nodes/types'; +import { + CodeBlock, + DangerButton, + PrimaryButton, + ResourceField, + ResourceInput, + SecondaryButton, + ToggleField, +} from '@/features/shared/components/resource-primitives'; +import { formatDateTime, formatRelativeTime } from '@/lib/utils/date'; + +const nodesQueryKey = ['nodes']; +const installerScriptUrl = + 'https://raw.githubusercontent.com/Rain-kl/ATSFlare/main/scripts/install-agent.sh'; + +const nodeSchema = z.object({ + name: z + .string() + .trim() + .min(1, '请输入节点名') + .max(128, '节点名不能超过 128 个字符'), + auto_update_enabled: z.boolean(), +}); + +type NodeFormValues = z.infer; + +type FeedbackState = { + tone: 'info' | 'success' | 'danger'; + message: string; +}; + +const defaultValues: NodeFormValues = { + name: '', + auto_update_enabled: false, +}; + +function getErrorMessage(error: unknown) { + return error instanceof Error ? error.message : '请求失败,请稍后重试。'; +} + +function toPayload(values: NodeFormValues): NodeMutationPayload { + return { + name: values.name.trim(), + auto_update_enabled: values.auto_update_enabled, + }; +} + +function isMeaningfulTime(value: string | null | undefined) { + return Boolean(value) && !String(value).startsWith('0001-01-01'); +} + +function getStatusVariant(status: NodeItem['status']) { + if (status === 'online') { + return 'success'; + } + + if (status === 'pending') { + return 'warning'; + } + + return 'danger'; +} + +function getStatusLabel(status: NodeItem['status']) { + if (status === 'online') { + return '在线'; + } + + if (status === 'pending') { + return '待接入'; + } + + return '离线'; +} + +function getApplyVariant(result: NodeItem['latest_apply_result']) { + if (result === 'success') { + return 'success'; + } + + if (result === 'failed') { + return 'danger'; + } + + return 'warning'; +} + +function getApplyLabel(result: NodeItem['latest_apply_result']) { + if (result === 'success') { + return '成功'; + } + + if (result === 'failed') { + return '失败'; + } + + return '暂无'; +} + +function getUpdateMode(node: NodeItem) { + if (node.update_requested) { + return { label: '等待更新', variant: 'warning' as const }; + } + + if (node.auto_update_enabled) { + return { label: '自动更新', variant: 'success' as const }; + } + + return { label: '手动更新', variant: 'info' as const }; +} + +function getServerUrl(value: string) { + return value.trim().replace(/\/+$/, ''); +} + +function buildNodeInstallCommand(serverUrl: string, agentToken: string) { + return [ + `curl -fsSL ${installerScriptUrl} | bash -s -- \\`, + ` --server-url ${serverUrl} \\`, + ` --agent-token ${agentToken}`, + ].join('\n'); +} + +async function copyToClipboard(value: string) { + await navigator.clipboard.writeText(value); +} + +export function NodesPage() { + const queryClient = useQueryClient(); + const [feedback, setFeedback] = useState(null); + const [editingNodeId, setEditingNodeId] = useState(null); + const [isEditorOpen, setIsEditorOpen] = useState(false); + const [isDeployModalOpen, setIsDeployModalOpen] = useState(false); + const [selectedNode, setSelectedNode] = useState(null); + const [serverUrl, setServerUrl] = useState(''); + const [, setRefreshTick] = useState(0); + + const form = useForm({ + resolver: zodResolver(nodeSchema), + defaultValues, + }); + + const watchedAutoUpdate = useWatch({ + control: form.control, + name: 'auto_update_enabled', + }); + + const nodesQuery = useQuery({ + queryKey: nodesQueryKey, + queryFn: getNodes, + }); + + useEffect(() => { + if (typeof window !== 'undefined' && !serverUrl) { + setServerUrl(window.location.origin); + } + }, [serverUrl]); + + useEffect(() => { + const timer = window.setInterval(() => { + void queryClient.invalidateQueries({ queryKey: nodesQueryKey }); + setRefreshTick((value) => value + 1); + }, 30000); + + return () => { + window.clearInterval(timer); + }; + }, [queryClient]); + + const saveMutation = useMutation({ + mutationFn: async (values: NodeFormValues) => { + const payload = toPayload(values); + return editingNodeId + ? updateNode(editingNodeId, payload) + : createNode(payload); + }, + onSuccess: async (node) => { + setFeedback({ + tone: 'success', + message: editingNodeId ? '节点已更新。' : '节点已创建。', + }); + setEditingNodeId(null); + setIsEditorOpen(false); + setSelectedNode(node); + form.reset(defaultValues); + await queryClient.invalidateQueries({ queryKey: nodesQueryKey }); + }, + onError: (error) => { + setFeedback({ tone: 'danger', message: getErrorMessage(error) }); + }, + }); + + const updateAgentMutation = useMutation({ + mutationFn: requestNodeAgentUpdate, + onSuccess: async (node) => { + setFeedback({ + tone: 'success', + message: `已向节点 ${node.name} 下发更新指令。`, + }); + setSelectedNode(node); + await queryClient.invalidateQueries({ queryKey: nodesQueryKey }); + }, + onError: (error) => { + setFeedback({ tone: 'danger', message: getErrorMessage(error) }); + }, + }); + + const deleteMutation = useMutation({ + mutationFn: deleteNode, + onSuccess: async () => { + setFeedback({ tone: 'success', message: '节点已删除。' }); + setEditingNodeId(null); + setSelectedNode(null); + form.reset(defaultValues); + await queryClient.invalidateQueries({ queryKey: nodesQueryKey }); + }, + onError: (error) => { + setFeedback({ tone: 'danger', message: getErrorMessage(error) }); + }, + }); + + const nodes = useMemo(() => nodesQuery.data ?? [], [nodesQuery.data]); + const normalizedServerUrl = getServerUrl(serverUrl); + + const selectedNodeFromList = useMemo(() => { + if (!selectedNode) { + return null; + } + + return nodes.find((item) => item.id === selectedNode.id) ?? selectedNode; + }, [nodes, selectedNode]); + + const handleReset = () => { + setFeedback(null); + setEditingNodeId(null); + setIsEditorOpen(false); + form.reset(defaultValues); + }; + + const handleCreate = () => { + setFeedback(null); + setEditingNodeId(null); + form.reset(defaultValues); + setIsEditorOpen(true); + }; + + const handleEdit = (node: NodeItem) => { + setFeedback(null); + setEditingNodeId(node.id); + setSelectedNode(node); + form.reset({ + name: node.name, + auto_update_enabled: node.auto_update_enabled, + }); + setIsEditorOpen(true); + }; + + const handleOpenDeployModal = (node: NodeItem) => { + setFeedback(null); + setSelectedNode(node); + setIsDeployModalOpen(true); + }; + + const handleDelete = (node: NodeItem) => { + if ( + !window.confirm( + `确认删除节点“${node.name}”吗?删除后该节点需要重新创建并重新接入。`, + ) + ) { + return; + } + + setFeedback(null); + deleteMutation.mutate(node.id); + }; + + const handleCopy = async (value: string, successMessage: string) => { + try { + await copyToClipboard(value); + setFeedback({ tone: 'success', message: successMessage }); + } catch (error) { + setFeedback({ tone: 'danger', message: getErrorMessage(error) }); + } + }; + + const handleSubmit = form.handleSubmit((values) => { + setFeedback(null); + saveMutation.mutate(values); + }); + + const nodeInstallCommand = + normalizedServerUrl && selectedNodeFromList?.agent_token + ? buildNodeInstallCommand( + normalizedServerUrl, + selectedNodeFromList.agent_token, + ) + : ''; + + return ( + <> +
+ + + 新增节点 + + + 应用记录 + + + } + /> + + {feedback ? ( + + ) : null} + + + void queryClient.invalidateQueries({ queryKey: nodesQueryKey }) + } + > + 刷新列表 + + } + > + {nodesQuery.isLoading ? ( + + ) : nodesQuery.isError ? ( + + ) : nodes.length === 0 ? ( + + ) : ( +
+ + + + + + + + + + + + + + + + {nodes.map((node) => { + const updateMode = getUpdateMode(node); + + return ( + + + + + + + + + + + + ); + })} + +
节点状态更新模式Agent / Nginx当前版本最近应用最近心跳错误操作
+
+

+ {node.name} +

+

+ Node ID:{node.node_id} +

+

+ Token:{node.agent_token || '暂无'} +

+

+ IP:{node.ip || '暂无'} +

+
+
+
+ + +
+
+ + + {node.agent_version || 'unknown'} /{' '} + {node.nginx_version || 'unknown'} + + {node.current_version || '未应用'} + +
+ +

+ {node.latest_apply_message || '暂无记录'} +

+

+ {isMeaningfulTime(node.latest_apply_at) + ? `${formatRelativeTime(node.latest_apply_at)} · ${formatDateTime(node.latest_apply_at)}` + : '暂无'} +

+
+
+ {isMeaningfulTime(node.last_seen_at) + ? `${formatRelativeTime(node.last_seen_at)} · ${formatDateTime(node.last_seen_at)}` + : '暂无'} + +

+ {node.last_error || '无'} +

+
+
+ + updateAgentMutation.mutate(node.id) + } + disabled={ + updateAgentMutation.isPending || + node.update_requested + } + className="px-3 py-2 text-xs" + > + 立即更新 + + handleOpenDeployModal(node)} + className="px-3 py-2 text-xs" + > + 部署 + + handleEdit(node)} + className="px-3 py-2 text-xs" + > + 编辑 + + handleDelete(node)} + disabled={deleteMutation.isPending} + className="px-3 py-2 text-xs" + > + 删除 + +
+
+
+ )} +
+
+ setIsDeployModalOpen(false)} + title="节点专属部署命令" + description="仅展示节点专属 Agent Token 部署方式。Discovery Token 部署入口已从管理端移除。" + footer={ +
+ setIsDeployModalOpen(false)} + > + 关闭 + + {nodeInstallCommand ? ( + + void handleCopy( + nodeInstallCommand, + '节点专属部署命令已复制。', + ) + } + > + 复制命令 + + ) : null} +
+ } + > + {selectedNodeFromList ? ( +
+
+
+
+

+ {selectedNodeFromList.name} +

+ +
+

+ Node ID:{selectedNodeFromList.node_id} +

+

+ IP:{selectedNodeFromList.ip || '暂无'} +

+
+
+

+ Agent Token +

+

+ {selectedNodeFromList.agent_token || '暂无'} +

+
+
+ + setServerUrl(event.target.value)} + /> + + {nodeInstallCommand ? ( + + {nodeInstallCommand} + + ) : null} +
+ ) : ( + + )} +
+ + + 取消 + + + {saveMutation.isPending + ? '保存中...' + : editingNodeId + ? '保存修改' + : '新增节点'} + +
+ } + > +
+ + + + + + form.setValue('auto_update_enabled', checked, { + shouldDirty: true, + shouldValidate: true, + }) + } + /> + + + + ); +} diff --git a/atsf_server/web/features/settings/api/settings.ts b/atsf_server/web/features/settings/api/settings.ts index e0d4e1b2..74320e60 100644 --- a/atsf_server/web/features/settings/api/settings.ts +++ b/atsf_server/web/features/settings/api/settings.ts @@ -1,62 +1,48 @@ -import { apiRequest } from '@/lib/api/client'; - -import type { - BootstrapTokenPayload, - LatestReleaseInfo, - OptionItem, - SettingsProfile, - UpdateSelfPayload, -} from '@/features/settings/types'; - -export function getOptions() { - return apiRequest('/option/'); -} - -export function updateOption(key: string, value: string) { - return apiRequest('/option/', { - method: 'PUT', - body: JSON.stringify({ key, value }), - }); -} - -export function getBootstrapToken() { - return apiRequest('/nodes/bootstrap-token'); -} - -export function rotateBootstrapToken() { - return apiRequest('/nodes/bootstrap-token/rotate', { - method: 'POST', - }); -} - -export function getLatestRelease() { - return apiRequest('/update/latest-release'); -} - -export function getSettingsProfile() { - return apiRequest('/user/self'); -} - -export function updateSelf(payload: UpdateSelfPayload) { - return apiRequest('/user/self', { - method: 'PUT', - body: JSON.stringify(payload), - }); -} - -export function generateAccessToken() { - return apiRequest('/user/token'); -} - -export function bindWeChat(code: string) { - return apiRequest(`/oauth/wechat/bind?code=${encodeURIComponent(code)}`); -} - -export function bindEmail(email: string, code: string) { - const searchParams = new URLSearchParams({ email, code }); - return apiRequest(`/oauth/email/bind?${searchParams.toString()}`); -} - -export function getAboutContent() { - return apiRequest('/about'); -} +import { apiRequest } from '@/lib/api/client'; + +import type { + OptionItem, + SettingsProfile, + UpdateSelfPayload, +} from '@/features/settings/types'; + +export function getOptions() { + return apiRequest('/option/'); +} + +export function updateOption(key: string, value: string) { + return apiRequest('/option/', { + method: 'PUT', + body: JSON.stringify({ key, value }), + }); +} + +export function getSettingsProfile() { + return apiRequest('/user/self'); +} + +export function updateSelf(payload: UpdateSelfPayload) { + return apiRequest('/user/self', { + method: 'PUT', + body: JSON.stringify(payload), + }); +} + +export function generateAccessToken() { + return apiRequest('/user/token'); +} + +export function bindWeChat(code: string) { + return apiRequest( + `/oauth/wechat/bind?code=${encodeURIComponent(code)}`, + ); +} + +export function bindEmail(email: string, code: string) { + const searchParams = new URLSearchParams({ email, code }); + return apiRequest(`/oauth/email/bind?${searchParams.toString()}`); +} + +export function getAboutContent() { + return apiRequest('/about'); +} diff --git a/atsf_server/web/features/settings/components/settings-page.tsx b/atsf_server/web/features/settings/components/settings-page.tsx index a9dfd406..4f911f3e 100644 --- a/atsf_server/web/features/settings/components/settings-page.tsx +++ b/atsf_server/web/features/settings/components/settings-page.tsx @@ -1,1571 +1,1703 @@ -'use client'; - -import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query'; -import {marked} from 'marked'; -import {useEffect, useMemo, useState} from 'react'; - -import {EmptyState} from '@/components/feedback/empty-state'; -import {ErrorState} from '@/components/feedback/error-state'; -import {InlineMessage} from '@/components/feedback/inline-message'; -import {LoadingState} from '@/components/feedback/loading-state'; -import {TurnstileWidget} from '@/components/forms/turnstile-widget'; -import {useAuth} from '@/components/providers/auth-provider'; -import {PageHeader} from '@/components/layout/page-header'; -import {AppCard} from '@/components/ui/app-card'; -import {StatusBadge} from '@/components/ui/status-badge'; -import {sendEmailVerification} from '@/features/auth/api/auth'; -import {getPublicStatus} from '@/features/auth/api/public'; -import { - bindEmail, - bindWeChat, - generateAccessToken, - getBootstrapToken, - getLatestRelease, - getOptions, - getSettingsProfile, - rotateBootstrapToken, - updateOption, - updateSelf, -} from '@/features/settings/api/settings'; -import type {LatestReleaseInfo, OptionItem, UpdateSelfPayload,} from '@/features/settings/types'; -import { - CodeBlock, - PrimaryButton, - ResourceField, - ResourceInput, - ResourceTextarea, - SecondaryButton, - ToggleField, -} from '@/features/shared/components/resource-primitives'; -import {publicEnv} from '@/lib/env/public-env'; -import {formatDateTime, formatRelativeTime} from '@/lib/utils/date'; - -const installerScriptUrl = 'https://raw.githubusercontent.com/Rain-kl/ATSFlare/main/scripts/install-agent.sh'; -const settingsQueryKey = ['settings', 'options'] as const; - -const defaultSystemFields = { - ServerAddress: '', - PasswordLoginEnabled: true, - PasswordRegisterEnabled: true, - EmailVerificationEnabled: false, - GitHubOAuthEnabled: false, - WeChatAuthEnabled: false, - TurnstileCheckEnabled: false, - RegisterEnabled: true, - SMTPServer: '', - SMTPPort: '587', - SMTPAccount: '', - SMTPToken: '', - GitHubClientId: '', - GitHubClientSecret: '', - WeChatServerAddress: '', - WeChatServerToken: '', - WeChatAccountQRCodeImageURL: '', - TurnstileSiteKey: '', - TurnstileSecretKey: '', -}; - -const defaultOperationFields = { - AgentHeartbeatInterval: '30000', - AgentSyncInterval: '30000', - NodeOfflineThreshold: '120000', - AgentUpdateRepo: 'Rain-kl/ATSFlare', - GlobalApiRateLimitNum: '300', - GlobalApiRateLimitDuration: '180', - GlobalWebRateLimitNum: '300', - GlobalWebRateLimitDuration: '180', - UploadRateLimitNum: '50', - UploadRateLimitDuration: '60', - DownloadRateLimitNum: '50', - DownloadRateLimitDuration: '60', - CriticalRateLimitNum: '100', - CriticalRateLimitDuration: '1200', - ServerAddress: '', -}; - -const defaultOtherFields = { - Notice: '', - SystemName: '', - HomePageLink: '', - About: '', - Footer: '', -}; - -const defaultProfileFields: UpdateSelfPayload = { - username: '', - display_name: '', - password: '', -}; - -type FeedbackState = { - tone: 'info' | 'success' | 'danger'; - message: string; -}; - -type SettingsTab = 'personal' | 'operation' | 'system' | 'other'; - -function getErrorMessage(error: unknown) { - return error instanceof Error ? error.message : '请求失败,请稍后重试。'; -} - -function optionsToMap(options: OptionItem[] | undefined) { - return (options ?? []).reduce>((accumulator, option) => { - accumulator[option.key] = option.value; - return accumulator; - }, {}); -} - -function toBoolean(value: string | undefined, fallback: boolean) { - if (value === undefined) { - return fallback; - } - - return value === 'true'; -} - -function normalizeServerUrl(value: string) { - return value.trim().replace(/\/+$/, ''); -} - -function formatDurationLabel(value: string) { - const milliseconds = Number.parseInt(value, 10); - if (Number.isNaN(milliseconds)) { - return value; - } - - if (milliseconds >= 60000) { - return `${milliseconds / 60000} 分钟`; - } - - return `${milliseconds / 1000} 秒`; -} - -function formatSecondsLabel(value: string) { - const seconds = Number.parseInt(value, 10); - if (Number.isNaN(seconds)) { - return value; - } - - if (seconds >= 3600 && seconds % 3600 === 0) { - return `${seconds / 3600} 小时`; - } - - if (seconds >= 60 && seconds % 60 === 0) { - return `${seconds / 60} 分钟`; - } - - return `${seconds} 秒`; -} - -function buildDiscoveryCommand(serverUrl: string, discoveryToken: string) { - return [ - `curl -fsSL ${installerScriptUrl} | bash -s -- \\`, - ` --server-url ${normalizeServerUrl(serverUrl)} \\`, - ` --discovery-token ${discoveryToken}`, - ].join('\n'); -} - -async function copyToClipboard(value: string) { - await navigator.clipboard.writeText(value); -} - -export function SettingsPage() { - const queryClient = useQueryClient(); - const {refreshUser, user} = useAuth(); - const [activeTab, setActiveTab] = useState('personal'); - const [feedback, setFeedback] = useState(null); - const [busyKey, setBusyKey] = useState(null); - const [profileFields, setProfileFields] = useState(defaultProfileFields); - const [systemFields, setSystemFields] = useState(defaultSystemFields); - const [operationFields, setOperationFields] = useState(defaultOperationFields); - const [otherFields, setOtherFields] = useState(defaultOtherFields); - const [accessToken, setAccessToken] = useState(''); - const [wechatCode, setWeChatCode] = useState(''); - const [emailAddress, setEmailAddress] = useState(''); - const [emailCode, setEmailCode] = useState(''); - const [emailTurnstileToken, setEmailTurnstileToken] = useState(''); - const [latestRelease, setLatestRelease] = useState(null); - - const isRoot = (user?.role ?? 0) >= 100; - - const publicStatusQuery = useQuery({ - queryKey: ['public-status'], - queryFn: getPublicStatus, - }); - - const profileQuery = useQuery({ - queryKey: ['settings', 'profile'], - queryFn: getSettingsProfile, - }); - - const optionsQuery = useQuery({ - queryKey: settingsQueryKey, - queryFn: getOptions, - enabled: isRoot, - }); - - const bootstrapQuery = useQuery({ - queryKey: ['settings', 'bootstrap-token'], - queryFn: getBootstrapToken, - enabled: isRoot, - }); - - useEffect(() => { - if (profileQuery.data) { - setProfileFields({ - username: profileQuery.data.username, - display_name: profileQuery.data.display_name || '', - password: '', - }); - setEmailAddress(profileQuery.data.email || ''); - } - }, [profileQuery.data]); - - useEffect(() => { - const publicStatus = publicStatusQuery.data; - if (!publicStatus) { - return; - } - - setSystemFields((previous) => ({ - ...previous, - ServerAddress: publicStatus.server_address || previous.ServerAddress, - GitHubClientId: publicStatus.github_client_id || previous.GitHubClientId, - WeChatAccountQRCodeImageURL: publicStatus.wechat_qrcode || previous.WeChatAccountQRCodeImageURL, - TurnstileSiteKey: publicStatus.turnstile_site_key || previous.TurnstileSiteKey, - })); - setOtherFields((previous) => ({ - ...previous, - SystemName: publicStatus.system_name || previous.SystemName, - HomePageLink: publicStatus.home_page_link || previous.HomePageLink, - Footer: publicStatus.footer_html || previous.Footer, - })); - setOperationFields((previous) => ({ - ...previous, - ServerAddress: publicStatus.server_address || previous.ServerAddress, - })); - }, [publicStatusQuery.data]); - - useEffect(() => { - if (!optionsQuery.data) { - return; - } - - const optionMap = optionsToMap(optionsQuery.data); - - setSystemFields({ - ServerAddress: optionMap.ServerAddress ?? '', - PasswordLoginEnabled: toBoolean(optionMap.PasswordLoginEnabled, true), - PasswordRegisterEnabled: toBoolean(optionMap.PasswordRegisterEnabled, true), - EmailVerificationEnabled: toBoolean(optionMap.EmailVerificationEnabled, false), - GitHubOAuthEnabled: toBoolean(optionMap.GitHubOAuthEnabled, false), - WeChatAuthEnabled: toBoolean(optionMap.WeChatAuthEnabled, false), - TurnstileCheckEnabled: toBoolean(optionMap.TurnstileCheckEnabled, false), - RegisterEnabled: toBoolean(optionMap.RegisterEnabled, true), - SMTPServer: optionMap.SMTPServer ?? '', - SMTPPort: optionMap.SMTPPort ?? '587', - SMTPAccount: optionMap.SMTPAccount ?? '', - SMTPToken: '', - GitHubClientId: optionMap.GitHubClientId ?? '', - GitHubClientSecret: '', - WeChatServerAddress: optionMap.WeChatServerAddress ?? '', - WeChatServerToken: '', - WeChatAccountQRCodeImageURL: optionMap.WeChatAccountQRCodeImageURL ?? '', - TurnstileSiteKey: optionMap.TurnstileSiteKey ?? '', - TurnstileSecretKey: '', - }); - - setOperationFields({ - AgentHeartbeatInterval: optionMap.AgentHeartbeatInterval ?? '30000', - AgentSyncInterval: optionMap.AgentSyncInterval ?? '30000', - NodeOfflineThreshold: optionMap.NodeOfflineThreshold ?? '120000', - AgentUpdateRepo: optionMap.AgentUpdateRepo ?? 'Rain-kl/ATSFlare', - GlobalApiRateLimitNum: optionMap.GlobalApiRateLimitNum ?? '300', - GlobalApiRateLimitDuration: optionMap.GlobalApiRateLimitDuration ?? '180', - GlobalWebRateLimitNum: optionMap.GlobalWebRateLimitNum ?? '300', - GlobalWebRateLimitDuration: optionMap.GlobalWebRateLimitDuration ?? '180', - UploadRateLimitNum: optionMap.UploadRateLimitNum ?? '50', - UploadRateLimitDuration: optionMap.UploadRateLimitDuration ?? '60', - DownloadRateLimitNum: optionMap.DownloadRateLimitNum ?? '50', - DownloadRateLimitDuration: optionMap.DownloadRateLimitDuration ?? '60', - CriticalRateLimitNum: optionMap.CriticalRateLimitNum ?? '100', - CriticalRateLimitDuration: optionMap.CriticalRateLimitDuration ?? '1200', - ServerAddress: optionMap.ServerAddress ?? publicStatusQuery.data?.server_address ?? '', - }); - - setOtherFields({ - Notice: optionMap.Notice ?? '', - SystemName: optionMap.SystemName ?? '', - HomePageLink: optionMap.HomePageLink ?? '', - About: optionMap.About ?? '', - Footer: optionMap.Footer ?? '', - }); - }, [optionsQuery.data, publicStatusQuery.data?.server_address]); - - const rotateTokenMutation = useMutation({ - mutationFn: rotateBootstrapToken, - onSuccess: async (data) => { - setFeedback({tone: 'success', message: 'Discovery Token 已重新生成。'}); - await queryClient.invalidateQueries({queryKey: ['settings', 'bootstrap-token']}); - if (data.discovery_token) { - try { - await copyToClipboard(data.discovery_token); - } catch { - // ignore clipboard errors - } - } - }, - onError: (error) => { - setFeedback({tone: 'danger', message: getErrorMessage(error)}); - }, - }); - - const accessTokenMutation = useMutation({ - mutationFn: generateAccessToken, - onSuccess: (token) => { - setAccessToken(token); - setFeedback({tone: 'success', message: '访问令牌已重置,并已在当前页面展示。'}); - }, - onError: (error) => { - setFeedback({tone: 'danger', message: getErrorMessage(error)}); - }, - }); - - const discoveryToken = bootstrapQuery.data?.discovery_token ?? ''; - const discoveryCommand = - isRoot && operationFields.ServerAddress && discoveryToken - ? buildDiscoveryCommand(operationFields.ServerAddress, discoveryToken) - : ''; - - const tabs = useMemo( - () => [ - {key: 'personal' as const, label: '个人设置', description: '更新个人资料、绑定账号与访问令牌。'}, - ...(isRoot - ? [ - { - key: 'operation' as const, - label: '运维设置', - description: 'Agent 参数、Discovery Token 与部署命令。' - }, - {key: 'system' as const, label: '系统设置', description: '登录注册、SMTP、OAuth 与风控开关。'}, - {key: 'other' as const, label: '其他设置', description: '公告、关于、品牌信息与版本检查。'}, - ] - : []), - ], - [isRoot], - ); - - const runBusyAction = async (key: string, action: () => Promise) => { - setBusyKey(key); - setFeedback(null); - - try { - await action(); - } catch (error) { - setFeedback({tone: 'danger', message: getErrorMessage(error)}); - } finally { - setBusyKey(null); - } - }; - - const saveOptionEntries = async (entries: Array<[string, string]>, successMessage: string) => { - for (const [key, value] of entries) { - await updateOption(key, value); - } - - await queryClient.invalidateQueries({queryKey: settingsQueryKey}); - await queryClient.invalidateQueries({queryKey: ['public-status']}); - setFeedback({tone: 'success', message: successMessage}); - }; - - const handleProfileSave = () => { - void runBusyAction('profile', async () => { - await updateSelf({ - username: profileFields.username.trim(), - display_name: profileFields.display_name.trim(), - password: profileFields.password, - }); - await Promise.all([ - queryClient.invalidateQueries({queryKey: ['settings', 'profile']}), - refreshUser(), - ]); - setProfileFields((previous) => ({...previous, password: ''})); - setFeedback({tone: 'success', message: '个人资料已更新。'}); - }); - }; - - const handleEmailVerification = () => { - if (!emailAddress.trim()) { - setFeedback({tone: 'danger', message: '请输入要绑定的邮箱地址。'}); - return; - } - - if (publicStatusQuery.data?.turnstile_check && !emailTurnstileToken) { - setFeedback({tone: 'info', message: '请先完成人机验证。'}); - return; - } - - void runBusyAction('email-send', async () => { - await sendEmailVerification(emailAddress.trim(), emailTurnstileToken || undefined); - setFeedback({tone: 'success', message: '验证码已发送,请检查邮箱。'}); - }); - }; - - const handleBindEmail = () => { - if (!emailAddress.trim() || !emailCode.trim()) { - setFeedback({tone: 'danger', message: '请输入邮箱地址和验证码。'}); - return; - } - - void runBusyAction('email-bind', async () => { - await bindEmail(emailAddress.trim(), emailCode.trim()); - setEmailCode(''); - await Promise.all([ - queryClient.invalidateQueries({queryKey: ['settings', 'profile']}), - refreshUser(), - ]); - setFeedback({tone: 'success', message: '邮箱已绑定。'}); - }); - }; - - const handleBindWeChat = () => { - if (!wechatCode.trim()) { - setFeedback({tone: 'danger', message: '请输入微信验证码。'}); - return; - } - - void runBusyAction('wechat-bind', async () => { - await bindWeChat(wechatCode.trim()); - setWeChatCode(''); - await Promise.all([ - queryClient.invalidateQueries({queryKey: ['settings', 'profile']}), - refreshUser(), - ]); - setFeedback({tone: 'success', message: '微信账号已绑定。'}); - }); - }; - - const handleToggleOption = (key: keyof typeof systemFields, nextValue: boolean) => { - setSystemFields((previous) => ({...previous, [key]: nextValue})); - - void runBusyAction(`toggle-${key}`, async () => { - await saveOptionEntries([[key, String(nextValue)]], '系统开关已更新。'); - }); - }; - - const handleCheckLatestRelease = () => { - void runBusyAction('latest-release', async () => { - const release = await getLatestRelease(); - setLatestRelease(release); - setFeedback({tone: 'success', message: `已获取最新版本信息:${release.tag_name}`}); - }); - }; - - const renderTabContent = () => { - if (profileQuery.isLoading || publicStatusQuery.isLoading) { - return ; - } - - if (profileQuery.isError) { - return ; - } - - if (publicStatusQuery.isError) { - return ; - } - - const publicStatus = publicStatusQuery.data; - const profile = profileQuery.data; - - if (!publicStatus || !profile) { - return ; - } - - if (activeTab === 'personal') { - return ( -
-
- - {busyKey === 'profile' ? '保存中...' : '保存资料'} - - } - > -
- - - setProfileFields((previous) => ({ - ...previous, - username: event.target.value - })) - } - placeholder='请输入用户名' - /> - - - - - setProfileFields((previous) => ({ - ...previous, - display_name: event.target.value - })) - } - placeholder='请输入显示名称' - /> - - - - - setProfileFields((previous) => ({ - ...previous, - password: event.target.value - })) - } - placeholder='请输入新密码' - /> - -
-
- - accessTokenMutation.mutate()} - disabled={accessTokenMutation.isPending} - > - {accessTokenMutation.isPending ? '生成中...' : '重置令牌'} - - } - > -
-
-
-

当前角色

-
- -
-
-
-

已绑定邮箱

-

- {profile.email || '未绑定'} -

-
-
- - {accessToken ? ( -
- {accessToken} - void copyToClipboard(accessToken)} - > - 复制令牌 - -
- ) : ( - - )} -
-
-
- - -
-
-
-

GitHub - 账号

-

- 当前状态:{profile.github_id ? `已绑定 ${profile.github_id}` : '未绑定'} -

-
- - window.open( - `https://github.com/login/oauth/authorize?client_id=${publicStatus.github_client_id}&scope=user:email`, - '_blank', - 'noopener,noreferrer', - ) - } - disabled={!publicStatus.github_oauth || !publicStatus.github_client_id} - > - {publicStatus.github_oauth ? '绑定 GitHub' : '未启用 GitHub OAuth'} - -
- -
-
-

微信账号

-

- 当前状态:{profile.wechat_id ? `已绑定 ${profile.wechat_id}` : '未绑定'} -

-
- {publicStatus.wechat_login && publicStatus.wechat_qrcode ? ( - // eslint-disable-next-line @next/next/no-img-element - 微信绑定二维码 - ) : null} - - setWeChatCode(event.target.value)} - placeholder='请输入微信验证码' - /> - - - {busyKey === 'wechat-bind' ? '绑定中...' : '绑定微信'} - -
- -
-
-

邮箱地址

-

- 当前状态:{profile.email ? `已绑定 ${profile.email}` : '未绑定'} -

-
-
- - setEmailAddress(event.target.value)} - placeholder='请输入邮箱地址' - /> - - - setEmailCode(event.target.value)} - placeholder='请输入邮箱验证码' - /> - - {publicStatus.turnstile_check ? ( - publicStatus.turnstile_site_key ? ( - setEmailTurnstileToken(token)} - onExpire={() => setEmailTurnstileToken('')} - onError={() => setEmailTurnstileToken('')} - /> - ) : ( - - ) - ) : null} -
- - {busyKey === 'email-send' ? '发送中...' : '发送验证码'} - - - {busyKey === 'email-bind' ? '绑定中...' : '绑定邮箱'} - -
-
-
-
-
-
- ); - } - - if (!isRoot) { - return ; - } - - if (optionsQuery.isLoading) { - return ; - } - - if (optionsQuery.isError) { - return ; - } - - if (activeTab === 'operation') { - return ( -
-
- - window.open('/swagger/index.html', '_blank', 'noopener,noreferrer')}> - 打开接口文档 - - - void runBusyAction('operation-intervals', async () => { - const heartbeat = Number.parseInt(operationFields.AgentHeartbeatInterval, 10); - const sync = Number.parseInt(operationFields.AgentSyncInterval, 10); - const offline = Number.parseInt(operationFields.NodeOfflineThreshold, 10); - - if (Number.isNaN(heartbeat) || heartbeat < 5000) { - throw new Error('心跳间隔不能小于 5000 毫秒。'); - } - if (Number.isNaN(sync) || sync < 5000) { - throw new Error('同步间隔不能小于 5000 毫秒。'); - } - if (Number.isNaN(offline) || offline < 10000) { - throw new Error('离线阈值不能小于 10000 毫秒。'); - } - - await saveOptionEntries( - [ - ['AgentHeartbeatInterval', String(heartbeat)], - ['AgentSyncInterval', String(sync)], - ['NodeOfflineThreshold', String(offline)], - ], - 'Agent 运行参数已保存。', - ); - }) - } - disabled={busyKey === 'operation-intervals'} - > - {busyKey === 'operation-intervals' ? '保存中...' : '保存运行参数'} - -
- } - > -
- - - setOperationFields((previous) => ({ - ...previous, - AgentHeartbeatInterval: event.target.value - })) - } - /> - - - - setOperationFields((previous) => ({ - ...previous, - AgentSyncInterval: event.target.value - })) - } - /> - - - - setOperationFields((previous) => ({ - ...previous, - NodeOfflineThreshold: event.target.value - })) - } - /> - -
- - - - void runBusyAction('operation-repo', async () => { - await saveOptionEntries( - [['AgentUpdateRepo', operationFields.AgentUpdateRepo.trim()]], - 'Agent 更新仓库已保存。', - ); - }) - } - disabled={busyKey === 'operation-repo'} - > - {busyKey === 'operation-repo' ? '保存中...' : '保存更新仓库'} - - } - > - - - setOperationFields((previous) => ({ - ...previous, - AgentUpdateRepo: event.target.value - })) - } - placeholder='Rain-kl/ATSFlare' - /> - - -
- -
- - rotateTokenMutation.mutate()} - disabled={rotateTokenMutation.isPending} - > - {rotateTokenMutation.isPending ? '生成中...' : '重新生成 Token'} - - {discoveryCommand ? ( - void copyToClipboard(discoveryCommand)}> - 复制命令 - - ) : null} -
- } - > - {bootstrapQuery.isLoading ? ( - - ) : bootstrapQuery.isError ? ( - - ) : ( -
- - - setOperationFields((previous) => ({ - ...previous, - ServerAddress: event.target.value - })) - } - /> - -
-

Discovery - Token

-

- {discoveryToken || '未生成'} -

-
- {discoveryCommand ? {discoveryCommand} : null} -
- )} - - - -
-
-

前端版本

-

{publicEnv.appVersion}

-
-
-

服务端版本

-

{publicStatus.version}

-
-
-

Server - 启动时间

-

- {formatDateTime(new Date(publicStatus.start_time * 1000))} -

-
-
-

运行模式

-

静态导出 + Go Server - 托管

-
-
-
-
-
- ); - } - - if (activeTab === 'system') { - return ( -
- -
- handleToggleOption('PasswordLoginEnabled', checked)} - disabled={busyKey === 'toggle-PasswordLoginEnabled'} - /> - handleToggleOption('PasswordRegisterEnabled', checked)} - disabled={busyKey === 'toggle-PasswordRegisterEnabled'} - /> - handleToggleOption('EmailVerificationEnabled', checked)} - disabled={busyKey === 'toggle-EmailVerificationEnabled'} - /> - handleToggleOption('GitHubOAuthEnabled', checked)} - disabled={busyKey === 'toggle-GitHubOAuthEnabled'} - /> - handleToggleOption('WeChatAuthEnabled', checked)} - disabled={busyKey === 'toggle-WeChatAuthEnabled'} - /> - handleToggleOption('TurnstileCheckEnabled', checked)} - disabled={busyKey === 'toggle-TurnstileCheckEnabled'} - /> - handleToggleOption('RegisterEnabled', checked)} - disabled={busyKey === 'toggle-RegisterEnabled'} - /> -
-
- -
- - void runBusyAction('system-core', async () => { - await saveOptionEntries( - [ - ['ServerAddress', normalizeServerUrl(systemFields.ServerAddress)], - ['SMTPServer', systemFields.SMTPServer.trim()], - ['SMTPPort', systemFields.SMTPPort.trim()], - ['SMTPAccount', systemFields.SMTPAccount.trim()], - ['SMTPToken', systemFields.SMTPToken.trim()], - ], - '通用与 SMTP 设置已保存。', - ); - }) - } - disabled={busyKey === 'system-core'} - > - {busyKey === 'system-core' ? '保存中...' : '保存通用设置'} - - } - > -
- - - setSystemFields((previous) => ({ - ...previous, - ServerAddress: event.target.value - })) - } - placeholder='https://yourdomain.com' - /> - -
- - - setSystemFields((previous) => ({ - ...previous, - SMTPServer: event.target.value - })) - } - placeholder='smtp.qq.com' - /> - - - - setSystemFields((previous) => ({ - ...previous, - SMTPPort: event.target.value - })) - } - placeholder='587' - /> - - - - setSystemFields((previous) => ({ - ...previous, - SMTPAccount: event.target.value - })) - } - placeholder='name@example.com' - /> - - - - setSystemFields((previous) => ({ - ...previous, - SMTPToken: event.target.value - })) - } - placeholder='请输入新的 SMTP 凭证' - /> - -
-
-
- - - void runBusyAction('system-integrations', async () => { - await saveOptionEntries( - [ - ['GitHubClientId', systemFields.GitHubClientId.trim()], - ['GitHubClientSecret', systemFields.GitHubClientSecret.trim()], - ['WeChatServerAddress', normalizeServerUrl(systemFields.WeChatServerAddress)], - ['WeChatServerToken', systemFields.WeChatServerToken.trim()], - ['WeChatAccountQRCodeImageURL', systemFields.WeChatAccountQRCodeImageURL.trim()], - ['TurnstileSiteKey', systemFields.TurnstileSiteKey.trim()], - ['TurnstileSecretKey', systemFields.TurnstileSecretKey.trim()], - ], - '第三方集成设置已保存。', - ); - }) - } - disabled={busyKey === 'system-integrations'} - > - {busyKey === 'system-integrations' ? '保存中...' : '保存集成设置'} - - } - > -
-
- - - setSystemFields((previous) => ({ - ...previous, - GitHubClientId: event.target.value - })) - } - /> - - - - setSystemFields((previous) => ({ - ...previous, - GitHubClientSecret: event.target.value - })) - } - /> - - - - setSystemFields((previous) => ({ - ...previous, - WeChatServerAddress: event.target.value - })) - } - /> - - - - setSystemFields((previous) => ({ - ...previous, - WeChatServerToken: event.target.value - })) - } - /> - - - - setSystemFields((previous) => ({ - ...previous, - WeChatAccountQRCodeImageURL: event.target.value - })) - } - /> - - - - setSystemFields((previous) => ({ - ...previous, - TurnstileSiteKey: event.target.value - })) - } - /> - - - - setSystemFields((previous) => ({ - ...previous, - TurnstileSecretKey: event.target.value - })) - } - /> - -
-
-
-
- - void runBusyAction('operation-rate-limit', async () => { - const entries = [ - ['GlobalApiRateLimitNum', operationFields.GlobalApiRateLimitNum], - ['GlobalApiRateLimitDuration', operationFields.GlobalApiRateLimitDuration], - ['GlobalWebRateLimitNum', operationFields.GlobalWebRateLimitNum], - ['GlobalWebRateLimitDuration', operationFields.GlobalWebRateLimitDuration], - ['UploadRateLimitNum', operationFields.UploadRateLimitNum], - ['UploadRateLimitDuration', operationFields.UploadRateLimitDuration], - ['DownloadRateLimitNum', operationFields.DownloadRateLimitNum], - ['DownloadRateLimitDuration', operationFields.DownloadRateLimitDuration], - ['CriticalRateLimitNum', operationFields.CriticalRateLimitNum], - ['CriticalRateLimitDuration', operationFields.CriticalRateLimitDuration], - ] as const; - - for (const [key, rawValue] of entries) { - const parsedValue = Number.parseInt(rawValue, 10); - if (Number.isNaN(parsedValue) || parsedValue <= 0) { - throw new Error(`${key} 必须为大于 0 的整数。`); - } - if (key.endsWith('Duration') && parsedValue > 1200) { - throw new Error(`${key} 不能超过 1200 秒。`); - } - } - - await saveOptionEntries(entries.map(([key, value]) => [key, String(Number.parseInt(value, 10))]), '限流设置已保存。'); - }) - } - disabled={busyKey === 'operation-rate-limit'} - > - {busyKey === 'operation-rate-limit' ? '保存中...' : '保存限流设置'} - - } - > -
-
-

全局 API 限流

-

作用于 `/api` - 下的通用请求。

-
- - - setOperationFields((previous) => ({ - ...previous, - GlobalApiRateLimitNum: event.target.value - })) - } - /> - - - - setOperationFields((previous) => ({ - ...previous, - GlobalApiRateLimitDuration: event.target.value - })) - } - /> - -
-
- -
-

全局 Web 限流

-

作用于页面和静态资源请求,过低会更容易触发 - 429。

-
- - - setOperationFields((previous) => ({ - ...previous, - GlobalWebRateLimitNum: event.target.value - })) - } - /> - - - - setOperationFields((previous) => ({ - ...previous, - GlobalWebRateLimitDuration: event.target.value - })) - } - /> - -
-
- -
-

上传 / - 下载限流

-

用于文件上传与下载接口,建议保留相对严格的阈值。

-
- - - setOperationFields((previous) => ({ - ...previous, - UploadRateLimitNum: event.target.value - })) - } - /> - - - - setOperationFields((previous) => ({ - ...previous, - UploadRateLimitDuration: event.target.value - })) - } - /> - - - - setOperationFields((previous) => ({ - ...previous, - DownloadRateLimitNum: event.target.value - })) - } - /> - - - - setOperationFields((previous) => ({ - ...previous, - DownloadRateLimitDuration: event.target.value - })) - } - /> - -
-
- -
-

敏感接口限流

-

用于登录、注册、验证码、重置密码和 - OAuth 等接口。

-
- - - setOperationFields((previous) => ({ - ...previous, - CriticalRateLimitNum: event.target.value - })) - } - /> - - - - setOperationFields((previous) => ({ - ...previous, - CriticalRateLimitDuration: event.target.value - })) - } - /> - -
-
-
-
- -
- ); - } - - return ( -
-
- - void runBusyAction('other-brand', async () => { - await saveOptionEntries( - [ - ['Notice', otherFields.Notice], - ['SystemName', otherFields.SystemName.trim()], - ['HomePageLink', otherFields.HomePageLink.trim()], - ['Footer', otherFields.Footer], - ], - '公告与品牌设置已保存。', - ); - }) - } - disabled={busyKey === 'other-brand'} - > - {busyKey === 'other-brand' ? '保存中...' : '保存基础信息'} - - } - > -
- - - setOtherFields((previous) => ({...previous, SystemName: event.target.value})) - } - placeholder='ATSFlare' - /> - - - - setOtherFields((previous) => ({...previous, HomePageLink: event.target.value})) - } - placeholder='https://example.com' - /> - - - - setOtherFields((previous) => ({...previous, Notice: event.target.value})) - } - placeholder='可在此编写首页公告内容' - /> - - - - setOtherFields((previous) => ({...previous, Footer: event.target.value})) - } - placeholder='留空则使用默认页脚' - /> - -
-
- - - - {busyKey === 'latest-release' ? '检查中...' : '检查更新'} - - - void runBusyAction('other-about', async () => { - await saveOptionEntries([['About', otherFields.About]], '关于页内容已保存。'); - }) - } - disabled={busyKey === 'other-about'} - > - {busyKey === 'other-about' ? '保存中...' : '保存关于内容'} - -
- } - > -
- - - setOtherFields((previous) => ({...previous, About: event.target.value})) - } - placeholder='在这里编写关于 ATSFlare 的介绍内容' - className='min-h-48' - /> - - - {latestRelease ? ( -
-
-

最新版本:{latestRelease.tag_name}

- -
-

- 发布时间:{formatRelativeTime(latestRelease.published_at)} · {formatDateTime(latestRelease.published_at)} -

- - ) : ( - - )} -
- -
-
- ); - }; - - return ( -
- - - {feedback ? : null} - -
- {tabs.map((tab) => ( - - ))} -
- - {renderTabContent()} -
- ); -} +'use client'; + +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { useEffect, useMemo, useState } from 'react'; + +import { EmptyState } from '@/components/feedback/empty-state'; +import { ErrorState } from '@/components/feedback/error-state'; +import { InlineMessage } from '@/components/feedback/inline-message'; +import { LoadingState } from '@/components/feedback/loading-state'; +import { TurnstileWidget } from '@/components/forms/turnstile-widget'; +import { useAuth } from '@/components/providers/auth-provider'; +import { PageHeader } from '@/components/layout/page-header'; +import { AppCard } from '@/components/ui/app-card'; +import { StatusBadge } from '@/components/ui/status-badge'; +import { sendEmailVerification } from '@/features/auth/api/auth'; +import { getPublicStatus } from '@/features/auth/api/public'; +import { + bindEmail, + bindWeChat, + generateAccessToken, + getOptions, + getSettingsProfile, + updateOption, + updateSelf, +} from '@/features/settings/api/settings'; +import type { OptionItem, UpdateSelfPayload } from '@/features/settings/types'; +import { + CodeBlock, + PrimaryButton, + ResourceField, + ResourceInput, + ResourceTextarea, + SecondaryButton, + ToggleField, +} from '@/features/shared/components/resource-primitives'; +import { formatDateTime } from '@/lib/utils/date'; + +const settingsQueryKey = ['settings', 'options'] as const; + +const defaultSystemFields = { + ServerAddress: '', + PasswordLoginEnabled: true, + PasswordRegisterEnabled: true, + EmailVerificationEnabled: false, + GitHubOAuthEnabled: false, + WeChatAuthEnabled: false, + TurnstileCheckEnabled: false, + RegisterEnabled: true, + SMTPServer: '', + SMTPPort: '587', + SMTPAccount: '', + SMTPToken: '', + GitHubClientId: '', + GitHubClientSecret: '', + WeChatServerAddress: '', + WeChatServerToken: '', + WeChatAccountQRCodeImageURL: '', + TurnstileSiteKey: '', + TurnstileSecretKey: '', +}; + +const defaultOperationFields = { + AgentHeartbeatInterval: '30000', + AgentSyncInterval: '30000', + NodeOfflineThreshold: '120000', + AgentUpdateRepo: 'Rain-kl/ATSFlare', + GlobalApiRateLimitNum: '300', + GlobalApiRateLimitDuration: '180', + GlobalWebRateLimitNum: '300', + GlobalWebRateLimitDuration: '180', + UploadRateLimitNum: '50', + UploadRateLimitDuration: '60', + DownloadRateLimitNum: '50', + DownloadRateLimitDuration: '60', + CriticalRateLimitNum: '100', + CriticalRateLimitDuration: '1200', + ServerAddress: '', +}; + +const defaultOtherFields = { + Notice: '', + SystemName: '', + HomePageLink: '', + About: '', + Footer: '', +}; + +const defaultProfileFields: UpdateSelfPayload = { + username: '', + display_name: '', + password: '', +}; + +type FeedbackState = { + tone: 'info' | 'success' | 'danger'; + message: string; +}; + +type SettingsTab = 'personal' | 'operation' | 'system' | 'other'; + +function getErrorMessage(error: unknown) { + return error instanceof Error ? error.message : '请求失败,请稍后重试。'; +} + +function optionsToMap(options: OptionItem[] | undefined) { + return (options ?? []).reduce>( + (accumulator, option) => { + accumulator[option.key] = option.value; + return accumulator; + }, + {}, + ); +} + +function toBoolean(value: string | undefined, fallback: boolean) { + if (value === undefined) { + return fallback; + } + + return value === 'true'; +} + +function normalizeServerUrl(value: string) { + return value.trim().replace(/\/+$/, ''); +} + +function formatDurationLabel(value: string) { + const milliseconds = Number.parseInt(value, 10); + if (Number.isNaN(milliseconds)) { + return value; + } + + if (milliseconds >= 60000) { + return `${milliseconds / 60000} 分钟`; + } + + return `${milliseconds / 1000} 秒`; +} + +function formatSecondsLabel(value: string) { + const seconds = Number.parseInt(value, 10); + if (Number.isNaN(seconds)) { + return value; + } + + if (seconds >= 3600 && seconds % 3600 === 0) { + return `${seconds / 3600} 小时`; + } + + if (seconds >= 60 && seconds % 60 === 0) { + return `${seconds / 60} 分钟`; + } + + return `${seconds} 秒`; +} + +async function copyToClipboard(value: string) { + await navigator.clipboard.writeText(value); +} + +export function SettingsPage() { + const queryClient = useQueryClient(); + const { refreshUser, user } = useAuth(); + const [activeTab, setActiveTab] = useState('personal'); + const [feedback, setFeedback] = useState(null); + const [busyKey, setBusyKey] = useState(null); + const [profileFields, setProfileFields] = useState(defaultProfileFields); + const [systemFields, setSystemFields] = useState(defaultSystemFields); + const [operationFields, setOperationFields] = useState( + defaultOperationFields, + ); + const [otherFields, setOtherFields] = useState(defaultOtherFields); + const [accessToken, setAccessToken] = useState(''); + const [wechatCode, setWeChatCode] = useState(''); + const [emailAddress, setEmailAddress] = useState(''); + const [emailCode, setEmailCode] = useState(''); + const [emailTurnstileToken, setEmailTurnstileToken] = useState(''); + + const isRoot = (user?.role ?? 0) >= 100; + + const publicStatusQuery = useQuery({ + queryKey: ['public-status'], + queryFn: getPublicStatus, + }); + + const profileQuery = useQuery({ + queryKey: ['settings', 'profile'], + queryFn: getSettingsProfile, + }); + + const optionsQuery = useQuery({ + queryKey: settingsQueryKey, + queryFn: getOptions, + enabled: isRoot, + }); + + useEffect(() => { + if (profileQuery.data) { + setProfileFields({ + username: profileQuery.data.username, + display_name: profileQuery.data.display_name || '', + password: '', + }); + setEmailAddress(profileQuery.data.email || ''); + } + }, [profileQuery.data]); + + useEffect(() => { + const publicStatus = publicStatusQuery.data; + if (!publicStatus) { + return; + } + + setSystemFields((previous) => ({ + ...previous, + ServerAddress: publicStatus.server_address || previous.ServerAddress, + GitHubClientId: publicStatus.github_client_id || previous.GitHubClientId, + WeChatAccountQRCodeImageURL: + publicStatus.wechat_qrcode || previous.WeChatAccountQRCodeImageURL, + TurnstileSiteKey: + publicStatus.turnstile_site_key || previous.TurnstileSiteKey, + })); + setOtherFields((previous) => ({ + ...previous, + SystemName: publicStatus.system_name || previous.SystemName, + HomePageLink: publicStatus.home_page_link || previous.HomePageLink, + Footer: publicStatus.footer_html || previous.Footer, + })); + setOperationFields((previous) => ({ + ...previous, + ServerAddress: publicStatus.server_address || previous.ServerAddress, + })); + }, [publicStatusQuery.data]); + + useEffect(() => { + if (!optionsQuery.data) { + return; + } + + const optionMap = optionsToMap(optionsQuery.data); + + setSystemFields({ + ServerAddress: optionMap.ServerAddress ?? '', + PasswordLoginEnabled: toBoolean(optionMap.PasswordLoginEnabled, true), + PasswordRegisterEnabled: toBoolean( + optionMap.PasswordRegisterEnabled, + true, + ), + EmailVerificationEnabled: toBoolean( + optionMap.EmailVerificationEnabled, + false, + ), + GitHubOAuthEnabled: toBoolean(optionMap.GitHubOAuthEnabled, false), + WeChatAuthEnabled: toBoolean(optionMap.WeChatAuthEnabled, false), + TurnstileCheckEnabled: toBoolean(optionMap.TurnstileCheckEnabled, false), + RegisterEnabled: toBoolean(optionMap.RegisterEnabled, true), + SMTPServer: optionMap.SMTPServer ?? '', + SMTPPort: optionMap.SMTPPort ?? '587', + SMTPAccount: optionMap.SMTPAccount ?? '', + SMTPToken: '', + GitHubClientId: optionMap.GitHubClientId ?? '', + GitHubClientSecret: '', + WeChatServerAddress: optionMap.WeChatServerAddress ?? '', + WeChatServerToken: '', + WeChatAccountQRCodeImageURL: optionMap.WeChatAccountQRCodeImageURL ?? '', + TurnstileSiteKey: optionMap.TurnstileSiteKey ?? '', + TurnstileSecretKey: '', + }); + + setOperationFields({ + AgentHeartbeatInterval: optionMap.AgentHeartbeatInterval ?? '30000', + AgentSyncInterval: optionMap.AgentSyncInterval ?? '30000', + NodeOfflineThreshold: optionMap.NodeOfflineThreshold ?? '120000', + AgentUpdateRepo: optionMap.AgentUpdateRepo ?? 'Rain-kl/ATSFlare', + GlobalApiRateLimitNum: optionMap.GlobalApiRateLimitNum ?? '300', + GlobalApiRateLimitDuration: optionMap.GlobalApiRateLimitDuration ?? '180', + GlobalWebRateLimitNum: optionMap.GlobalWebRateLimitNum ?? '300', + GlobalWebRateLimitDuration: optionMap.GlobalWebRateLimitDuration ?? '180', + UploadRateLimitNum: optionMap.UploadRateLimitNum ?? '50', + UploadRateLimitDuration: optionMap.UploadRateLimitDuration ?? '60', + DownloadRateLimitNum: optionMap.DownloadRateLimitNum ?? '50', + DownloadRateLimitDuration: optionMap.DownloadRateLimitDuration ?? '60', + CriticalRateLimitNum: optionMap.CriticalRateLimitNum ?? '100', + CriticalRateLimitDuration: optionMap.CriticalRateLimitDuration ?? '1200', + ServerAddress: + optionMap.ServerAddress ?? publicStatusQuery.data?.server_address ?? '', + }); + + setOtherFields({ + Notice: optionMap.Notice ?? '', + SystemName: optionMap.SystemName ?? '', + HomePageLink: optionMap.HomePageLink ?? '', + About: optionMap.About ?? '', + Footer: optionMap.Footer ?? '', + }); + }, [optionsQuery.data, publicStatusQuery.data?.server_address]); + + const accessTokenMutation = useMutation({ + mutationFn: generateAccessToken, + onSuccess: (token) => { + setAccessToken(token); + setFeedback({ + tone: 'success', + message: '访问令牌已重置,并已在当前页面展示。', + }); + }, + onError: (error) => { + setFeedback({ tone: 'danger', message: getErrorMessage(error) }); + }, + }); + + const tabs = useMemo( + () => [ + { + key: 'personal' as const, + label: '个人设置', + description: '更新个人资料、绑定账号与访问令牌。', + }, + ...(isRoot + ? [ + { + key: 'operation' as const, + label: '运维设置', + description: 'Agent 参数、更新仓库与接口限流设置。', + }, + { + key: 'system' as const, + label: '系统设置', + description: '登录注册、SMTP、OAuth 与风控开关。', + }, + { + key: 'other' as const, + label: '其他设置', + description: '公告、关于与品牌信息。', + }, + ] + : []), + ], + [isRoot], + ); + + const runBusyAction = async (key: string, action: () => Promise) => { + setBusyKey(key); + setFeedback(null); + + try { + await action(); + } catch (error) { + setFeedback({ tone: 'danger', message: getErrorMessage(error) }); + } finally { + setBusyKey(null); + } + }; + + const saveOptionEntries = async ( + entries: Array<[string, string]>, + successMessage: string, + ) => { + for (const [key, value] of entries) { + await updateOption(key, value); + } + + await queryClient.invalidateQueries({ queryKey: settingsQueryKey }); + await queryClient.invalidateQueries({ queryKey: ['public-status'] }); + setFeedback({ tone: 'success', message: successMessage }); + }; + + const handleProfileSave = () => { + void runBusyAction('profile', async () => { + await updateSelf({ + username: profileFields.username.trim(), + display_name: profileFields.display_name.trim(), + password: profileFields.password, + }); + await Promise.all([ + queryClient.invalidateQueries({ queryKey: ['settings', 'profile'] }), + refreshUser(), + ]); + setProfileFields((previous) => ({ ...previous, password: '' })); + setFeedback({ tone: 'success', message: '个人资料已更新。' }); + }); + }; + + const handleEmailVerification = () => { + if (!emailAddress.trim()) { + setFeedback({ tone: 'danger', message: '请输入要绑定的邮箱地址。' }); + return; + } + + if (publicStatusQuery.data?.turnstile_check && !emailTurnstileToken) { + setFeedback({ tone: 'info', message: '请先完成人机验证。' }); + return; + } + + void runBusyAction('email-send', async () => { + await sendEmailVerification( + emailAddress.trim(), + emailTurnstileToken || undefined, + ); + setFeedback({ tone: 'success', message: '验证码已发送,请检查邮箱。' }); + }); + }; + + const handleBindEmail = () => { + if (!emailAddress.trim() || !emailCode.trim()) { + setFeedback({ tone: 'danger', message: '请输入邮箱地址和验证码。' }); + return; + } + + void runBusyAction('email-bind', async () => { + await bindEmail(emailAddress.trim(), emailCode.trim()); + setEmailCode(''); + await Promise.all([ + queryClient.invalidateQueries({ queryKey: ['settings', 'profile'] }), + refreshUser(), + ]); + setFeedback({ tone: 'success', message: '邮箱已绑定。' }); + }); + }; + + const handleBindWeChat = () => { + if (!wechatCode.trim()) { + setFeedback({ tone: 'danger', message: '请输入微信验证码。' }); + return; + } + + void runBusyAction('wechat-bind', async () => { + await bindWeChat(wechatCode.trim()); + setWeChatCode(''); + await Promise.all([ + queryClient.invalidateQueries({ queryKey: ['settings', 'profile'] }), + refreshUser(), + ]); + setFeedback({ tone: 'success', message: '微信账号已绑定。' }); + }); + }; + + const handleToggleOption = ( + key: keyof typeof systemFields, + nextValue: boolean, + ) => { + setSystemFields((previous) => ({ ...previous, [key]: nextValue })); + + void runBusyAction(`toggle-${key}`, async () => { + await saveOptionEntries([[key, String(nextValue)]], '系统开关已更新。'); + }); + }; + + const renderTabContent = () => { + if (profileQuery.isLoading || publicStatusQuery.isLoading) { + return ; + } + + if (profileQuery.isError) { + return ( + + ); + } + + if (publicStatusQuery.isError) { + return ( + + ); + } + + const publicStatus = publicStatusQuery.data; + const profile = profileQuery.data; + + if (!publicStatus || !profile) { + return ( + + ); + } + + if (activeTab === 'personal') { + return ( +
+
+ + {busyKey === 'profile' ? '保存中...' : '保存资料'} + + } + > +
+ + + setProfileFields((previous) => ({ + ...previous, + username: event.target.value, + })) + } + placeholder="请输入用户名" + /> + + + + + setProfileFields((previous) => ({ + ...previous, + display_name: event.target.value, + })) + } + placeholder="请输入显示名称" + /> + + + + + setProfileFields((previous) => ({ + ...previous, + password: event.target.value, + })) + } + placeholder="请输入新密码" + /> + +
+
+ + accessTokenMutation.mutate()} + disabled={accessTokenMutation.isPending} + > + {accessTokenMutation.isPending ? '生成中...' : '重置令牌'} + + } + > +
+
+
+

+ 当前角色 +

+
+ +
+
+
+

+ 已绑定邮箱 +

+

+ {profile.email || '未绑定'} +

+
+
+ + {accessToken ? ( +
+ + {accessToken} + + void copyToClipboard(accessToken)} + > + 复制令牌 + +
+ ) : ( + + )} +
+
+
+ + +
+
+
+

+ GitHub 账号 +

+

+ 当前状态: + {profile.github_id + ? `已绑定 ${profile.github_id}` + : '未绑定'} +

+
+ + window.open( + `https://github.com/login/oauth/authorize?client_id=${publicStatus.github_client_id}&scope=user:email`, + '_blank', + 'noopener,noreferrer', + ) + } + disabled={ + !publicStatus.github_oauth || !publicStatus.github_client_id + } + > + {publicStatus.github_oauth + ? '绑定 GitHub' + : '未启用 GitHub OAuth'} + +
+ +
+
+

+ 微信账号 +

+

+ 当前状态: + {profile.wechat_id + ? `已绑定 ${profile.wechat_id}` + : '未绑定'} +

+
+ {publicStatus.wechat_login && publicStatus.wechat_qrcode ? ( + // eslint-disable-next-line @next/next/no-img-element + 微信绑定二维码 + ) : null} + + setWeChatCode(event.target.value)} + placeholder="请输入微信验证码" + /> + + + {busyKey === 'wechat-bind' ? '绑定中...' : '绑定微信'} + +
+ +
+
+

+ 邮箱地址 +

+

+ 当前状态: + {profile.email ? `已绑定 ${profile.email}` : '未绑定'} +

+
+
+ + setEmailAddress(event.target.value)} + placeholder="请输入邮箱地址" + /> + + + setEmailCode(event.target.value)} + placeholder="请输入邮箱验证码" + /> + + {publicStatus.turnstile_check ? ( + publicStatus.turnstile_site_key ? ( + setEmailTurnstileToken(token)} + onExpire={() => setEmailTurnstileToken('')} + onError={() => setEmailTurnstileToken('')} + /> + ) : ( + + ) + ) : null} +
+ + {busyKey === 'email-send' ? '发送中...' : '发送验证码'} + + + {busyKey === 'email-bind' ? '绑定中...' : '绑定邮箱'} + +
+
+
+
+
+
+ ); + } + + if (!isRoot) { + return ( + + ); + } + + if (optionsQuery.isLoading) { + return ; + } + + if (optionsQuery.isError) { + return ( + + ); + } + + if (activeTab === 'operation') { + return ( +
+
+ + + window.open( + '/swagger/index.html', + '_blank', + 'noopener,noreferrer', + ) + } + > + 打开接口文档 + + + void runBusyAction('operation-intervals', async () => { + const heartbeat = Number.parseInt( + operationFields.AgentHeartbeatInterval, + 10, + ); + const sync = Number.parseInt( + operationFields.AgentSyncInterval, + 10, + ); + const offline = Number.parseInt( + operationFields.NodeOfflineThreshold, + 10, + ); + + if (Number.isNaN(heartbeat) || heartbeat < 5000) { + throw new Error('心跳间隔不能小于 5000 毫秒。'); + } + if (Number.isNaN(sync) || sync < 5000) { + throw new Error('同步间隔不能小于 5000 毫秒。'); + } + if (Number.isNaN(offline) || offline < 10000) { + throw new Error('离线阈值不能小于 10000 毫秒。'); + } + + await saveOptionEntries( + [ + ['AgentHeartbeatInterval', String(heartbeat)], + ['AgentSyncInterval', String(sync)], + ['NodeOfflineThreshold', String(offline)], + ], + 'Agent 运行参数已保存。', + ); + }) + } + disabled={busyKey === 'operation-intervals'} + > + {busyKey === 'operation-intervals' + ? '保存中...' + : '保存运行参数'} + +
+ } + > +
+ + + setOperationFields((previous) => ({ + ...previous, + AgentHeartbeatInterval: event.target.value, + })) + } + /> + + + + setOperationFields((previous) => ({ + ...previous, + AgentSyncInterval: event.target.value, + })) + } + /> + + + + setOperationFields((previous) => ({ + ...previous, + NodeOfflineThreshold: event.target.value, + })) + } + /> + +
+ + + + void runBusyAction('operation-repo', async () => { + await saveOptionEntries( + [ + [ + 'AgentUpdateRepo', + operationFields.AgentUpdateRepo.trim(), + ], + ], + 'Agent 更新仓库已保存。', + ); + }) + } + disabled={busyKey === 'operation-repo'} + > + {busyKey === 'operation-repo' ? '保存中...' : '保存更新仓库'} + + } + > + + + setOperationFields((previous) => ({ + ...previous, + AgentUpdateRepo: event.target.value, + })) + } + placeholder="Rain-kl/ATSFlare" + /> + + + +
+
+

+ 服务端版本 +

+

+ {publicStatus.version} +

+
+
+

+ Server 启动时间 +

+

+ {formatDateTime(new Date(publicStatus.start_time * 1000))} +

+
+
+

+ 运行模式 +

+

+ 静态导出 + Go Server 托管 +

+
+
+

+ 版本入口 +

+

+ 点击顶栏“版本”可检查更新、查看 Release + Notes,并直接触发服务端升级。 +

+
+
+
+
+ + ); + } + + if (activeTab === 'system') { + return ( +
+ +
+ + handleToggleOption('PasswordLoginEnabled', checked) + } + disabled={busyKey === 'toggle-PasswordLoginEnabled'} + /> + + handleToggleOption('PasswordRegisterEnabled', checked) + } + disabled={busyKey === 'toggle-PasswordRegisterEnabled'} + /> + + handleToggleOption('EmailVerificationEnabled', checked) + } + disabled={busyKey === 'toggle-EmailVerificationEnabled'} + /> + + handleToggleOption('GitHubOAuthEnabled', checked) + } + disabled={busyKey === 'toggle-GitHubOAuthEnabled'} + /> + + handleToggleOption('WeChatAuthEnabled', checked) + } + disabled={busyKey === 'toggle-WeChatAuthEnabled'} + /> + + handleToggleOption('TurnstileCheckEnabled', checked) + } + disabled={busyKey === 'toggle-TurnstileCheckEnabled'} + /> + + handleToggleOption('RegisterEnabled', checked) + } + disabled={busyKey === 'toggle-RegisterEnabled'} + /> +
+
+ +
+ + void runBusyAction('system-core', async () => { + await saveOptionEntries( + [ + [ + 'ServerAddress', + normalizeServerUrl(systemFields.ServerAddress), + ], + ['SMTPServer', systemFields.SMTPServer.trim()], + ['SMTPPort', systemFields.SMTPPort.trim()], + ['SMTPAccount', systemFields.SMTPAccount.trim()], + ['SMTPToken', systemFields.SMTPToken.trim()], + ], + '通用与 SMTP 设置已保存。', + ); + }) + } + disabled={busyKey === 'system-core'} + > + {busyKey === 'system-core' ? '保存中...' : '保存通用设置'} + + } + > +
+ + + setSystemFields((previous) => ({ + ...previous, + ServerAddress: event.target.value, + })) + } + placeholder="https://yourdomain.com" + /> + +
+ + + setSystemFields((previous) => ({ + ...previous, + SMTPServer: event.target.value, + })) + } + placeholder="smtp.qq.com" + /> + + + + setSystemFields((previous) => ({ + ...previous, + SMTPPort: event.target.value, + })) + } + placeholder="587" + /> + + + + setSystemFields((previous) => ({ + ...previous, + SMTPAccount: event.target.value, + })) + } + placeholder="name@example.com" + /> + + + + setSystemFields((previous) => ({ + ...previous, + SMTPToken: event.target.value, + })) + } + placeholder="请输入新的 SMTP 凭证" + /> + +
+
+
+ + + void runBusyAction('system-integrations', async () => { + await saveOptionEntries( + [ + [ + 'GitHubClientId', + systemFields.GitHubClientId.trim(), + ], + [ + 'GitHubClientSecret', + systemFields.GitHubClientSecret.trim(), + ], + [ + 'WeChatServerAddress', + normalizeServerUrl( + systemFields.WeChatServerAddress, + ), + ], + [ + 'WeChatServerToken', + systemFields.WeChatServerToken.trim(), + ], + [ + 'WeChatAccountQRCodeImageURL', + systemFields.WeChatAccountQRCodeImageURL.trim(), + ], + [ + 'TurnstileSiteKey', + systemFields.TurnstileSiteKey.trim(), + ], + [ + 'TurnstileSecretKey', + systemFields.TurnstileSecretKey.trim(), + ], + ], + '第三方集成设置已保存。', + ); + }) + } + disabled={busyKey === 'system-integrations'} + > + {busyKey === 'system-integrations' + ? '保存中...' + : '保存集成设置'} + + } + > +
+
+ + + setSystemFields((previous) => ({ + ...previous, + GitHubClientId: event.target.value, + })) + } + /> + + + + setSystemFields((previous) => ({ + ...previous, + GitHubClientSecret: event.target.value, + })) + } + /> + + + + setSystemFields((previous) => ({ + ...previous, + WeChatServerAddress: event.target.value, + })) + } + /> + + + + setSystemFields((previous) => ({ + ...previous, + WeChatServerToken: event.target.value, + })) + } + /> + + + + setSystemFields((previous) => ({ + ...previous, + WeChatAccountQRCodeImageURL: event.target.value, + })) + } + /> + + + + setSystemFields((previous) => ({ + ...previous, + TurnstileSiteKey: event.target.value, + })) + } + /> + + + + setSystemFields((previous) => ({ + ...previous, + TurnstileSecretKey: event.target.value, + })) + } + /> + +
+
+
+
+ + void runBusyAction('operation-rate-limit', async () => { + const entries = [ + [ + 'GlobalApiRateLimitNum', + operationFields.GlobalApiRateLimitNum, + ], + [ + 'GlobalApiRateLimitDuration', + operationFields.GlobalApiRateLimitDuration, + ], + [ + 'GlobalWebRateLimitNum', + operationFields.GlobalWebRateLimitNum, + ], + [ + 'GlobalWebRateLimitDuration', + operationFields.GlobalWebRateLimitDuration, + ], + [ + 'UploadRateLimitNum', + operationFields.UploadRateLimitNum, + ], + [ + 'UploadRateLimitDuration', + operationFields.UploadRateLimitDuration, + ], + [ + 'DownloadRateLimitNum', + operationFields.DownloadRateLimitNum, + ], + [ + 'DownloadRateLimitDuration', + operationFields.DownloadRateLimitDuration, + ], + [ + 'CriticalRateLimitNum', + operationFields.CriticalRateLimitNum, + ], + [ + 'CriticalRateLimitDuration', + operationFields.CriticalRateLimitDuration, + ], + ] as const; + + for (const [key, rawValue] of entries) { + const parsedValue = Number.parseInt(rawValue, 10); + if (Number.isNaN(parsedValue) || parsedValue <= 0) { + throw new Error(`${key} 必须为大于 0 的整数。`); + } + if (key.endsWith('Duration') && parsedValue > 1200) { + throw new Error(`${key} 不能超过 1200 秒。`); + } + } + + await saveOptionEntries( + entries.map(([key, value]) => [ + key, + String(Number.parseInt(value, 10)), + ]), + '限流设置已保存。', + ); + }) + } + disabled={busyKey === 'operation-rate-limit'} + > + {busyKey === 'operation-rate-limit' + ? '保存中...' + : '保存限流设置'} + + } + > +
+
+

+ 全局 API 限流 +

+

+ 作用于 `/api` 下的通用请求。 +

+
+ + + setOperationFields((previous) => ({ + ...previous, + GlobalApiRateLimitNum: event.target.value, + })) + } + /> + + + + setOperationFields((previous) => ({ + ...previous, + GlobalApiRateLimitDuration: event.target.value, + })) + } + /> + +
+
+ +
+

+ 全局 Web 限流 +

+

+ 作用于页面和静态资源请求,过低会更容易触发 429。 +

+
+ + + setOperationFields((previous) => ({ + ...previous, + GlobalWebRateLimitNum: event.target.value, + })) + } + /> + + + + setOperationFields((previous) => ({ + ...previous, + GlobalWebRateLimitDuration: event.target.value, + })) + } + /> + +
+
+ +
+

+ 上传 / 下载限流 +

+

+ 用于文件上传与下载接口,建议保留相对严格的阈值。 +

+
+ + + setOperationFields((previous) => ({ + ...previous, + UploadRateLimitNum: event.target.value, + })) + } + /> + + + + setOperationFields((previous) => ({ + ...previous, + UploadRateLimitDuration: event.target.value, + })) + } + /> + + + + setOperationFields((previous) => ({ + ...previous, + DownloadRateLimitNum: event.target.value, + })) + } + /> + + + + setOperationFields((previous) => ({ + ...previous, + DownloadRateLimitDuration: event.target.value, + })) + } + /> + +
+
+ +
+

+ 敏感接口限流 +

+

+ 用于登录、注册、验证码、重置密码和 OAuth 等接口。 +

+
+ + + setOperationFields((previous) => ({ + ...previous, + CriticalRateLimitNum: event.target.value, + })) + } + /> + + + + setOperationFields((previous) => ({ + ...previous, + CriticalRateLimitDuration: event.target.value, + })) + } + /> + +
+
+
+
+
+ ); + } + + return ( +
+
+ + void runBusyAction('other-brand', async () => { + await saveOptionEntries( + [ + ['Notice', otherFields.Notice], + ['SystemName', otherFields.SystemName.trim()], + ['HomePageLink', otherFields.HomePageLink.trim()], + ['Footer', otherFields.Footer], + ], + '公告与品牌设置已保存。', + ); + }) + } + disabled={busyKey === 'other-brand'} + > + {busyKey === 'other-brand' ? '保存中...' : '保存基础信息'} + + } + > +
+ + + setOtherFields((previous) => ({ + ...previous, + SystemName: event.target.value, + })) + } + placeholder="ATSFlare" + /> + + + + setOtherFields((previous) => ({ + ...previous, + HomePageLink: event.target.value, + })) + } + placeholder="https://example.com" + /> + + + + setOtherFields((previous) => ({ + ...previous, + Notice: event.target.value, + })) + } + placeholder="可在此编写首页公告内容" + /> + + + + setOtherFields((previous) => ({ + ...previous, + Footer: event.target.value, + })) + } + placeholder="留空则使用默认页脚" + /> + +
+
+ + + void runBusyAction('other-about', async () => { + await saveOptionEntries( + [['About', otherFields.About]], + '关于页内容已保存。', + ); + }) + } + disabled={busyKey === 'other-about'} + > + {busyKey === 'other-about' ? '保存中...' : '保存关于内容'} + + } + > +
+ + + setOtherFields((previous) => ({ + ...previous, + About: event.target.value, + })) + } + placeholder="在这里编写关于 ATSFlare 的介绍内容" + className="min-h-48" + /> + +
+
+
+
+ ); + }; + + return ( +
+ + + {feedback ? ( + + ) : null} + +
+ {tabs.map((tab) => ( + + ))} +
+ + {renderTabContent()} +
+ ); +} diff --git a/atsf_server/web/features/settings/types.ts b/atsf_server/web/features/settings/types.ts index b3250517..1942eb6a 100644 --- a/atsf_server/web/features/settings/types.ts +++ b/atsf_server/web/features/settings/types.ts @@ -1,25 +1,14 @@ -import type { AuthUser } from '@/types/auth'; - -export interface OptionItem { - key: string; - value: string; -} - -export interface LatestReleaseInfo { - tag_name: string; - body: string; - html_url: string; - published_at: string; -} - -export interface BootstrapTokenPayload { - discovery_token: string; -} - -export interface UpdateSelfPayload { - username: string; - display_name: string; - password: string; -} - -export type SettingsProfile = AuthUser; +import type { AuthUser } from '@/types/auth'; + +export interface OptionItem { + key: string; + value: string; +} + +export interface UpdateSelfPayload { + username: string; + display_name: string; + password: string; +} + +export type SettingsProfile = AuthUser; diff --git a/atsf_server/web/features/update/api/update.ts b/atsf_server/web/features/update/api/update.ts new file mode 100644 index 00000000..ecc9128e --- /dev/null +++ b/atsf_server/web/features/update/api/update.ts @@ -0,0 +1,13 @@ +import { apiRequest } from '@/lib/api/client'; + +import type { LatestReleaseInfo } from '@/features/update/types'; + +export function getLatestRelease() { + return apiRequest('/update/latest-release'); +} + +export function upgradeServer() { + return apiRequest('/update/upgrade', { + method: 'POST', + }); +} diff --git a/atsf_server/web/features/update/components/version-upgrade-modal.tsx b/atsf_server/web/features/update/components/version-upgrade-modal.tsx new file mode 100644 index 00000000..69bc980e --- /dev/null +++ b/atsf_server/web/features/update/components/version-upgrade-modal.tsx @@ -0,0 +1,188 @@ +'use client'; + +import { marked } from 'marked'; + +import { EmptyState } from '@/components/feedback/empty-state'; +import { ErrorState } from '@/components/feedback/error-state'; +import { LoadingState } from '@/components/feedback/loading-state'; +import { AppCard } from '@/components/ui/app-card'; +import { AppModal } from '@/components/ui/app-modal'; +import { StatusBadge } from '@/components/ui/status-badge'; +import type { LatestReleaseInfo } from '@/features/update/types'; +import { + PrimaryButton, + SecondaryButton, +} from '@/features/shared/components/resource-primitives'; +import { formatDateTime, formatRelativeTime } from '@/lib/utils/date'; + +interface VersionUpgradeModalProps { + isOpen: boolean; + onClose: () => void; + currentVersion: string; + frontendVersion: string; + startTime?: number; + release: LatestReleaseInfo | null | undefined; + isLoading: boolean; + errorMessage?: string; + canUpgrade: boolean; + isChecking: boolean; + isUpgrading: boolean; + onRefresh: () => void; + onUpgrade: () => void; +} + +function getUpgradeBadge(release: LatestReleaseInfo | null | undefined) { + if (!release) { + return { label: '未检查', variant: 'info' as const }; + } + if (release.in_progress) { + return { label: '升级中', variant: 'warning' as const }; + } + if (release.has_update) { + return { label: '可升级', variant: 'warning' as const }; + } + return { label: '最新', variant: 'success' as const }; +} + +export function VersionUpgradeModal({ + isOpen, + onClose, + currentVersion, + frontendVersion, + startTime, + release, + isLoading, + errorMessage, + canUpgrade, + isChecking, + isUpgrading, + onRefresh, + onUpgrade, +}: VersionUpgradeModalProps) { + const upgradeBadge = getUpgradeBadge(release); + + return ( + + + {isChecking ? '检查中...' : '检查更新'} + + {canUpgrade ? ( + + {isUpgrading + ? '升级中...' + : release?.in_progress + ? '升级中...' + : '立即升级'} + + ) : null} + + } + > +
+
+ +

+ {frontendVersion} +

+
+ +
+

+ {currentVersion || 'unknown'} +

+ +
+
+ +

+ {release?.tag_name || '未检查'} +

+
+ +

+ {startTime ? formatDateTime(new Date(startTime * 1000)) : '未知'} +

+
+
+ + {isLoading ? : null} + {!isLoading && errorMessage ? ( + + ) : null} + {!isLoading && !errorMessage && !release ? ( + + ) : null} + {!isLoading && !errorMessage && release ? ( + +
+
+ + {!release.upgrade_supported ? ( + + ) : null} + {release.in_progress ? ( + + ) : null} +
+ + + ) : null} +
+ + ); +} diff --git a/atsf_server/web/features/update/types.ts b/atsf_server/web/features/update/types.ts new file mode 100644 index 00000000..5da03941 --- /dev/null +++ b/atsf_server/web/features/update/types.ts @@ -0,0 +1,10 @@ +export interface LatestReleaseInfo { + tag_name: string; + body: string; + html_url: string; + published_at: string; + current_version: string; + has_update: boolean; + upgrade_supported: boolean; + in_progress: boolean; +} diff --git a/docs/deployment.md b/docs/deployment.md index 1a2e9aae..15f05f6b 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -84,12 +84,18 @@ volumes: docker compose up -d ``` -说明: - -* `SESSION_SECRET` 必须替换为随机字符串 -* SQLite 数据文件持久化到 Docker volume `atsflare-data` -* 镜像默认监听容器内 `3000` 端口 -* 若要固定版本,可将 `latest` 替换为具体 tag,例如 `ghcr.io/rain-kl/atsflare:v0.3.0` +说明: + +* `SESSION_SECRET` 必须替换为随机字符串 +* SQLite 数据文件持久化到 Docker volume `atsflare-data` +* 镜像默认监听容器内 `3000` 端口 +* 若要固定版本,可将 `latest` 替换为具体 tag,例如 `ghcr.io/rain-kl/atsflare:v0.3.0` + +版本升级说明: + +* Root 用户可在管理端顶栏点击「版本」检查 GitHub 最新 Release +* 当前运行的是 Release 二进制且二进制目录可写时,可直接在弹窗内触发 Server 自升级 +* 自升级会下载匹配当前平台的 `atsflare-server-*` 资产,替换当前二进制并自动重启进程 ### 2.4 首次登录 diff --git a/docs/design.md b/docs/design.md index 87c3f59e..e19a06bf 100644 --- a/docs/design.md +++ b/docs/design.md @@ -20,12 +20,13 @@ ATSFlare 当前仍定位为**内部自用的反向代理控制面**,不是面 * 反代规则管理 * 配置渲染、发布、激活与回滚 -* Agent 心跳、同步、应用结果上报 -* Nginx 配置写入、校验、reload 与失败回滚 -* HTTPS/TLS 路由支持 -* 证书托管与域名管理 -* 节点预创建、节点专属 `agent_token`、全局 `discovery_token` -* 配置预览与变更摘要 +* Agent 心跳、同步、应用结果上报 +* Nginx 配置写入、校验、reload 与失败回滚 +* HTTPS/TLS 路由支持 +* 证书托管与域名管理 +* 节点预创建、节点专属 `agent_token`、全局 `discovery_token` +* 配置预览与变更摘要 +* Server 版本检查与自升级 当前默认工作方式: diff --git a/docs/development-plan.md b/docs/development-plan.md index e43e2e4f..c50afd1e 100644 --- a/docs/development-plan.md +++ b/docs/development-plan.md @@ -140,16 +140,18 @@ 实施步骤: -1. 节点列表时间显示改为友好格式 -2. 节点状态颜色标识 -3. Discovery Token 在运维设置面板中可查看和重新生成 -4. Agent 部署命令一键复制 - -验收标准: - -* 时间显示为友好的相对时间(如「2 分钟前」) -* 节点状态有颜色区分:在线(绿色)、离线(红色)、待接入(黄色) -* curl 部署命令支持一键复制 +1. 节点列表时间显示改为友好格式 +2. 节点状态颜色标识 +3. 节点专属 Agent 部署命令改为节点列表弹窗展示 +4. 版本检查入口迁移到顶栏“版本”,支持可升级提示与 Server 自升级 + +验收标准: + +* 时间显示为友好的相对时间(如「2 分钟前」) +* 节点状态有颜色区分:在线(绿色)、离线(红色)、待接入(黄色) +* 节点列表可弹窗查看并复制节点专属部署命令 +* 顶栏版本入口可检查 GitHub 最新 Release,并在存在新版本时显示可升级提示 +* Root 用户可从顶栏版本弹窗触发 Server 自升级 ### 3.6 并行专项:管理端前端工程改造 @@ -174,4 +176,4 @@ * 每个阶段完成后验证验收标准,再进入下一阶段 * 阶段间的代码不相互依赖时可并行 * 每个阶段完成后运行全量测试 -* 管理端前端改造按 [docs/frontend-revamp-plan.md](./frontend-revamp-plan.md) 单独跟踪阶段状态 \ No newline at end of file +* 管理端前端改造按 [docs/frontend-revamp-plan.md](./frontend-revamp-plan.md) 单独跟踪阶段状态