diff --git a/atsf_server/router/api_phase2_test.go b/atsf_server/router/api_phase2_test.go index 74b52c2a..fc3e722f 100644 --- a/atsf_server/router/api_phase2_test.go +++ b/atsf_server/router/api_phase2_test.go @@ -21,6 +21,7 @@ func TestPhase2RateLimitOptionsHotReload(t *testing.T) { gin.SetMode(gin.TestMode) common.RedisEnabled = false setupTestDB(t) + model.InitOptionMap() oldGlobalApiRateLimitNum := common.GlobalApiRateLimitNum oldGlobalApiRateLimitDuration := common.GlobalApiRateLimitDuration @@ -37,21 +38,21 @@ func TestPhase2RateLimitOptionsHotReload(t *testing.T) { engine.Use(sessions.Sessions("session", cookie.NewStore([]byte("test-secret")))) router.SetApiRouter(engine) - token := prepareRootToken(t) + loginCookie := loginAsRoot(t, engine) - performJSONRequest(t, engine, token, http.MethodPut, "/api/option/", map[string]any{ + performSessionJSONRequest(t, engine, loginCookie, http.MethodPut, "/api/option/", map[string]any{ "key": "GlobalApiRateLimitNum", "value": "450", }) - performJSONRequest(t, engine, token, http.MethodPut, "/api/option/", map[string]any{ + performSessionJSONRequest(t, engine, loginCookie, http.MethodPut, "/api/option/", map[string]any{ "key": "GlobalApiRateLimitDuration", "value": "240", }) - performJSONRequest(t, engine, token, http.MethodPut, "/api/option/", map[string]any{ + performSessionJSONRequest(t, engine, loginCookie, http.MethodPut, "/api/option/", map[string]any{ "key": "CriticalRateLimitNum", "value": "150", }) - performJSONRequest(t, engine, token, http.MethodPut, "/api/option/", map[string]any{ + performSessionJSONRequest(t, engine, loginCookie, http.MethodPut, "/api/option/", map[string]any{ "key": "CriticalRateLimitDuration", "value": "900", }) @@ -69,7 +70,7 @@ func TestPhase2RateLimitOptionsHotReload(t *testing.T) { t.Fatalf("expected CriticalRateLimitDuration to be hot reloaded, got %d", common.CriticalRateLimitDuration) } - resp := performJSONRequest(t, engine, token, http.MethodGet, "/api/option/", nil) + resp := performSessionJSONRequest(t, engine, loginCookie, http.MethodGet, "/api/option/", nil) var options []model.Option decodeResponseData(t, resp, &options) @@ -86,6 +87,74 @@ func TestPhase2RateLimitOptionsHotReload(t *testing.T) { } } +func loginAsRoot(t *testing.T, engine http.Handler) *http.Cookie { + t.Helper() + payload, err := json.Marshal(map[string]any{ + "username": "root", + "password": "123456", + }) + if err != nil { + t.Fatalf("failed to marshal login payload: %v", err) + } + + req := httptest.NewRequest(http.MethodPost, "/api/user/login", bytes.NewReader(payload)) + req.Header.Set("Content-Type", "application/json") + recorder := httptest.NewRecorder() + engine.ServeHTTP(recorder, req) + if recorder.Code != http.StatusOK { + t.Fatalf("unexpected login status %d: %s", recorder.Code, recorder.Body.String()) + } + + var resp apiResponse + if err = json.Unmarshal(recorder.Body.Bytes(), &resp); err != nil { + t.Fatalf("failed to decode login response: %v", err) + } + if !resp.Success { + t.Fatalf("root login failed: %s", resp.Message) + } + + for _, cookie := range recorder.Result().Cookies() { + if cookie.Name == "session" { + return cookie + } + } + t.Fatal("expected session cookie after root login") + return nil +} + +func performSessionJSONRequest(t *testing.T, engine http.Handler, sessionCookie *http.Cookie, method string, path string, body any) apiResponse { + t.Helper() + var payload []byte + var err error + if body != nil { + payload, err = json.Marshal(body) + if err != nil { + t.Fatalf("failed to marshal request body: %v", err) + } + } + + req := httptest.NewRequest(method, path, bytes.NewReader(payload)) + if body != nil { + req.Header.Set("Content-Type", "application/json") + } + req.AddCookie(sessionCookie) + + recorder := httptest.NewRecorder() + engine.ServeHTTP(recorder, req) + if recorder.Code != http.StatusOK { + t.Fatalf("unexpected status %d for %s %s: %s", recorder.Code, method, path, recorder.Body.String()) + } + + var resp apiResponse + if err = json.Unmarshal(recorder.Body.Bytes(), &resp); err != nil { + t.Fatalf("failed to unmarshal response: %v", err) + } + if !resp.Success { + t.Fatalf("request %s %s failed: %s", method, path, resp.Message) + } + return resp +} + func TestPhase2AgentLifecycle(t *testing.T) { gin.SetMode(gin.TestMode) common.RedisEnabled = false diff --git a/atsf_server/web/features/settings/components/settings-page.tsx b/atsf_server/web/features/settings/components/settings-page.tsx index e9e2348a..a9dfd406 100644 --- a/atsf_server/web/features/settings/components/settings-page.tsx +++ b/atsf_server/web/features/settings/components/settings-page.tsx @@ -1,1438 +1,1571 @@ 'use client'; -import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; -import { marked } from 'marked'; -import { useEffect, useMemo, useState } from 'react'; +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 {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, + 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 type {LatestReleaseInfo, OptionItem, UpdateSelfPayload,} from '@/features/settings/types'; import { - CodeBlock, - PrimaryButton, - ResourceField, - ResourceInput, - ResourceTextarea, - SecondaryButton, - ToggleField, + 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'; +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: '', + 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: '', + 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: '', + Notice: '', + SystemName: '', + HomePageLink: '', + About: '', + Footer: '', }; const defaultProfileFields: UpdateSelfPayload = { - username: '', - display_name: '', - password: '', + username: '', + display_name: '', + password: '', }; type FeedbackState = { - tone: 'info' | 'success' | 'danger'; - message: string; + tone: 'info' | 'success' | 'danger'; + message: string; }; type SettingsTab = 'personal' | 'operation' | 'system' | 'other'; function getErrorMessage(error: unknown) { - return error instanceof Error ? error.message : '请求失败,请稍后重试。'; + return error instanceof Error ? error.message : '请求失败,请稍后重试。'; } function optionsToMap(options: OptionItem[] | undefined) { - return (options ?? []).reduce>((accumulator, option) => { - accumulator[option.key] = option.value; - return accumulator; - }, {}); + return (options ?? []).reduce>((accumulator, option) => { + accumulator[option.key] = option.value; + return accumulator; + }, {}); } function toBoolean(value: string | undefined, fallback: boolean) { - if (value === undefined) { - return fallback; - } + if (value === undefined) { + return fallback; + } - return value === 'true'; + return value === 'true'; } function normalizeServerUrl(value: string) { - return value.trim().replace(/\/+$/, ''); + return value.trim().replace(/\/+$/, ''); } function formatDurationLabel(value: string) { - const milliseconds = Number.parseInt(value, 10); - if (Number.isNaN(milliseconds)) { - return value; - } + const milliseconds = Number.parseInt(value, 10); + if (Number.isNaN(milliseconds)) { + return value; + } - if (milliseconds >= 60000) { - return `${milliseconds / 60000} 分钟`; - } + if (milliseconds >= 60000) { + return `${milliseconds / 60000} 分钟`; + } - return `${milliseconds / 1000} 秒`; + return `${milliseconds / 1000} 秒`; } function formatSecondsLabel(value: string) { - const seconds = Number.parseInt(value, 10); - if (Number.isNaN(seconds)) { - return value; - } + const seconds = Number.parseInt(value, 10); + if (Number.isNaN(seconds)) { + return value; + } - if (seconds >= 3600 && seconds % 3600 === 0) { - return `${seconds / 3600} 小时`; - } + if (seconds >= 3600 && seconds % 3600 === 0) { + return `${seconds / 3600} 小时`; + } - if (seconds >= 60 && seconds % 60 === 0) { - return `${seconds / 60} 分钟`; - } + if (seconds >= 60 && seconds % 60 === 0) { + return `${seconds / 60} 分钟`; + } - return `${seconds} 秒`; + return `${seconds} 秒`; } function buildDiscoveryCommand(serverUrl: string, discoveryToken: string) { - return [ - `curl -fsSL ${installerScriptUrl} | bash -s -- \\`, - ` --server-url ${normalizeServerUrl(serverUrl)} \\`, - ` --discovery-token ${discoveryToken}`, - ].join('\n'); + 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); + 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 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 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: '', + const publicStatusQuery = useQuery({ + queryKey: ['public-status'], + queryFn: getPublicStatus, }); - 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 ?? '', + const profileQuery = useQuery({ + queryKey: ['settings', 'profile'], + queryFn: getSettingsProfile, }); - setOtherFields({ - Notice: optionMap.Notice ?? '', - SystemName: optionMap.SystemName ?? '', - HomePageLink: optionMap.HomePageLink ?? '', - About: optionMap.About ?? '', - Footer: optionMap.Footer ?? '', + const optionsQuery = useQuery({ + queryKey: settingsQueryKey, + queryFn: getOptions, + enabled: isRoot, }); - }, [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 + 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 || ''); } - } - }, - onError: (error) => { - setFeedback({ tone: 'danger', message: getErrorMessage(error) }); - }, - }); + }, [profileQuery.data]); - const accessTokenMutation = useMutation({ - mutationFn: generateAccessToken, - onSuccess: (token) => { - setAccessToken(token); - setFeedback({ tone: 'success', message: '访问令牌已重置,并已在当前页面展示。' }); - }, - onError: (error) => { - setFeedback({ tone: 'danger', message: getErrorMessage(error) }); - }, - }); + useEffect(() => { + const publicStatus = publicStatusQuery.data; + if (!publicStatus) { + return; + } - const discoveryToken = bootstrapQuery.data?.discovery_token ?? ''; - const discoveryCommand = - isRoot && operationFields.ServerAddress && discoveryToken - ? buildDiscoveryCommand(operationFields.ServerAddress, discoveryToken) - : ''; + 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]); - 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], - ); + useEffect(() => { + if (!optionsQuery.data) { + return; + } - const runBusyAction = async (key: string, action: () => Promise) => { - setBusyKey(key); - setFeedback(null); + const optionMap = optionsToMap(optionsQuery.data); - try { - await action(); - } catch (error) { - setFeedback({ tone: 'danger', message: getErrorMessage(error) }); - } finally { - setBusyKey(null); - } - }; + 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: '', + }); - const saveOptionEntries = async (entries: Array<[string, string]>, successMessage: string) => { - for (const [key, value] of entries) { - await updateOption(key, value); - } + 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 ?? '', + }); - await queryClient.invalidateQueries({ queryKey: settingsQueryKey }); - await queryClient.invalidateQueries({ queryKey: ['public-status'] }); - setFeedback({ tone: 'success', message: successMessage }); - }; + setOtherFields({ + Notice: optionMap.Notice ?? '', + SystemName: optionMap.SystemName ?? '', + HomePageLink: optionMap.HomePageLink ?? '', + About: optionMap.About ?? '', + Footer: optionMap.Footer ?? '', + }); + }, [optionsQuery.data, publicStatusQuery.data?.server_address]); - 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' - /> - - -
- - - 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))]), '限流设置已保存。'); - }) + 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 } - 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 })) - } - /> - -
-
+ }, + onError: (error) => { + setFeedback({tone: 'danger', message: getErrorMessage(error)}); + }, + }); -
-

