mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-10-02 23:06:36 +08:00
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:
@@ -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
|
||||
)
|
||||
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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`,构建时支持以下公开环境变量:
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 接收
|
||||
|
||||
|
||||
Reference in New Issue
Block a user