Refactor: Remove summary cards from various pages and update UI components

- Removed summary cards from Managed Domains, Nodes, Proxy Routes, TLS Certificates, Users pages to streamline UI.
- Updated Nodes and Proxy Routes pages to enhance action buttons and descriptions.
- Added new runtime configuration options for rate limiting in Settings page, allowing for dynamic updates.
- Updated documentation to reflect new runtime configurations and their effects.
- Enhanced unit tests for DashboardOverview to include mock data for comprehensive coverage.
This commit is contained in:
ryan
2026-03-11 19:49:30 +08:00
parent f7d18f712e
commit db3f9b0c0a
20 changed files with 1171 additions and 555 deletions
+5 -5
View File
@@ -72,19 +72,19 @@ var (
// All duration's unit is seconds
// Shouldn't larger then RateLimitKeyExpirationDuration
var (
GlobalApiRateLimitNum = 60
GlobalApiRateLimitNum = 300
GlobalApiRateLimitDuration int64 = 3 * 60
GlobalWebRateLimitNum = 60
GlobalWebRateLimitNum = 300
GlobalWebRateLimitDuration int64 = 3 * 60
UploadRateLimitNum = 10
UploadRateLimitNum = 50
UploadRateLimitDuration int64 = 60
DownloadRateLimitNum = 10
DownloadRateLimitNum = 50
DownloadRateLimitDuration int64 = 60
CriticalRateLimitNum = 20
CriticalRateLimitNum = 100
CriticalRateLimitDuration int64 = 20 * 60
)
+33
View File
@@ -4,11 +4,37 @@ import (
"atsflare/common"
"atsflare/model"
"encoding/json"
"fmt"
"github.com/gin-gonic/gin"
"net/http"
"strconv"
"strings"
)
func validateRateLimitOption(key string, value string) error {
maxDurationSeconds := int(common.RateLimitKeyExpirationDuration.Seconds())
switch key {
case "GlobalApiRateLimitNum", "GlobalWebRateLimitNum", "UploadRateLimitNum", "DownloadRateLimitNum", "CriticalRateLimitNum":
intValue, err := strconv.Atoi(value)
if err != nil || intValue <= 0 {
return fmt.Errorf("%s 必须为大于 0 的整数", key)
}
return nil
case "GlobalApiRateLimitDuration", "GlobalWebRateLimitDuration", "UploadRateLimitDuration", "DownloadRateLimitDuration", "CriticalRateLimitDuration":
intValue, err := strconv.Atoi(value)
if err != nil || intValue <= 0 {
return fmt.Errorf("%s 必须为大于 0 的整数秒", key)
}
if intValue > maxDurationSeconds {
return fmt.Errorf("%s 不能大于 %d 秒", key, maxDurationSeconds)
}
return nil
default:
return nil
}
}
// GetOptions godoc
// @Summary List editable options
// @Tags Options
@@ -81,6 +107,13 @@ func UpdateOption(c *gin.Context) {
return
}
}
if err = validateRateLimitOption(option.Key, option.Value); err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
return
}
err = model.UpdateOption(option.Key, option.Value)
if err != nil {
c.JSON(http.StatusOK, gin.H{
+50
View File
@@ -55,6 +55,16 @@ func InitOptionMap() {
common.OptionMap["AgentSyncInterval"] = strconv.Itoa(common.AgentSyncInterval)
common.OptionMap["NodeOfflineThreshold"] = strconv.Itoa(int(common.NodeOfflineThreshold.Milliseconds()))
common.OptionMap["AgentUpdateRepo"] = common.AgentUpdateRepo
common.OptionMap["GlobalApiRateLimitNum"] = strconv.Itoa(common.GlobalApiRateLimitNum)
common.OptionMap["GlobalApiRateLimitDuration"] = strconv.FormatInt(common.GlobalApiRateLimitDuration, 10)
common.OptionMap["GlobalWebRateLimitNum"] = strconv.Itoa(common.GlobalWebRateLimitNum)
common.OptionMap["GlobalWebRateLimitDuration"] = strconv.FormatInt(common.GlobalWebRateLimitDuration, 10)
common.OptionMap["UploadRateLimitNum"] = strconv.Itoa(common.UploadRateLimitNum)
common.OptionMap["UploadRateLimitDuration"] = strconv.FormatInt(common.UploadRateLimitDuration, 10)
common.OptionMap["DownloadRateLimitNum"] = strconv.Itoa(common.DownloadRateLimitNum)
common.OptionMap["DownloadRateLimitDuration"] = strconv.FormatInt(common.DownloadRateLimitDuration, 10)
common.OptionMap["CriticalRateLimitNum"] = strconv.Itoa(common.CriticalRateLimitNum)
common.OptionMap["CriticalRateLimitDuration"] = strconv.FormatInt(common.CriticalRateLimitDuration, 10)
common.OptionMapRWMutex.Unlock()
options, _ := AllOption()
for _, option := range options {
@@ -165,5 +175,45 @@ func updateOptionMap(key string, value string) {
if value != "" {
common.AgentUpdateRepo = value
}
case "GlobalApiRateLimitNum":
if v, err := strconv.Atoi(value); err == nil && v > 0 {
common.GlobalApiRateLimitNum = v
}
case "GlobalApiRateLimitDuration":
if v, err := strconv.ParseInt(value, 10, 64); err == nil && v > 0 {
common.GlobalApiRateLimitDuration = v
}
case "GlobalWebRateLimitNum":
if v, err := strconv.Atoi(value); err == nil && v > 0 {
common.GlobalWebRateLimitNum = v
}
case "GlobalWebRateLimitDuration":
if v, err := strconv.ParseInt(value, 10, 64); err == nil && v > 0 {
common.GlobalWebRateLimitDuration = v
}
case "UploadRateLimitNum":
if v, err := strconv.Atoi(value); err == nil && v > 0 {
common.UploadRateLimitNum = v
}
case "UploadRateLimitDuration":
if v, err := strconv.ParseInt(value, 10, 64); err == nil && v > 0 {
common.UploadRateLimitDuration = v
}
case "DownloadRateLimitNum":
if v, err := strconv.Atoi(value); err == nil && v > 0 {
common.DownloadRateLimitNum = v
}
case "DownloadRateLimitDuration":
if v, err := strconv.ParseInt(value, 10, 64); err == nil && v > 0 {
common.DownloadRateLimitDuration = v
}
case "CriticalRateLimitNum":
if v, err := strconv.Atoi(value); err == nil && v > 0 {
common.CriticalRateLimitNum = v
}
case "CriticalRateLimitDuration":
if v, err := strconv.ParseInt(value, 10, 64); err == nil && v > 0 {
common.CriticalRateLimitDuration = v
}
}
}
+69
View File
@@ -17,6 +17,75 @@ import (
"time"
)
func TestPhase2RateLimitOptionsHotReload(t *testing.T) {
gin.SetMode(gin.TestMode)
common.RedisEnabled = false
setupTestDB(t)
oldGlobalApiRateLimitNum := common.GlobalApiRateLimitNum
oldGlobalApiRateLimitDuration := common.GlobalApiRateLimitDuration
oldCriticalRateLimitNum := common.CriticalRateLimitNum
oldCriticalRateLimitDuration := common.CriticalRateLimitDuration
t.Cleanup(func() {
common.GlobalApiRateLimitNum = oldGlobalApiRateLimitNum
common.GlobalApiRateLimitDuration = oldGlobalApiRateLimitDuration
common.CriticalRateLimitNum = oldCriticalRateLimitNum
common.CriticalRateLimitDuration = oldCriticalRateLimitDuration
})
engine := gin.New()
engine.Use(sessions.Sessions("session", cookie.NewStore([]byte("test-secret"))))
router.SetApiRouter(engine)
token := prepareRootToken(t)
performJSONRequest(t, engine, token, http.MethodPut, "/api/option/", map[string]any{
"key": "GlobalApiRateLimitNum",
"value": "450",
})
performJSONRequest(t, engine, token, http.MethodPut, "/api/option/", map[string]any{
"key": "GlobalApiRateLimitDuration",
"value": "240",
})
performJSONRequest(t, engine, token, http.MethodPut, "/api/option/", map[string]any{
"key": "CriticalRateLimitNum",
"value": "150",
})
performJSONRequest(t, engine, token, http.MethodPut, "/api/option/", map[string]any{
"key": "CriticalRateLimitDuration",
"value": "900",
})
if common.GlobalApiRateLimitNum != 450 {
t.Fatalf("expected GlobalApiRateLimitNum to be hot reloaded, got %d", common.GlobalApiRateLimitNum)
}
if common.GlobalApiRateLimitDuration != 240 {
t.Fatalf("expected GlobalApiRateLimitDuration to be hot reloaded, got %d", common.GlobalApiRateLimitDuration)
}
if common.CriticalRateLimitNum != 150 {
t.Fatalf("expected CriticalRateLimitNum to be hot reloaded, got %d", common.CriticalRateLimitNum)
}
if common.CriticalRateLimitDuration != 900 {
t.Fatalf("expected CriticalRateLimitDuration to be hot reloaded, got %d", common.CriticalRateLimitDuration)
}
resp := performJSONRequest(t, engine, token, http.MethodGet, "/api/option/", nil)
var options []model.Option
decodeResponseData(t, resp, &options)
optionMap := make(map[string]string, len(options))
for _, option := range options {
optionMap[option.Key] = option.Value
}
if optionMap["GlobalApiRateLimitNum"] != "450" {
t.Fatalf("expected option payload to include GlobalApiRateLimitNum=450, got %q", optionMap["GlobalApiRateLimitNum"])
}
if optionMap["CriticalRateLimitDuration"] != "900" {
t.Fatalf("expected option payload to include CriticalRateLimitDuration=900, got %q", optionMap["CriticalRateLimitDuration"])
}
}
func TestPhase2AgentLifecycle(t *testing.T) {
gin.SetMode(gin.TestMode)
common.RedisEnabled = false
@@ -1,5 +1,6 @@
'use client';
import { useEffect } from 'react';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
@@ -100,36 +101,40 @@ function SidebarNavItem({
item,
currentPath,
isSidebarCollapsed,
forceExpanded,
onNavigate,
depth = 0,
}: {
item: NavigationItem;
currentPath: string;
isSidebarCollapsed: boolean;
forceExpanded?: boolean;
onNavigate?: () => void;
depth?: number;
}) {
const active = isNavigationItemActive(currentPath, item);
const hasChildren = Boolean(item.children?.length);
const showLabel = forceExpanded || !isSidebarCollapsed;
return (
<div className='space-y-2'>
<Link
href={item.href}
onClick={onNavigate}
className={cn(
'flex items-center gap-3 rounded-2xl border px-3 py-3 transition-colors',
depth > 0 && 'ml-3 rounded-xl py-2.5',
'flex min-h-[50px] items-center gap-3 rounded-2xl border px-3 py-2.5 transition-colors',
depth > 0 && 'ml-3 rounded-xl',
active
? 'border-[var(--border-strong)] bg-[var(--accent-soft)] text-[var(--foreground-primary)]'
: 'border-transparent text-[var(--foreground-secondary)] hover:border-[var(--border-default)] hover:bg-[var(--surface-muted)] hover:text-[var(--foreground-primary)]',
)}
>
<span className='flex h-9 w-9 shrink-0 items-center justify-center rounded-xl bg-[var(--control-background)] text-[var(--foreground-primary)]'>
<span className='flex h-8 w-8 shrink-0 items-center justify-center rounded-xl bg-[var(--control-background)] text-[var(--foreground-primary)]'>
<SidebarIcon icon={item.icon} />
</span>
{!isSidebarCollapsed ? (
<span className='min-w-0 flex-1 text-sm font-medium'>{item.label}</span>
) : null}
{showLabel ? <span className='min-w-0 flex-1 text-sm font-medium'>{item.label}</span> : null}
</Link>
{!isSidebarCollapsed && hasChildren ? (
{showLabel && hasChildren ? (
<div className='space-y-2'>
{item.children?.map((child) => (
<SidebarNavItem
@@ -137,6 +142,8 @@ function SidebarNavItem({
item={child}
currentPath={currentPath}
isSidebarCollapsed={isSidebarCollapsed}
forceExpanded={forceExpanded}
onNavigate={onNavigate}
depth={depth + 1}
/>
))}
@@ -146,50 +153,101 @@ function SidebarNavItem({
);
}
export function DashboardSidebar() {
const pathname = usePathname();
const currentPath = pathname ?? '/';
const isSidebarCollapsed = useAppShellStore((state) => state.isSidebarCollapsed);
return (
<aside
className={cn(
'sticky top-0 hidden h-screen shrink-0 overflow-hidden border-r border-[var(--border-default)] bg-[var(--surface-panel)]/95 px-3 py-6 backdrop-blur lg:block',
isSidebarCollapsed ? 'w-24' : 'w-72',
)}
>
<div className='flex h-full flex-col gap-6'>
<div className='flex items-center gap-3 rounded-2xl border border-[var(--border-default)] bg-[var(--surface-muted)] px-3 py-3'>
<div className='flex h-11 w-11 items-center justify-center rounded-2xl bg-[var(--brand-primary-soft)] text-lg font-semibold text-[var(--brand-primary)]'>
AF
</div>
{!isSidebarCollapsed ? (
<div>
<p className='text-sm font-semibold text-[var(--foreground-primary)]'>ATSFlare</p>
<p className='text-xs text-[var(--foreground-secondary)]'>控制面新版工程</p>
</div>
) : null}
</div>
<nav className='flex-1 space-y-2'>
<div className='flex max-h-full min-h-0 flex-col gap-2 overflow-y-auto pr-1'>
{dashboardNavigation.map((item) => (
<SidebarNavItem
key={item.href}
item={item}
currentPath={currentPath}
isSidebarCollapsed={isSidebarCollapsed}
/>
))}
</div>
</nav>
function SidebarContent({
currentPath,
isSidebarCollapsed,
forceExpanded = false,
onNavigate,
}: {
currentPath: string;
isSidebarCollapsed: boolean;
forceExpanded?: boolean;
onNavigate?: () => void;
}) {
const showLabel = forceExpanded || !isSidebarCollapsed;
{!isSidebarCollapsed ? (
<div className='rounded-2xl border border-[var(--status-success-border)] bg-[var(--status-success-soft)] px-4 py-4 text-xs leading-6 text-[var(--status-success-foreground)]'>
新版控制台已切换为生产导向的信息架构,核心操作统一收敛到列表与弹窗。
return (
<div className='flex h-full flex-col gap-5'>
<div className='flex items-center gap-3 rounded-2xl border border-[var(--border-default)] bg-[var(--surface-muted)] px-3 py-3'>
<div className='flex h-10 w-10 items-center justify-center rounded-2xl bg-[var(--brand-primary-soft)] text-sm font-semibold text-[var(--brand-primary)]'>
AF
</div>
{showLabel ? (
<div>
<p className='text-sm font-semibold text-[var(--foreground-primary)]'>ATSFlare</p>
<p className='text-xs text-[var(--foreground-secondary)]'>控制面</p>
</div>
) : null}
</div>
</aside>
<nav className='flex-1 space-y-2'>
<div className='flex max-h-full min-h-0 flex-col gap-2 overflow-y-auto pr-1'>
{dashboardNavigation.map((item) => (
<SidebarNavItem
key={item.href}
item={item}
currentPath={currentPath}
isSidebarCollapsed={isSidebarCollapsed}
forceExpanded={forceExpanded}
onNavigate={onNavigate}
/>
))}
</div>
</nav>
{showLabel ? (
<div className='rounded-2xl border border-[var(--status-success-border)] bg-[var(--status-success-soft)] px-3 py-3 text-xs leading-6 text-[var(--status-success-foreground)]'>
总览页现在承接各模块摘要,侧栏只保留稳定导航。
</div>
) : null}
</div>
);
}
export function DashboardSidebar() {
const pathname = usePathname();
const currentPath = pathname ?? '/';
const isSidebarCollapsed = useAppShellStore((state) => state.isSidebarCollapsed);
const isMobileSidebarOpen = useAppShellStore((state) => state.isMobileSidebarOpen);
const setMobileSidebarOpen = useAppShellStore((state) => state.setMobileSidebarOpen);
useEffect(() => {
setMobileSidebarOpen(false);
}, [currentPath, setMobileSidebarOpen]);
return (
<>
<div
className={cn(
'fixed inset-0 z-30 bg-black/35 transition-opacity duration-200 min-[1000px]:hidden',
isMobileSidebarOpen ? 'opacity-100' : 'pointer-events-none opacity-0',
)}
onClick={() => setMobileSidebarOpen(false)}
aria-hidden='true'
/>
<aside
className={cn(
'fixed left-0 top-0 z-40 h-screen w-[200px] overflow-hidden border-r border-[var(--border-default)] bg-[var(--surface-panel)]/95 px-3 py-5 backdrop-blur transition-transform duration-200 min-[1000px]:hidden',
isMobileSidebarOpen ? 'translate-x-0' : '-translate-x-full',
)}
>
<SidebarContent
currentPath={currentPath}
isSidebarCollapsed={false}
forceExpanded
onNavigate={() => setMobileSidebarOpen(false)}
/>
</aside>
<aside
className={cn(
'sticky top-0 z-10 hidden h-screen shrink-0 overflow-hidden border-r border-[var(--border-default)] bg-[var(--surface-panel)]/95 px-3 py-5 backdrop-blur min-[1000px]:block',
isSidebarCollapsed ? 'w-[76px]' : 'w-[200px]',
)}
>
<SidebarContent currentPath={currentPath} isSidebarCollapsed={isSidebarCollapsed} />
</aside>
</>
);
}
@@ -1,73 +1,119 @@
'use client';
'use client';
import { useEffect, useRef, useState } from 'react';
import { useRouter } from 'next/navigation';
import { useState } from 'react';
import { useAuth } from '@/components/providers/auth-provider';
import { ThemeToggle } from '@/components/ui/theme-toggle';
import { getCurrentNavigationItem } from '@/lib/utils/navigation';
import { publicEnv } from '@/lib/env/public-env';
import { useAppShellStore } from '@/store/app-shell';
import { usePathname } from 'next/navigation';
export function DashboardTopbar() {
const router = useRouter();
const pathname = usePathname();
const currentPath = pathname ?? '/';
const { logout, user } = useAuth();
const toggleSidebar = useAppShellStore((state) => state.toggleSidebar);
const currentItem = getCurrentNavigationItem(currentPath);
const [isLoggingOut, setIsLoggingOut] = useState(false);
const handleLogout = async () => {
setIsLoggingOut(true);
await logout();
router.replace('/login');
};
return (
<header className='sticky top-0 z-10 border-b border-[var(--border-default)] bg-[var(--surface-panel)]/75 px-4 py-4 backdrop-blur md:px-8'>
<div className='flex flex-col gap-4 md:flex-row md:items-center md:justify-between'>
<div className='flex items-center gap-3'>
<button
type='button'
onClick={toggleSidebar}
className='inline-flex h-11 w-11 items-center justify-center rounded-2xl border border-[var(--border-default)] bg-[var(--control-background)] text-lg text-[var(--foreground-primary)] transition hover:bg-[var(--control-background-hover)]'
aria-label='切换侧边栏'
>
☰
</button>
<div>
<p className='text-xs uppercase tracking-[0.24em] text-[var(--foreground-secondary)]'>当前模块</p>
<h2 className='text-lg font-semibold text-[var(--foreground-primary)]'>
{currentItem?.label ?? 'ATSFlare 控制台'}
</h2>
</div>
</div>
<div className='flex items-center gap-3 text-sm text-[var(--foreground-secondary)]'>
{user ? (
<span className='hidden rounded-full border border-[var(--border-default)] px-3 py-1.5 md:inline-flex'>
{user.display_name || user.username}
</span>
) : null}
<ThemeToggle />
<span className='rounded-full border border-[var(--border-default)] px-3 py-1.5'>
静态导出模式
</span>
<span className='rounded-full border border-[var(--border-default)] px-3 py-1.5'>
版本 {publicEnv.appVersion}
</span>
<button
type='button'
onClick={() => void handleLogout()}
disabled={isLoggingOut}
className='inline-flex rounded-full border border-[var(--border-default)] bg-[var(--control-background)] px-3 py-1.5 text-[var(--foreground-primary)] transition hover:bg-[var(--control-background-hover)] disabled:cursor-not-allowed disabled:opacity-60'
>
{isLoggingOut ? '退出中...' : '退出登录'}
</button>
</div>
</div>
</header>
);
}
import { publicEnv } from '@/lib/env/public-env';
import { useAppShellStore } from '@/store/app-shell';
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 [isLoggingOut, setIsLoggingOut] = useState(false);
const [isUserMenuOpen, setIsUserMenuOpen] = useState(false);
const menuRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
if (!isUserMenuOpen) {
return;
}
const handlePointerDown = (event: MouseEvent) => {
if (!menuRef.current?.contains(event.target as Node)) {
setIsUserMenuOpen(false);
}
};
const handleEscape = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
setIsUserMenuOpen(false);
}
};
window.addEventListener('mousedown', handlePointerDown);
window.addEventListener('keydown', handleEscape);
return () => {
window.removeEventListener('mousedown', handlePointerDown);
window.removeEventListener('keydown', handleEscape);
};
}, [isUserMenuOpen]);
const handleLogout = async () => {
setIsLoggingOut(true);
setIsUserMenuOpen(false);
await logout();
router.replace('/login');
};
const handleSidebarToggle = () => {
if (window.innerWidth < 1000) {
setMobileSidebarOpen(!isMobileSidebarOpen);
return;
}
toggleSidebar();
};
return (
<header className='sticky top-0 z-20 border-b border-[var(--border-default)] bg-[var(--surface-panel)]/88 px-4 py-4 backdrop-blur md:px-8'>
<div className='flex items-center justify-between gap-3'>
<button
type='button'
onClick={handleSidebarToggle}
className='inline-flex h-11 w-11 items-center justify-center rounded-2xl border border-[var(--border-default)] bg-[var(--control-background)] text-lg text-[var(--foreground-primary)] transition hover:bg-[var(--control-background-hover)]'
aria-label='切换侧边栏'
>
☰
</button>
<div className='flex items-center gap-3 text-sm text-[var(--foreground-secondary)]'>
<span className='hidden rounded-full border border-[var(--border-default)] px-3 py-1.5 sm:inline-flex'>
版本 {publicEnv.appVersion}
</span>
<ThemeToggle />
<div className='relative' ref={menuRef}>
<button
type='button'
onClick={() => setIsUserMenuOpen((value) => !value)}
className='inline-flex h-11 items-center gap-2 rounded-2xl border border-[var(--border-default)] bg-[var(--control-background)] px-3 text-[var(--foreground-primary)] transition hover:bg-[var(--control-background-hover)]'
aria-expanded={isUserMenuOpen}
aria-haspopup='menu'
>
<span className='inline-flex h-7 w-7 items-center justify-center rounded-full bg-[var(--accent-soft)] text-xs font-semibold'>
{(user?.display_name || user?.username || 'U').slice(0, 1).toUpperCase()}
</span>
<span className='hidden sm:inline'>{user?.display_name || user?.username || '用户'}</span>
</button>
{isUserMenuOpen ? (
<div className='absolute right-0 top-[calc(100%+0.5rem)] w-52 rounded-2xl border border-[var(--border-default)] bg-[var(--surface-panel)] p-2 shadow-[var(--shadow-lg)]'>
<div className='rounded-xl px-3 py-2'>
<p className='text-sm font-semibold text-[var(--foreground-primary)]'>
{user?.display_name || user?.username || '用户'}
</p>
{user?.username ? (
<p className='mt-1 text-xs text-[var(--foreground-secondary)]'>@{user.username}</p>
) : null}
</div>
<button
type='button'
onClick={() => void handleLogout()}
disabled={isLoggingOut}
className='flex w-full items-center rounded-xl px-3 py-2 text-left text-sm text-[var(--status-danger-foreground)] transition hover:bg-[var(--status-danger-soft)] disabled:cursor-not-allowed disabled:opacity-60'
>
{isLoggingOut ? '退出中...' : '退出登录'}
</button>
</div>
) : null}
</div>
</div>
</div>
</header>
);
}
+74 -55
View File
@@ -1,55 +1,74 @@
'use client';
import { cn } from '@/lib/utils/cn';
import type { ThemeMode } from '@/lib/theme/theme';
import { useTheme } from '@/components/providers/theme-provider';
const themeOptions: Array<{ value: ThemeMode; label: string }> = [
{ value: 'light', label: '浅色' },
{ value: 'system', label: '跟随系统' },
{ value: 'dark', label: '深色' },
];
interface ThemeToggleProps {
className?: string;
}
export function ThemeToggle({ className }: ThemeToggleProps) {
const { themeMode, resolvedTheme, setThemeMode } = useTheme();
return (
<div
className={cn(
'inline-flex items-center gap-1 rounded-full border border-[var(--border-default)] bg-[var(--control-background)] p-1 text-xs text-[var(--foreground-secondary)]',
className,
)}
aria-label='主题切换'
>
{themeOptions.map((option) => {
const active = themeMode === option.value;
return (
<button
key={option.value}
type='button'
onClick={() => setThemeMode(option.value)}
className={cn(
'rounded-full px-3 py-1.5 transition-colors',
active
? 'bg-[var(--accent-soft)] text-[var(--foreground-primary)]'
: 'hover:bg-[var(--control-background-hover)] hover:text-[var(--foreground-primary)]',
)}
aria-pressed={active}
title={
option.value === 'system'
? `当前跟随系统(${resolvedTheme === 'dark' ? '深色' : '浅色'})`
: `切换到${option.label}`
}
>
{option.label}
</button>
);
})}
</div>
);
}
'use client';
import { cn } from '@/lib/utils/cn';
import { themeModes, type ThemeMode } from '@/lib/theme/theme';
import { useTheme } from '@/components/providers/theme-provider';
interface ThemeToggleProps {
className?: string;
}
export function ThemeToggle({ className }: ThemeToggleProps) {
const { themeMode, resolvedTheme, setThemeMode } = useTheme();
const currentIndex = themeModes.indexOf(themeMode);
const nextTheme = themeModes[(currentIndex + 1) % themeModes.length] as ThemeMode;
const icon =
themeMode === 'light' ? (
<svg
className='h-5 w-5'
viewBox='0 0 24 24'
fill='none'
stroke='currentColor'
strokeWidth='1.8'
strokeLinecap='round'
strokeLinejoin='round'
>
<circle cx='12' cy='12' r='4' />
<path d='M12 2.5v2.2M12 19.3v2.2M21.5 12h-2.2M4.7 12H2.5M18.7 5.3l-1.6 1.6M6.9 17.1l-1.6 1.6M18.7 18.7l-1.6-1.6M6.9 6.9 5.3 5.3' />
</svg>
) : themeMode === 'dark' ? (
<svg
className='h-5 w-5'
viewBox='0 0 24 24'
fill='none'
stroke='currentColor'
strokeWidth='1.8'
strokeLinecap='round'
strokeLinejoin='round'
>
<path d='M20 15.2A7.8 7.8 0 1 1 8.8 4 6.4 6.4 0 0 0 20 15.2Z' />
</svg>
) : (
<svg
className='h-5 w-5'
viewBox='0 0 24 24'
fill='none'
stroke='currentColor'
strokeWidth='1.8'
strokeLinecap='round'
strokeLinejoin='round'
>
<rect x='3.5' y='4' width='17' height='12' rx='2.5' />
<path d='M8 20h8M12 16v4' />
</svg>
);
const label =
themeMode === 'light' ? '浅色模式' : themeMode === 'dark' ? '深色模式' : '跟随系统';
return (
<button
type='button'
onClick={() => setThemeMode(nextTheme)}
className={cn(
'inline-flex h-11 w-11 items-center justify-center rounded-2xl border border-[var(--border-default)] bg-[var(--control-background)] text-[var(--foreground-primary)] transition hover:bg-[var(--control-background-hover)]',
className,
)}
aria-label={`当前${label},点击切换到${nextTheme === 'light' ? '浅色模式' : nextTheme === 'dark' ? '深色模式' : '跟随系统'}`}
title={`${label}(当前生效:${resolvedTheme === 'dark' ? '深色' : '浅色'}),点击切换到${nextTheme === 'light' ? '浅色模式' : nextTheme === 'dark' ? '深色模式' : '跟随系统'}`}
>
{icon}
</button>
);
}
@@ -1,7 +1,7 @@
'use client';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useMemo, useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useState } from 'react';
import { EmptyState } from '@/components/feedback/empty-state';
import { ErrorState } from '@/components/feedback/error-state';
@@ -28,7 +28,7 @@ import {
PrimaryButton,
SecondaryButton,
} from '@/features/shared/components/resource-primitives';
import { formatDateTime } from '@/lib/utils/date';
import { formatDateTime } from '@/lib/utils/date';
const versionsQueryKey = ['config-versions'];
@@ -249,21 +249,7 @@ export function ConfigVersionsPage() {
},
});
const summary = useMemo(() => {
const versions = versionsQuery.data || [];
const activeCount = versions.filter((item) => item.is_active).length;
return [
{ label: '版本总数', value: versions.length },
{ label: '激活版本', value: activeCount },
{
label: '最近创建时间',
value: versions[0]?.created_at ? formatDateTime(versions[0].created_at) : '—',
},
];
}, [versionsQuery.data]);
const handleOpenPublishPreview = async () => {
const handleOpenPublishPreview = async () => {
setFeedback(null);
setIsPreviewLoading(true);
@@ -294,39 +280,20 @@ export function ConfigVersionsPage() {
return (
<div className='space-y-6'>
<PageHeader
title='配置版本'
description='查看历史快照、预览待发布配置差异,并在需要时重新激活旧版本。'
/>
<PageHeader
title='配置版本'
description='查看历史快照、预览待发布配置差异,并在需要时重新激活旧版本。'
action={
<PrimaryButton type='button' onClick={handleOpenPublishPreview} disabled={isPreviewLoading}>
{isPreviewLoading ? '加载预览中...' : '预览并发布'}
</PrimaryButton>
}
/>
{feedback ? <InlineMessage tone={feedback.tone} message={feedback.message} /> : null}
<div className='grid gap-6 xl:grid-cols-[0.8fr_1.2fr]'>
<AppCard
title='版本摘要'
description='阶段 3 已接入发布前预览、历史版本明细与激活动作。'
action={
<PrimaryButton type='button' onClick={handleOpenPublishPreview} disabled={isPreviewLoading}>
{isPreviewLoading ? '加载预览中...' : '预览并发布'}
</PrimaryButton>
}
>
<div className='grid gap-4 md:grid-cols-3 xl:grid-cols-1'>
{summary.map((item) => (
<div
key={item.label}
className='rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-4'
>
<p className='text-xs uppercase tracking-[0.2em] text-[var(--foreground-muted)]'>
{item.label}
</p>
<p className='mt-2 text-sm font-semibold text-[var(--foreground-primary)]'>{item.value}</p>
</div>
))}
</div>
</AppCard>
{publishPreview ? (
<div className='grid gap-6'>
{publishPreview ? (
<PublishPreviewCard
preview={publishPreview.preview}
diff={publishPreview.diff}
@@ -1,76 +1,258 @@
import Link from 'next/link';
'use client';
import Link from 'next/link';
import { useQueries } from '@tanstack/react-query';
import { ErrorState } from '@/components/feedback/error-state';
import { LoadingState } from '@/components/feedback/loading-state';
import { AppCard } from '@/components/ui/app-card';
import { StatusBadge } from '@/components/ui/status-badge';
import { dashboardNavigation } from '@/lib/constants/navigation';
import { flattenNavigationItems } from '@/lib/utils/navigation';
import type { NavigationItem } from '@/types/navigation';
import { getConfigVersions } from '@/features/config-versions/api/config-versions';
import type { ConfigVersionItem } from '@/features/config-versions/types';
import { getManagedDomains } from '@/features/managed-domains/api/managed-domains';
import type { ManagedDomainItem } from '@/features/managed-domains/types';
import { getNodes } from '@/features/nodes/api/nodes';
import type { NodeItem } from '@/features/nodes/types';
import { getProxyRoutes } from '@/features/proxy-routes/api/proxy-routes';
import type { ProxyRouteItem } from '@/features/proxy-routes/types';
import { getTlsCertificates } from '@/features/tls-certificates/api/tls-certificates';
import type { TlsCertificateItem } from '@/features/tls-certificates/types';
import { getUsers, searchUsers } from '@/features/users/api/users';
import type { UserItem } from '@/features/users/types';
import { formatDateTime } from '@/lib/utils/date';
const readinessItems = [
{
title: '工程底座',
description: 'Next.js App Router、TypeScript strict、Tailwind CSS 与静态导出链路已稳定运行。',
},
{
title: '认证骨架',
description: '登录、注册、重置密码、鉴权守卫与后台布局已迁移到新前端。',
},
{
title: '业务模块',
description: '核心链路、用户和设置模块都已接入真实数据与交互,正在继续生产化打磨。',
},
];
function parseCustomHeaders(rawValue: string) {
if (!rawValue) {
return [];
}
const moduleLinks = flattenNavigationItems(dashboardNavigation).filter(
(item: NavigationItem) => item.href !== '/' && !item.children?.length,
);
try {
const parsed = JSON.parse(rawValue);
return Array.isArray(parsed) ? parsed : [];
} catch {
return [];
}
}
function getErrorMessage(error: unknown) {
return error instanceof Error ? error.message : '请求失败,请稍后重试。';
}
function SummaryCard({
title,
description,
href,
items,
status,
error,
}: {
title: string;
description: string;
href: string;
items: ReadonlyArray<{ label: string; value: string | number }>;
status: 'pending' | 'success' | 'error';
error?: unknown;
}) {
return (
<AppCard
title={title}
description={description}
action={
<Link
href={href}
className='inline-flex items-center rounded-full border border-[var(--border-default)] px-3 py-1.5 text-xs text-[var(--foreground-primary)] transition hover:bg-[var(--control-background-hover)]'
>
进入模块
</Link>
}
>
{status === 'pending' ? (
<LoadingState />
) : status === 'error' ? (
<ErrorState title={`${title}加载失败`} description={getErrorMessage(error)} />
) : (
<div className='grid gap-3 sm:grid-cols-2'>
{items.map((item) => (
<div
key={item.label}
className='rounded-2xl border border-[var(--border-default)] bg-[var(--surface-muted)] px-4 py-4'
>
<p className='text-xs uppercase tracking-[0.2em] text-[var(--foreground-muted)]'>
{item.label}
</p>
<p className='mt-2 text-lg font-semibold text-[var(--foreground-primary)]'>{item.value}</p>
</div>
))}
</div>
)}
</AppCard>
);
}
export function DashboardOverview() {
const [
nodesQuery,
versionsQuery,
managedDomainsQuery,
certificatesQuery,
proxyRoutesQuery,
usersQuery,
] = useQueries({
queries: [
{ queryKey: ['dashboard', 'nodes'], queryFn: getNodes },
{ queryKey: ['dashboard', 'config-versions'], queryFn: getConfigVersions },
{ queryKey: ['dashboard', 'managed-domains'], queryFn: getManagedDomains },
{ queryKey: ['dashboard', 'tls-certificates'], queryFn: getTlsCertificates },
{ queryKey: ['dashboard', 'proxy-routes'], queryFn: getProxyRoutes },
{
queryKey: ['dashboard', 'users'],
queryFn: async () => {
try {
return await searchUsers('');
} catch {
return getUsers(0);
}
},
},
],
});
const nodes = (nodesQuery.data ?? []) as NodeItem[];
const versions = (versionsQuery.data ?? []) as ConfigVersionItem[];
const domains = (managedDomainsQuery.data ?? []) as ManagedDomainItem[];
const certificates = (certificatesQuery.data ?? []) as TlsCertificateItem[];
const routes = (proxyRoutesQuery.data ?? []) as ProxyRouteItem[];
const users = (usersQuery.data ?? []) as UserItem[];
const expiringCertificates = certificates.filter((item) => {
const expiresAt = new Date(item.not_after).getTime();
return !Number.isNaN(expiresAt) && expiresAt >= Date.now() && expiresAt - Date.now() <= 30 * 24 * 60 * 60 * 1000;
}).length;
const expiredCertificates = certificates.filter((item) => {
const expiresAt = new Date(item.not_after).getTime();
return !Number.isNaN(expiresAt) && expiresAt < Date.now();
}).length;
const dashboardCards = [
{
title: '节点摘要',
description: '集中查看在线状态、待接入节点和自动更新覆盖情况。',
href: '/node',
status: nodesQuery.status,
error: nodesQuery.error,
items: [
{ label: '节点总数', value: nodes.length },
{ label: '在线节点', value: nodes.filter((item) => item.status === 'online').length },
{ label: '待接入节点', value: nodes.filter((item) => item.status === 'pending').length },
{ label: '自动更新', value: nodes.filter((item) => item.auto_update_enabled).length },
],
},
{
title: '版本摘要',
description: '直接感知当前发布沉淀、激活版本和最近一次变更时间。',
href: '/config-version',
status: versionsQuery.status,
error: versionsQuery.error,
items: [
{ label: '版本总数', value: versions.length },
{ label: '激活版本', value: versions.filter((item) => item.is_active).length },
{ label: '最近创建', value: versions[0]?.created_at ? formatDateTime(versions[0].created_at) : '—' },
],
},
{
title: '规则摘要',
description: '查看域名规则启用状态、通配符覆盖和证书绑定规模。',
href: '/managed-domain',
status: managedDomainsQuery.status,
error: managedDomainsQuery.error,
items: [
{ label: '域名规则', value: domains.length },
{ label: '已启用', value: domains.filter((item) => item.enabled).length },
{ label: '通配符规则', value: domains.filter((item) => item.domain.startsWith('*.')).length },
{ label: '已绑证书', value: domains.filter((item) => item.cert_id).length },
],
},
{
title: '证书概览',
description: '快速识别证书存量、近 30 天到期风险和过期情况。',
href: '/tls-certificate',
status: certificatesQuery.status,
error: certificatesQuery.error,
items: [
{ label: '证书总数', value: certificates.length },
{ label: '30 天内到期', value: expiringCertificates },
{ label: '已过期', value: expiredCertificates },
{ label: '最近更新', value: certificates[0]?.updated_at ? formatDateTime(certificates[0].updated_at) : '—' },
],
},
{
title: '发布与摘要',
description: '汇总反向代理规则规模、HTTPS 覆盖和请求头配置密度。',
href: '/proxy-route',
status: proxyRoutesQuery.status,
error: proxyRoutesQuery.error,
items: [
{ label: '规则总数', value: routes.length },
{ label: '已启用', value: routes.filter((item) => item.enabled).length },
{ label: 'HTTPS 规则', value: routes.filter((item) => item.enable_https).length },
{
label: '自定义请求头',
value: routes.reduce((count, route) => count + parseCustomHeaders(route.custom_headers).length, 0),
},
],
},
{
title: '用户概览',
description: '展示当前可管理用户池的角色和状态分布。',
href: '/user',
status: usersQuery.status,
error: usersQuery.error,
items: [
{ label: '用户总数', value: users.length },
{ label: '管理员', value: users.filter((item) => item.role >= 10).length },
{ label: '已激活', value: users.filter((item) => item.status === 1).length },
{ label: '已封禁', value: users.filter((item) => item.status !== 1).length },
],
},
] as const;
return (
<div className='space-y-6'>
<AppCard
title='前端迁移完成'
description='新版管理端已承接主链路页面与核心运维动作,当前重点转向生产环境信息架构与操作效率优化。'
action={<StatusBadge label='统一 Next.js 前端' variant='success' />}
title='控制台仪表盘'
description='首页统一承接节点、发布、域名、证书、反向代理和用户六个模块的核心总览,避免在各页面重复扫一遍摘要。'
action={<StatusBadge label='实时聚合概览' variant='success' />}
>
<div className='grid gap-4 lg:grid-cols-3'>
{readinessItems.map((item) => (
<div
key={item.title}
className='rounded-2xl border border-[var(--border-default)] bg-[var(--surface-muted)] p-4'
>
<p className='text-base font-semibold text-[var(--foreground-primary)]'>{item.title}</p>
<p className='mt-2 text-sm leading-6 text-[var(--foreground-secondary)]'>
{item.description}
</p>
</div>
))}
</div>
</AppCard>
<div className='grid gap-6 xl:grid-cols-[1.3fr_0.9fr]'>
<AppCard title='模块入口' description='当前导航已收敛为首页、节点、网站、发布、用户和设置,网站下再细分业务对象。'>
<div className='grid gap-3 md:grid-cols-2'>
{moduleLinks.map((item) => (
<Link
key={item.href}
href={item.href}
className='rounded-2xl border border-[var(--border-default)] bg-[var(--surface-muted)] p-4 transition hover:border-[var(--border-strong)] hover:bg-[var(--accent-soft)]'
>
<p className='text-sm font-semibold text-[var(--foreground-primary)]'>{item.label}</p>
<p className='mt-2 text-sm leading-6 text-[var(--foreground-secondary)]'>进入 {item.label} 页面,继续完成管理与发布操作。</p>
</Link>
))}
<div className='grid gap-4 md:grid-cols-3'>
<div className='rounded-2xl border border-[var(--border-default)] bg-[var(--surface-muted)] p-4'>
<p className='text-xs uppercase tracking-[0.2em] text-[var(--foreground-muted)]'>信息架构</p>
<p className='mt-2 text-base font-semibold text-[var(--foreground-primary)]'>首页负责总览</p>
<p className='mt-2 text-sm leading-6 text-[var(--foreground-secondary)]'>
模块页保留列表、表单和具体操作,摘要统一回收到仪表盘。
</p>
</div>
</AppCard>
<div className='rounded-2xl border border-[var(--border-default)] bg-[var(--surface-muted)] p-4'>
<p className='text-xs uppercase tracking-[0.2em] text-[var(--foreground-muted)]'>导航效率</p>
<p className='mt-2 text-base font-semibold text-[var(--foreground-primary)]'>顶栏与侧栏已精简</p>
<p className='mt-2 text-sm leading-6 text-[var(--foreground-secondary)]'>
顶栏只保留版本、主题切换和用户入口,侧栏宽度与按钮高度同步压缩。
</p>
</div>
<div className='rounded-2xl border border-[var(--border-default)] bg-[var(--surface-muted)] p-4'>
<p className='text-xs uppercase tracking-[0.2em] text-[var(--foreground-muted)]'>响应式</p>
<p className='mt-2 text-base font-semibold text-[var(--foreground-primary)]'>小屏可正常展开侧栏</p>
<p className='mt-2 text-sm leading-6 text-[var(--foreground-secondary)]'>
小于 1000px 时切换为抽屉侧栏,不再出现按钮可点但导航不可见的问题。
</p>
</div>
</div>
</AppCard>
<AppCard title='下一步建议' description='当前重点转向弹窗化操作、联调回归和发布验收。'>
<ol className='space-y-3 text-sm leading-6 text-[var(--foreground-secondary)]'>
<li>1. 对照后端接口继续压缩页面认知负担,保持核心动作在列表页附近完成。</li>
<li>2. 补齐关键页面的测试覆盖与构建验收。</li>
<li>3. 清理迁移期文档和发布说明中的旧前端表述。</li>
</ol>
</AppCard>
<div className='grid gap-6 xl:grid-cols-2'>
{dashboardCards.map((card) => (
<SummaryCard key={card.title} {...card} />
))}
</div>
</div>
);
@@ -174,16 +174,6 @@ export function ManagedDomainsPage() {
[certificates],
);
const summary = useMemo(
() => [
{ label: '域名规则', value: domains.length },
{ label: '已启用', value: domains.filter((item) => item.enabled).length },
{ label: '通配符规则', value: domains.filter((item) => item.domain.startsWith('*.')).length },
{ label: '已绑证书', value: domains.filter((item) => item.cert_id).length },
],
[domains],
);
const currentCertificate = watchedCertId ? certificateMap.get(Number(watchedCertId)) : null;
const handleReset = () => {
@@ -236,22 +226,6 @@ export function ManagedDomainsPage() {
{feedback ? <InlineMessage tone={feedback.tone} message={feedback.message} /> : null}
<AppCard title='规则摘要' description='域名维护动作已改为弹窗操作,列表专注展示状态与证书绑定关系。'>
<div className='grid gap-4 md:grid-cols-2 xl:grid-cols-4'>
{summary.map((item) => (
<div
key={item.label}
className='rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-4'
>
<p className='text-xs uppercase tracking-[0.2em] text-[var(--foreground-muted)]'>
{item.label}
</p>
<p className='mt-2 text-lg font-semibold text-[var(--foreground-primary)]'>{item.value}</p>
</div>
))}
</div>
</AppCard>
<AppCard
title='域名规则列表'
description='支持快速查看绑定证书、启用状态与最近更新时间。'
@@ -276,15 +276,6 @@ export function NodesPage() {
return nodes.find((item) => item.id === selectedNode.id) ?? selectedNode;
}, [nodes, selectedNode]);
const summary = useMemo(() => {
return [
{ label: '节点总数', value: nodes.length },
{ label: '在线节点', value: nodes.filter((item) => item.status === 'online').length },
{ label: '待接入节点', value: nodes.filter((item) => item.status === 'pending').length },
{ label: '自动更新', value: nodes.filter((item) => item.auto_update_enabled).length },
];
}, [nodes]);
const handleReset = () => {
setFeedback(null);
setEditingNodeId(null);
@@ -377,34 +368,7 @@ export function NodesPage() {
{feedback ? <InlineMessage tone={feedback.tone} message={feedback.message} /> : null}
<AppCard
title='节点摘要'
description='节点创建、编辑和更新动作均已收敛到列表页与弹窗,便于在生产环境快速处理。'
action={
<SecondaryButton
type='button'
onClick={() => void queryClient.invalidateQueries({ queryKey: nodesQueryKey })}
>
刷新列表
</SecondaryButton>
}
>
<div className='grid gap-4 md:grid-cols-2 xl:grid-cols-4'>
{summary.map((item) => (
<div
key={item.label}
className='rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-4'
>
<p className='text-xs uppercase tracking-[0.2em] text-[var(--foreground-muted)]'>
{item.label}
</p>
<p className='mt-2 text-lg font-semibold text-[var(--foreground-primary)]'>{item.value}</p>
</div>
))}
</div>
</AppCard>
<div className='grid gap-6 xl:grid-cols-[1fr_1fr]'>
<div className='grid gap-6 xl:grid-cols-[1fr_1fr]'>
<AppCard
title='Discovery Token 部署'
description='适用于新节点首次接入,Agent 会自动注册并换取节点专属 Token。'
@@ -494,7 +458,18 @@ export function NodesPage() {
</AppCard>
</div>
<AppCard title='节点列表' description='展示心跳、最近应用结果、版本信息与更新状态,可直接执行管理动作。'>
<AppCard
title='节点列表'
description='展示心跳、最近应用结果、版本信息与更新状态,可直接执行管理动作。'
action={
<SecondaryButton
type='button'
onClick={() => void queryClient.invalidateQueries({ queryKey: nodesQueryKey })}
>
刷新列表
</SecondaryButton>
}
>
{nodesQuery.isLoading ? (
<LoadingState />
) : nodesQuery.isError ? (
@@ -339,20 +339,6 @@ export function ProxyRoutesPage() {
[certificates],
);
const summary = useMemo(() => {
const routes = routesQuery.data || [];
return [
{ label: '规则总数', value: routes.length },
{ label: '已启用', value: routes.filter((item) => item.enabled).length },
{ label: 'HTTPS 规则', value: routes.filter((item) => item.enable_https).length },
{
label: '自定义请求头',
value: routes.reduce((count, route) => count + parseCustomHeaders(route.custom_headers).length, 0),
},
];
}, [routesQuery.data]);
const handleReset = () => {
setFeedback(null);
setEditingRouteId(null);
@@ -409,39 +395,20 @@ export function ProxyRoutesPage() {
title='反代规则'
description='维护域名到源站的映射、HTTPS 证书绑定与自定义请求头,并可直接触发配置发布。'
action={
<PrimaryButton type='button' onClick={handleCreate}>
新增规则
</PrimaryButton>
<>
<PrimaryButton type='button' onClick={() => publishMutation.mutate()} disabled={publishMutation.isPending}>
{publishMutation.isPending ? '发布中...' : '发布当前规则'}
</PrimaryButton>
<SecondaryButton type='button' onClick={handleCreate}>
新增规则
</SecondaryButton>
</>
}
/>
{feedback ? <InlineMessage tone={feedback.tone} message={feedback.message} /> : null}
<AppCard
title='发布与摘要'
description='规则维护动作改为按钮+弹窗模式,列表不再被长表单挤压。'
action={
<PrimaryButton type='button' onClick={() => publishMutation.mutate()} disabled={publishMutation.isPending}>
{publishMutation.isPending ? '发布中...' : '发布当前规则'}
</PrimaryButton>
}
>
<div className='grid gap-4 md:grid-cols-2 xl:grid-cols-4'>
{summary.map((item) => (
<div
key={item.label}
className='rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-4'
>
<p className='text-xs uppercase tracking-[0.2em] text-[var(--foreground-muted)]'>
{item.label}
</p>
<p className='mt-2 text-lg font-semibold text-[var(--foreground-primary)]'>{item.value}</p>
</div>
))}
</div>
</AppCard>
<AppCard title='规则列表' description='支持查看启用状态、HTTPS 配置、自定义请求头数量及最近更新时间。'>
<AppCard title='规则列表' description='支持查看启用状态、HTTPS 配置、自定义请求头数量及最近更新时间。'>
{routesQuery.isLoading ? (
<LoadingState />
) : routesQuery.isError ? (
@@ -4,12 +4,12 @@ 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 { 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';
@@ -74,6 +74,16 @@ const defaultOperationFields = {
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: '',
};
@@ -134,6 +144,23 @@ function formatDurationLabel(value: string) {
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 -- \\`,
@@ -157,11 +184,11 @@ export function SettingsPage() {
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<LatestReleaseInfo | null>(null);
const [wechatCode, setWeChatCode] = useState('');
const [emailAddress, setEmailAddress] = useState('');
const [emailCode, setEmailCode] = useState('');
const [emailTurnstileToken, setEmailTurnstileToken] = useState('');
const [latestRelease, setLatestRelease] = useState<LatestReleaseInfo | null>(null);
const isRoot = (user?.role ?? 0) >= 100;
@@ -257,6 +284,16 @@ export function SettingsPage() {
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 ?? '',
});
@@ -357,22 +394,22 @@ export function SettingsPage() {
});
};
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 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()) {
@@ -598,46 +635,46 @@ export function SettingsPage() {
当前状态:{profile.email ? `已绑定 ${profile.email}` : '未绑定'}
</p>
</div>
<div className='space-y-4'>
<ResourceField label='邮箱地址'>
<ResourceInput
value={emailAddress}
onChange={(event) => setEmailAddress(event.target.value)}
placeholder='请输入邮箱地址'
/>
</ResourceField>
<ResourceField label='验证码'>
<ResourceInput
value={emailCode}
onChange={(event) => setEmailCode(event.target.value)}
placeholder='请输入邮箱验证码'
/>
</ResourceField>
{publicStatus.turnstile_check ? (
publicStatus.turnstile_site_key ? (
<TurnstileWidget
siteKey={publicStatus.turnstile_site_key}
onVerify={(token) => setEmailTurnstileToken(token)}
onExpire={() => setEmailTurnstileToken('')}
onError={() => setEmailTurnstileToken('')}
/>
) : (
<EmptyState
title='Turnstile 配置不完整'
description='当前系统已启用 Turnstile,但未配置 Site Key,邮箱绑定暂不可用。'
/>
)
) : null}
<div className='flex flex-wrap gap-2'>
<SecondaryButton type='button' onClick={handleEmailVerification} disabled={busyKey === 'email-send'}>
{busyKey === 'email-send' ? '发送中...' : '发送验证码'}
</SecondaryButton>
<PrimaryButton type='button' onClick={handleBindEmail} disabled={busyKey === 'email-bind'}>
{busyKey === 'email-bind' ? '绑定中...' : '绑定邮箱'}
</PrimaryButton>
</div>
</div>
</div>
<div className='space-y-4'>
<ResourceField label='邮箱地址'>
<ResourceInput
value={emailAddress}
onChange={(event) => setEmailAddress(event.target.value)}
placeholder='请输入邮箱地址'
/>
</ResourceField>
<ResourceField label='验证码'>
<ResourceInput
value={emailCode}
onChange={(event) => setEmailCode(event.target.value)}
placeholder='请输入邮箱验证码'
/>
</ResourceField>
{publicStatus.turnstile_check ? (
publicStatus.turnstile_site_key ? (
<TurnstileWidget
siteKey={publicStatus.turnstile_site_key}
onVerify={(token) => setEmailTurnstileToken(token)}
onExpire={() => setEmailTurnstileToken('')}
onError={() => setEmailTurnstileToken('')}
/>
) : (
<EmptyState
title='Turnstile 配置不完整'
description='当前系统已启用 Turnstile,但未配置 Site Key,邮箱绑定暂不可用。'
/>
)
) : null}
<div className='flex flex-wrap gap-2'>
<SecondaryButton type='button' onClick={handleEmailVerification} disabled={busyKey === 'email-send'}>
{busyKey === 'email-send' ? '发送中...' : '发送验证码'}
</SecondaryButton>
<PrimaryButton type='button' onClick={handleBindEmail} disabled={busyKey === 'email-bind'}>
{busyKey === 'email-bind' ? '绑定中...' : '绑定邮箱'}
</PrimaryButton>
</div>
</div>
</div>
</div>
</AppCard>
</div>
@@ -766,6 +803,167 @@ export function SettingsPage() {
</AppCard>
</div>
<AppCard
title='请求限流设置'
description='按来源 IP 生效,保存后立即影响 Web、API、上传下载及登录注册等敏感接口。时间单位均为秒。'
action={
<PrimaryButton
type='button'
onClick={() =>
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' ? '保存中...' : '保存限流设置'}
</PrimaryButton>
}
>
<div className='grid gap-4 lg:grid-cols-2'>
<div className='rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] p-5'>
<p className='text-sm font-semibold text-[var(--foreground-primary)]'>全局 API 限流</p>
<p className='mt-1 text-sm text-[var(--foreground-muted)]'>作用于 `/api` 下的通用请求。</p>
<div className='mt-4 grid gap-4 sm:grid-cols-2'>
<ResourceField label='请求次数'>
<ResourceInput
type='number'
value={operationFields.GlobalApiRateLimitNum}
onChange={(event) =>
setOperationFields((previous) => ({ ...previous, GlobalApiRateLimitNum: event.target.value }))
}
/>
</ResourceField>
<ResourceField label={`时间窗口 (${formatSecondsLabel(operationFields.GlobalApiRateLimitDuration)})`}>
<ResourceInput
type='number'
value={operationFields.GlobalApiRateLimitDuration}
onChange={(event) =>
setOperationFields((previous) => ({ ...previous, GlobalApiRateLimitDuration: event.target.value }))
}
/>
</ResourceField>
</div>
</div>
<div className='rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] p-5'>
<p className='text-sm font-semibold text-[var(--foreground-primary)]'>全局 Web 限流</p>
<p className='mt-1 text-sm text-[var(--foreground-muted)]'>作用于页面和静态资源请求,过低会更容易触发 429。</p>
<div className='mt-4 grid gap-4 sm:grid-cols-2'>
<ResourceField label='请求次数'>
<ResourceInput
type='number'
value={operationFields.GlobalWebRateLimitNum}
onChange={(event) =>
setOperationFields((previous) => ({ ...previous, GlobalWebRateLimitNum: event.target.value }))
}
/>
</ResourceField>
<ResourceField label={`时间窗口 (${formatSecondsLabel(operationFields.GlobalWebRateLimitDuration)})`}>
<ResourceInput
type='number'
value={operationFields.GlobalWebRateLimitDuration}
onChange={(event) =>
setOperationFields((previous) => ({ ...previous, GlobalWebRateLimitDuration: event.target.value }))
}
/>
</ResourceField>
</div>
</div>
<div className='rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] p-5'>
<p className='text-sm font-semibold text-[var(--foreground-primary)]'>上传 / 下载限流</p>
<p className='mt-1 text-sm text-[var(--foreground-muted)]'>用于文件上传与下载接口,建议保留相对严格的阈值。</p>
<div className='mt-4 grid gap-4 sm:grid-cols-2'>
<ResourceField label='上传请求次数'>
<ResourceInput
type='number'
value={operationFields.UploadRateLimitNum}
onChange={(event) =>
setOperationFields((previous) => ({ ...previous, UploadRateLimitNum: event.target.value }))
}
/>
</ResourceField>
<ResourceField label={`上传窗口 (${formatSecondsLabel(operationFields.UploadRateLimitDuration)})`}>
<ResourceInput
type='number'
value={operationFields.UploadRateLimitDuration}
onChange={(event) =>
setOperationFields((previous) => ({ ...previous, UploadRateLimitDuration: event.target.value }))
}
/>
</ResourceField>
<ResourceField label='下载请求次数'>
<ResourceInput
type='number'
value={operationFields.DownloadRateLimitNum}
onChange={(event) =>
setOperationFields((previous) => ({ ...previous, DownloadRateLimitNum: event.target.value }))
}
/>
</ResourceField>
<ResourceField label={`下载窗口 (${formatSecondsLabel(operationFields.DownloadRateLimitDuration)})`}>
<ResourceInput
type='number'
value={operationFields.DownloadRateLimitDuration}
onChange={(event) =>
setOperationFields((previous) => ({ ...previous, DownloadRateLimitDuration: event.target.value }))
}
/>
</ResourceField>
</div>
</div>
<div className='rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] p-5'>
<p className='text-sm font-semibold text-[var(--foreground-primary)]'>敏感接口限流</p>
<p className='mt-1 text-sm text-[var(--foreground-muted)]'>用于登录、注册、验证码、重置密码和 OAuth 等接口。</p>
<div className='mt-4 grid gap-4 sm:grid-cols-2'>
<ResourceField label='请求次数'>
<ResourceInput
type='number'
value={operationFields.CriticalRateLimitNum}
onChange={(event) =>
setOperationFields((previous) => ({ ...previous, CriticalRateLimitNum: event.target.value }))
}
/>
</ResourceField>
<ResourceField label={`时间窗口 (${formatSecondsLabel(operationFields.CriticalRateLimitDuration)})`}>
<ResourceInput
type='number'
value={operationFields.CriticalRateLimitDuration}
onChange={(event) =>
setOperationFields((previous) => ({ ...previous, CriticalRateLimitDuration: event.target.value }))
}
/>
</ResourceField>
</div>
</div>
</div>
</AppCard>
<div className='grid gap-6 xl:grid-cols-[1fr_1fr]'>
<AppCard
title='Discovery Token 与部署命令'
@@ -179,32 +179,6 @@ export function TlsCertificatesPage() {
});
const certificates = useMemo(() => certificatesQuery.data ?? [], [certificatesQuery.data]);
const summary = useMemo(
() => [
{ label: '证书总数', value: certificates.length },
{
label: '30 天内到期',
value: certificates.filter((item) => {
const expiresAt = new Date(item.not_after).getTime();
if (Number.isNaN(expiresAt)) {
return false;
}
const diffMs = expiresAt - Date.now();
return diffMs >= 0 && diffMs <= 30 * 24 * 60 * 60 * 1000;
}).length,
},
{
label: '已过期',
value: certificates.filter((item) => {
const expiresAt = new Date(item.not_after).getTime();
return !Number.isNaN(expiresAt) && expiresAt < Date.now();
}).length,
},
{ label: '最近更新', value: certificates[0] ? formatDateTime(certificates[0].updated_at) : '—' },
],
[certificates],
);
const handleManualSubmit = manualForm.handleSubmit((values) => {
setFeedback(null);
@@ -245,20 +219,6 @@ export function TlsCertificatesPage() {
{feedback ? <InlineMessage tone={feedback.tone} message={feedback.message} /> : null}
<AppCard title='证书概览' description='上线前可先检查证书存量、过期风险与最近更新时间。'>
<div className='grid gap-4 md:grid-cols-2 xl:grid-cols-4'>
{summary.map((item) => (
<div
key={item.label}
className='rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-4'
>
<p className='text-xs uppercase tracking-[0.2em] text-[var(--foreground-muted)]'>{item.label}</p>
<p className='mt-2 text-lg font-semibold text-[var(--foreground-primary)]'>{item.value}</p>
</div>
))}
</div>
</AppCard>
<AppCard
title='证书列表'
description='展示证书有效期、到期状态与最近更新时间。删除前会提示确认。'
@@ -198,16 +198,7 @@ export function UsersPage() {
const activeQuery = searchKeyword.length > 0 ? searchQuery : usersQuery;
const users = useMemo(() => activeQuery.data ?? [], [activeQuery.data]);
const summary = useMemo(() => {
return [
{ label: searchKeyword ? '搜索结果' : '当前页用户', value: users.length },
{ label: '管理员', value: users.filter((item) => item.role >= 10).length },
{ label: '已激活', value: users.filter((item) => item.status === 1).length },
{ label: '已封禁', value: users.filter((item) => item.status !== 1).length },
];
}, [searchKeyword, users]);
const handleSearchSubmit = () => {
const handleSearchSubmit = () => {
setFeedback(null);
setPage(0);
setSearchKeyword(searchInput.trim());
@@ -296,25 +287,9 @@ export function UsersPage() {
{feedback ? <InlineMessage tone={feedback.tone} message={feedback.message} /> : null}
<AppCard title='用户概览' description='当前视图会根据分页或搜索结果实时刷新,新增和编辑统一在弹窗中完成。'>
<div className='grid gap-4 md:grid-cols-2 xl:grid-cols-4'>
{summary.map((item) => (
<div
key={item.label}
className='rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-4'
>
<p className='text-xs uppercase tracking-[0.2em] text-[var(--foreground-muted)]'>
{item.label}
</p>
<p className='mt-2 text-lg font-semibold text-[var(--foreground-primary)]'>{item.value}</p>
</div>
))}
</div>
</AppCard>
<AppCard
title='用户列表'
description='搜索会切换到全量匹配结果;分页模式下按服务器默认每页 10 条加载。'
<AppCard
title='用户列表'
description='搜索会切换到全量匹配结果;分页模式下按服务器默认每页 10 条加载。'
action={
<div className='flex flex-wrap gap-2'>
<SecondaryButton
+18 -14
View File
@@ -1,16 +1,20 @@
import { create } from 'zustand';
interface AppShellState {
isSidebarCollapsed: boolean;
toggleSidebar: () => void;
setSidebarCollapsed: (value: boolean) => void;
}
export const useAppShellStore = create<AppShellState>((set) => ({
isSidebarCollapsed: false,
toggleSidebar: () =>
set((state) => ({
isSidebarCollapsed: !state.isSidebarCollapsed,
})),
setSidebarCollapsed: (value) => set({ isSidebarCollapsed: value }),
}));
interface AppShellState {
isSidebarCollapsed: boolean;
isMobileSidebarOpen: boolean;
toggleSidebar: () => void;
setSidebarCollapsed: (value: boolean) => void;
setMobileSidebarOpen: (value: boolean) => void;
}
export const useAppShellStore = create<AppShellState>((set) => ({
isSidebarCollapsed: false,
isMobileSidebarOpen: false,
toggleSidebar: () =>
set((state) => ({
isSidebarCollapsed: !state.isSidebarCollapsed,
})),
setSidebarCollapsed: (value) => set({ isSidebarCollapsed: value }),
setMobileSidebarOpen: (value) => set({ isMobileSidebarOpen: value }),
}));
@@ -1,14 +1,119 @@
import { render, screen } from '@testing-library/react';
import { describe, expect, it } from 'vitest';
import { DashboardOverview } from '@/features/dashboard/components/dashboard-overview';
describe('DashboardOverview', () => {
it('renders stage one heading and readiness items', () => {
render(<DashboardOverview />);
expect(screen.getByText('阶段 1 已启动')).toBeInTheDocument();
expect(screen.getByText('工程底座')).toBeInTheDocument();
expect(screen.getByText('质量工具')).toBeInTheDocument();
});
});
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { render, screen } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { DashboardOverview } from '@/features/dashboard/components/dashboard-overview';
describe('DashboardOverview', () => {
afterEach(() => {
vi.unstubAllGlobals();
});
it('renders dashboard summary cards', async () => {
vi.stubGlobal(
'fetch',
vi.fn((input: RequestInfo | URL) => {
const url = String(input);
if (url.includes('/nodes/')) {
return Promise.resolve(
new Response(
JSON.stringify({
success: true,
message: '',
data: [{ id: 1, status: 'online', auto_update_enabled: true }],
}),
),
);
}
if (url.includes('/config-versions/')) {
return Promise.resolve(
new Response(
JSON.stringify({
success: true,
message: '',
data: [{ id: 1, version: '20260311-001', is_active: true, created_at: '2026-03-11T10:00:00Z' }],
}),
),
);
}
if (url.includes('/managed-domains/')) {
return Promise.resolve(
new Response(
JSON.stringify({
success: true,
message: '',
data: [{ id: 1, domain: '*.example.com', enabled: true, cert_id: 1 }],
}),
),
);
}
if (url.includes('/tls-certificates/')) {
return Promise.resolve(
new Response(
JSON.stringify({
success: true,
message: '',
data: [
{
id: 1,
name: 'example',
not_after: '2026-04-01T00:00:00Z',
updated_at: '2026-03-10T10:00:00Z',
},
],
}),
),
);
}
if (url.includes('/proxy-routes/')) {
return Promise.resolve(
new Response(
JSON.stringify({
success: true,
message: '',
data: [{ id: 1, enabled: true, enable_https: true, custom_headers: '[{"key":"X-Test","value":"1"}]' }],
}),
),
);
}
if (url.includes('/user/search')) {
return Promise.resolve(
new Response(
JSON.stringify({
success: true,
message: '',
data: [{ id: 1, role: 100, status: 1, username: 'root' }],
}),
),
);
}
return Promise.reject(new Error(`Unhandled fetch: ${url}`));
}),
);
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
});
render(
<QueryClientProvider client={queryClient}>
<DashboardOverview />
</QueryClientProvider>,
);
expect(screen.getByText('控制台仪表盘')).toBeInTheDocument();
expect(await screen.findByText('节点摘要')).toBeInTheDocument();
expect(await screen.findByText('用户概览')).toBeInTheDocument();
});
});
+23
View File
@@ -11,6 +11,8 @@ Server 当前支持两类启动配置:
1. 命令行参数
2. 环境变量
此外,部分运行时参数已迁入数据库 `Option` 表,可在管理端设置页中热更新,例如 Agent 运行参数与限流阈值。
### 1.1 Server 命令行参数
启动示例:
@@ -94,6 +96,27 @@ volumes:
* `REDIS_CONN_STRING` 未配置时,相关能力将回退为进程内实现
* `UPLOAD_PATH` 目录在启动时若不存在会自动创建
### 1.2.1 设置页可热更新的运行时配置
以下配置不依赖环境变量,保存在数据库 `Option` 表中,可在管理端设置页的「运维设置」中调整,并在保存后立即生效:
| 配置项 | 作用 | 默认值 |
| --- | --- | --- |
| `AgentHeartbeatInterval` | Agent 心跳间隔(毫秒) | `30000` |
| `AgentSyncInterval` | Agent 同步间隔(毫秒) | `30000` |
| `NodeOfflineThreshold` | 节点离线判定阈值(毫秒) | `120000` |
| `AgentUpdateRepo` | Agent 自更新仓库 | `Rain-kl/ATSFlare` |
| `GlobalApiRateLimitNum` / `GlobalApiRateLimitDuration` | 全局 API 限流次数 / 时间窗口(秒) | `300` / `180` |
| `GlobalWebRateLimitNum` / `GlobalWebRateLimitDuration` | 全局 Web 限流次数 / 时间窗口(秒) | `300` / `180` |
| `UploadRateLimitNum` / `UploadRateLimitDuration` | 上传接口限流次数 / 时间窗口(秒) | `50` / `60` |
| `DownloadRateLimitNum` / `DownloadRateLimitDuration` | 下载接口限流次数 / 时间窗口(秒) | `50` / `60` |
| `CriticalRateLimitNum` / `CriticalRateLimitDuration` | 登录、注册、验证码等敏感接口限流次数 / 时间窗口(秒) | `100` / `1200` |
说明:
* 限流窗口上限不能超过 `RateLimitKeyExpirationDuration`,当前为 20 分钟
* 限流按来源 IP 统计,若前置了 Nginx/CDN/LB,应正确透传真实客户端 IP
### 1.3 前端构建环境变量
新版管理端位于 `atsf_server/web`,构建时支持以下公开环境变量:
+5
View File
@@ -348,6 +348,11 @@ Agent 接口当前覆盖:
* `AgentSyncInterval`:Agent 配置同步间隔(毫秒),默认 30000
* `NodeOfflineThreshold`:节点离线判定阈值(毫秒),默认 120000
* `AgentUpdateRepo`:Agent 自动更新 GitHub 仓库地址,默认 `Rain-kl/ATSFlare`
* `GlobalApiRateLimitNum` / `GlobalApiRateLimitDuration`:全局 API 限流次数与窗口(秒)
* `GlobalWebRateLimitNum` / `GlobalWebRateLimitDuration`:全局 Web 限流次数与窗口(秒)
* `UploadRateLimitNum` / `UploadRateLimitDuration`:上传接口限流次数与窗口(秒)
* `DownloadRateLimitNum` / `DownloadRateLimitDuration`:下载接口限流次数与窗口(秒)
* `CriticalRateLimitNum` / `CriticalRateLimitDuration`:登录、注册、验证码等敏感接口限流次数与窗口(秒)
* 环境变量类设置(`SESSION_SECRET`、`SQLITE_PATH`、`PORT`)不迁移,保留原有方式
* 前端在设置页面新增「运维设置」Tab
+6
View File
@@ -54,6 +54,11 @@
* `AgentSyncInterval`(默认 30000ms)
* `NodeOfflineThreshold`(默认 120000ms)
* `AgentUpdateRepo`(默认 `Rain-kl/ATSFlare`)
* `GlobalApiRateLimitNum` / `GlobalApiRateLimitDuration`
* `GlobalWebRateLimitNum` / `GlobalWebRateLimitDuration`
* `UploadRateLimitNum` / `UploadRateLimitDuration`
* `DownloadRateLimitNum` / `DownloadRateLimitDuration`
* `CriticalRateLimitNum` / `CriticalRateLimitDuration`
2. 在 `model/option.go` 的 `InitOptionMap()` 注册新选项
3. 在 `model/option.go` 的 `updateOptionMap()` 增加对新选项的同步
4. 修改 `service/agent.go` 中 `computeNodeStatus()` 使用动态 `NodeOfflineThreshold`
@@ -64,6 +69,7 @@
* 运维设置在设置页面可查看和修改
* 修改后立即生效,无需重启 Server
* `NodeOfflineThreshold` 变更后节点状态判定使用新阈值
* 限流阈值与时间窗口可在设置页调整,并即时影响对应中间件
### 3.2 阶段二:Server 下发 Agent 设置 + Agent 接收