全局 Web 限流

-

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

-
- - - setOperationFields((previous) => ({ ...previous, GlobalWebRateLimitNum: event.target.value })) - } - /> - - - - setOperationFields((previous) => ({ ...previous, GlobalWebRateLimitDuration: event.target.value })) - } - /> - -
-
+ const accessTokenMutation = useMutation({ + mutationFn: generateAccessToken, + onSuccess: (token) => { + setAccessToken(token); + setFeedback({tone: 'success', message: '访问令牌已重置,并已在当前页面展示。'}); + }, + onError: (error) => { + setFeedback({tone: 'danger', message: getErrorMessage(error)}); + }, + }); -
-

上传 / 下载限流

-

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

-
- - - 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 })) - } - /> - -
-
+ const discoveryToken = bootstrapQuery.data?.discovery_token ?? ''; + const discoveryCommand = + isRoot && operationFields.ServerAddress && discoveryToken + ? buildDiscoveryCommand(operationFields.ServerAddress, discoveryToken) + : ''; -
-

敏感接口限流

-

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

-
- - - setOperationFields((previous) => ({ ...previous, CriticalRateLimitNum: event.target.value })) - } - /> - - - - setOperationFields((previous) => ({ ...previous, CriticalRateLimitDuration: event.target.value })) - } - /> - + 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)} +

+ + ) : ( + + )} +
+
-
-
- -
- - 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 })) - } - /> - -
-
-
-
-
- ); - } + ); + }; 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' - /> - + {feedback ? : null} - {latestRelease ? ( -
-
-

最新版本:{latestRelease.tag_name}

- -
-

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

- - ) : ( - - )} +
+ {tabs.map((tab) => ( + + ))}
- + + {renderTabContent()}
-
); - }; - - return ( -
- - - {feedback ? : null} - -
- {tabs.map((tab) => ( - - ))} -
- - {renderTabContent()} -
- ); }