mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-10-09 00:56:37 +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:
@@ -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 与部署命令'
|
||||
|
||||
Reference in New Issue
Block a user