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
@@ -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 与部署命令'