mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-10-08 16:46:37 +08:00
[优化] 界面优化
This commit is contained in:
@@ -21,6 +21,7 @@ func TestPhase2RateLimitOptionsHotReload(t *testing.T) {
|
|||||||
gin.SetMode(gin.TestMode)
|
gin.SetMode(gin.TestMode)
|
||||||
common.RedisEnabled = false
|
common.RedisEnabled = false
|
||||||
setupTestDB(t)
|
setupTestDB(t)
|
||||||
|
model.InitOptionMap()
|
||||||
|
|
||||||
oldGlobalApiRateLimitNum := common.GlobalApiRateLimitNum
|
oldGlobalApiRateLimitNum := common.GlobalApiRateLimitNum
|
||||||
oldGlobalApiRateLimitDuration := common.GlobalApiRateLimitDuration
|
oldGlobalApiRateLimitDuration := common.GlobalApiRateLimitDuration
|
||||||
@@ -37,21 +38,21 @@ func TestPhase2RateLimitOptionsHotReload(t *testing.T) {
|
|||||||
engine.Use(sessions.Sessions("session", cookie.NewStore([]byte("test-secret"))))
|
engine.Use(sessions.Sessions("session", cookie.NewStore([]byte("test-secret"))))
|
||||||
router.SetApiRouter(engine)
|
router.SetApiRouter(engine)
|
||||||
|
|
||||||
token := prepareRootToken(t)
|
loginCookie := loginAsRoot(t, engine)
|
||||||
|
|
||||||
performJSONRequest(t, engine, token, http.MethodPut, "/api/option/", map[string]any{
|
performSessionJSONRequest(t, engine, loginCookie, http.MethodPut, "/api/option/", map[string]any{
|
||||||
"key": "GlobalApiRateLimitNum",
|
"key": "GlobalApiRateLimitNum",
|
||||||
"value": "450",
|
"value": "450",
|
||||||
})
|
})
|
||||||
performJSONRequest(t, engine, token, http.MethodPut, "/api/option/", map[string]any{
|
performSessionJSONRequest(t, engine, loginCookie, http.MethodPut, "/api/option/", map[string]any{
|
||||||
"key": "GlobalApiRateLimitDuration",
|
"key": "GlobalApiRateLimitDuration",
|
||||||
"value": "240",
|
"value": "240",
|
||||||
})
|
})
|
||||||
performJSONRequest(t, engine, token, http.MethodPut, "/api/option/", map[string]any{
|
performSessionJSONRequest(t, engine, loginCookie, http.MethodPut, "/api/option/", map[string]any{
|
||||||
"key": "CriticalRateLimitNum",
|
"key": "CriticalRateLimitNum",
|
||||||
"value": "150",
|
"value": "150",
|
||||||
})
|
})
|
||||||
performJSONRequest(t, engine, token, http.MethodPut, "/api/option/", map[string]any{
|
performSessionJSONRequest(t, engine, loginCookie, http.MethodPut, "/api/option/", map[string]any{
|
||||||
"key": "CriticalRateLimitDuration",
|
"key": "CriticalRateLimitDuration",
|
||||||
"value": "900",
|
"value": "900",
|
||||||
})
|
})
|
||||||
@@ -69,7 +70,7 @@ func TestPhase2RateLimitOptionsHotReload(t *testing.T) {
|
|||||||
t.Fatalf("expected CriticalRateLimitDuration to be hot reloaded, got %d", common.CriticalRateLimitDuration)
|
t.Fatalf("expected CriticalRateLimitDuration to be hot reloaded, got %d", common.CriticalRateLimitDuration)
|
||||||
}
|
}
|
||||||
|
|
||||||
resp := performJSONRequest(t, engine, token, http.MethodGet, "/api/option/", nil)
|
resp := performSessionJSONRequest(t, engine, loginCookie, http.MethodGet, "/api/option/", nil)
|
||||||
var options []model.Option
|
var options []model.Option
|
||||||
decodeResponseData(t, resp, &options)
|
decodeResponseData(t, resp, &options)
|
||||||
|
|
||||||
@@ -86,6 +87,74 @@ func TestPhase2RateLimitOptionsHotReload(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func loginAsRoot(t *testing.T, engine http.Handler) *http.Cookie {
|
||||||
|
t.Helper()
|
||||||
|
payload, err := json.Marshal(map[string]any{
|
||||||
|
"username": "root",
|
||||||
|
"password": "123456",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to marshal login payload: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/api/user/login", bytes.NewReader(payload))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
recorder := httptest.NewRecorder()
|
||||||
|
engine.ServeHTTP(recorder, req)
|
||||||
|
if recorder.Code != http.StatusOK {
|
||||||
|
t.Fatalf("unexpected login status %d: %s", recorder.Code, recorder.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
var resp apiResponse
|
||||||
|
if err = json.Unmarshal(recorder.Body.Bytes(), &resp); err != nil {
|
||||||
|
t.Fatalf("failed to decode login response: %v", err)
|
||||||
|
}
|
||||||
|
if !resp.Success {
|
||||||
|
t.Fatalf("root login failed: %s", resp.Message)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, cookie := range recorder.Result().Cookies() {
|
||||||
|
if cookie.Name == "session" {
|
||||||
|
return cookie
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t.Fatal("expected session cookie after root login")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func performSessionJSONRequest(t *testing.T, engine http.Handler, sessionCookie *http.Cookie, method string, path string, body any) apiResponse {
|
||||||
|
t.Helper()
|
||||||
|
var payload []byte
|
||||||
|
var err error
|
||||||
|
if body != nil {
|
||||||
|
payload, err = json.Marshal(body)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to marshal request body: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
req := httptest.NewRequest(method, path, bytes.NewReader(payload))
|
||||||
|
if body != nil {
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
}
|
||||||
|
req.AddCookie(sessionCookie)
|
||||||
|
|
||||||
|
recorder := httptest.NewRecorder()
|
||||||
|
engine.ServeHTTP(recorder, req)
|
||||||
|
if recorder.Code != http.StatusOK {
|
||||||
|
t.Fatalf("unexpected status %d for %s %s: %s", recorder.Code, method, path, recorder.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
var resp apiResponse
|
||||||
|
if err = json.Unmarshal(recorder.Body.Bytes(), &resp); err != nil {
|
||||||
|
t.Fatalf("failed to unmarshal response: %v", err)
|
||||||
|
}
|
||||||
|
if !resp.Success {
|
||||||
|
t.Fatalf("request %s %s failed: %s", method, path, resp.Message)
|
||||||
|
}
|
||||||
|
return resp
|
||||||
|
}
|
||||||
|
|
||||||
func TestPhase2AgentLifecycle(t *testing.T) {
|
func TestPhase2AgentLifecycle(t *testing.T) {
|
||||||
gin.SetMode(gin.TestMode)
|
gin.SetMode(gin.TestMode)
|
||||||
common.RedisEnabled = false
|
common.RedisEnabled = false
|
||||||
|
|||||||
@@ -27,11 +27,7 @@ import {
|
|||||||
updateOption,
|
updateOption,
|
||||||
updateSelf,
|
updateSelf,
|
||||||
} from '@/features/settings/api/settings';
|
} from '@/features/settings/api/settings';
|
||||||
import type {
|
import type {LatestReleaseInfo, OptionItem, UpdateSelfPayload,} from '@/features/settings/types';
|
||||||
LatestReleaseInfo,
|
|
||||||
OptionItem,
|
|
||||||
UpdateSelfPayload,
|
|
||||||
} from '@/features/settings/types';
|
|
||||||
import {
|
import {
|
||||||
CodeBlock,
|
CodeBlock,
|
||||||
PrimaryButton,
|
PrimaryButton,
|
||||||
@@ -346,7 +342,11 @@ export function SettingsPage() {
|
|||||||
{key: 'personal' as const, label: '个人设置', description: '更新个人资料、绑定账号与访问令牌。'},
|
{key: 'personal' as const, label: '个人设置', description: '更新个人资料、绑定账号与访问令牌。'},
|
||||||
...(isRoot
|
...(isRoot
|
||||||
? [
|
? [
|
||||||
{ key: 'operation' as const, label: '运维设置', description: 'Agent 参数、Discovery Token 与部署命令。' },
|
{
|
||||||
|
key: 'operation' as const,
|
||||||
|
label: '运维设置',
|
||||||
|
description: 'Agent 参数、Discovery Token 与部署命令。'
|
||||||
|
},
|
||||||
{key: 'system' as const, label: '系统设置', description: '登录注册、SMTP、OAuth 与风控开关。'},
|
{key: 'system' as const, label: '系统设置', description: '登录注册、SMTP、OAuth 与风控开关。'},
|
||||||
{key: 'other' as const, label: '其他设置', description: '公告、关于、品牌信息与版本检查。'},
|
{key: 'other' as const, label: '其他设置', description: '公告、关于、品牌信息与版本检查。'},
|
||||||
]
|
]
|
||||||
@@ -489,7 +489,8 @@ export function SettingsPage() {
|
|||||||
title='个人资料'
|
title='个人资料'
|
||||||
description='可更新用户名、显示名称和密码。留空密码表示保持当前密码不变。'
|
description='可更新用户名、显示名称和密码。留空密码表示保持当前密码不变。'
|
||||||
action={
|
action={
|
||||||
<PrimaryButton type='button' onClick={handleProfileSave} disabled={busyKey === 'profile'}>
|
<PrimaryButton type='button' onClick={handleProfileSave}
|
||||||
|
disabled={busyKey === 'profile'}>
|
||||||
{busyKey === 'profile' ? '保存中...' : '保存资料'}
|
{busyKey === 'profile' ? '保存中...' : '保存资料'}
|
||||||
</PrimaryButton>
|
</PrimaryButton>
|
||||||
}
|
}
|
||||||
@@ -499,7 +500,10 @@ export function SettingsPage() {
|
|||||||
<ResourceInput
|
<ResourceInput
|
||||||
value={profileFields.username}
|
value={profileFields.username}
|
||||||
onChange={(event) =>
|
onChange={(event) =>
|
||||||
setProfileFields((previous) => ({ ...previous, username: event.target.value }))
|
setProfileFields((previous) => ({
|
||||||
|
...previous,
|
||||||
|
username: event.target.value
|
||||||
|
}))
|
||||||
}
|
}
|
||||||
placeholder='请输入用户名'
|
placeholder='请输入用户名'
|
||||||
/>
|
/>
|
||||||
@@ -509,7 +513,10 @@ export function SettingsPage() {
|
|||||||
<ResourceInput
|
<ResourceInput
|
||||||
value={profileFields.display_name}
|
value={profileFields.display_name}
|
||||||
onChange={(event) =>
|
onChange={(event) =>
|
||||||
setProfileFields((previous) => ({ ...previous, display_name: event.target.value }))
|
setProfileFields((previous) => ({
|
||||||
|
...previous,
|
||||||
|
display_name: event.target.value
|
||||||
|
}))
|
||||||
}
|
}
|
||||||
placeholder='请输入显示名称'
|
placeholder='请输入显示名称'
|
||||||
/>
|
/>
|
||||||
@@ -520,7 +527,10 @@ export function SettingsPage() {
|
|||||||
type='password'
|
type='password'
|
||||||
value={profileFields.password}
|
value={profileFields.password}
|
||||||
onChange={(event) =>
|
onChange={(event) =>
|
||||||
setProfileFields((previous) => ({ ...previous, password: event.target.value }))
|
setProfileFields((previous) => ({
|
||||||
|
...previous,
|
||||||
|
password: event.target.value
|
||||||
|
}))
|
||||||
}
|
}
|
||||||
placeholder='请输入新密码'
|
placeholder='请输入新密码'
|
||||||
/>
|
/>
|
||||||
@@ -543,7 +553,8 @@ export function SettingsPage() {
|
|||||||
>
|
>
|
||||||
<div className='space-y-4'>
|
<div className='space-y-4'>
|
||||||
<div className='grid gap-4 md:grid-cols-2'>
|
<div className='grid gap-4 md:grid-cols-2'>
|
||||||
<div className='rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-4'>
|
<div
|
||||||
|
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)]'>当前角色</p>
|
<p className='text-xs uppercase tracking-[0.2em] text-[var(--foreground-muted)]'>当前角色</p>
|
||||||
<div className='mt-2'>
|
<div className='mt-2'>
|
||||||
<StatusBadge
|
<StatusBadge
|
||||||
@@ -552,7 +563,8 @@ export function SettingsPage() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className='rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-4'>
|
<div
|
||||||
|
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)]'>已绑定邮箱</p>
|
<p className='text-xs uppercase tracking-[0.2em] text-[var(--foreground-muted)]'>已绑定邮箱</p>
|
||||||
<p className='mt-2 break-all text-sm text-[var(--foreground-primary)]'>
|
<p className='mt-2 break-all text-sm text-[var(--foreground-primary)]'>
|
||||||
{profile.email || '未绑定'}
|
{profile.email || '未绑定'}
|
||||||
@@ -571,7 +583,8 @@ export function SettingsPage() {
|
|||||||
</SecondaryButton>
|
</SecondaryButton>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<EmptyState title='尚未生成令牌' description='点击“重置令牌”后,新的访问令牌会显示在这里。' />
|
<EmptyState title='尚未生成令牌'
|
||||||
|
description='点击“重置令牌”后,新的访问令牌会显示在这里。'/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</AppCard>
|
</AppCard>
|
||||||
@@ -579,9 +592,11 @@ export function SettingsPage() {
|
|||||||
|
|
||||||
<AppCard title='账号绑定' description='支持绑定 GitHub、微信和邮箱地址,用于统一个人身份入口。'>
|
<AppCard title='账号绑定' description='支持绑定 GitHub、微信和邮箱地址,用于统一个人身份入口。'>
|
||||||
<div className='grid gap-6 xl:grid-cols-3'>
|
<div className='grid gap-6 xl:grid-cols-3'>
|
||||||
<div className='space-y-4 rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-4'>
|
<div
|
||||||
|
className='space-y-4 rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-4'>
|
||||||
<div className='space-y-1'>
|
<div className='space-y-1'>
|
||||||
<p className='text-base font-semibold text-[var(--foreground-primary)]'>GitHub 账号</p>
|
<p className='text-base font-semibold text-[var(--foreground-primary)]'>GitHub
|
||||||
|
账号</p>
|
||||||
<p className='text-sm leading-6 text-[var(--foreground-secondary)]'>
|
<p className='text-sm leading-6 text-[var(--foreground-secondary)]'>
|
||||||
当前状态:{profile.github_id ? `已绑定 ${profile.github_id}` : '未绑定'}
|
当前状态:{profile.github_id ? `已绑定 ${profile.github_id}` : '未绑定'}
|
||||||
</p>
|
</p>
|
||||||
@@ -601,7 +616,8 @@ export function SettingsPage() {
|
|||||||
</PrimaryButton>
|
</PrimaryButton>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className='space-y-4 rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-4'>
|
<div
|
||||||
|
className='space-y-4 rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-4'>
|
||||||
<div className='space-y-1'>
|
<div className='space-y-1'>
|
||||||
<p className='text-base font-semibold text-[var(--foreground-primary)]'>微信账号</p>
|
<p className='text-base font-semibold text-[var(--foreground-primary)]'>微信账号</p>
|
||||||
<p className='text-sm leading-6 text-[var(--foreground-secondary)]'>
|
<p className='text-sm leading-6 text-[var(--foreground-secondary)]'>
|
||||||
@@ -623,12 +639,14 @@ export function SettingsPage() {
|
|||||||
placeholder='请输入微信验证码'
|
placeholder='请输入微信验证码'
|
||||||
/>
|
/>
|
||||||
</ResourceField>
|
</ResourceField>
|
||||||
<PrimaryButton type='button' onClick={handleBindWeChat} disabled={!publicStatus.wechat_login || busyKey === 'wechat-bind'}>
|
<PrimaryButton type='button' onClick={handleBindWeChat}
|
||||||
|
disabled={!publicStatus.wechat_login || busyKey === 'wechat-bind'}>
|
||||||
{busyKey === 'wechat-bind' ? '绑定中...' : '绑定微信'}
|
{busyKey === 'wechat-bind' ? '绑定中...' : '绑定微信'}
|
||||||
</PrimaryButton>
|
</PrimaryButton>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className='space-y-4 rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-4'>
|
<div
|
||||||
|
className='space-y-4 rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-4'>
|
||||||
<div className='space-y-1'>
|
<div className='space-y-1'>
|
||||||
<p className='text-base font-semibold text-[var(--foreground-primary)]'>邮箱地址</p>
|
<p className='text-base font-semibold text-[var(--foreground-primary)]'>邮箱地址</p>
|
||||||
<p className='text-sm leading-6 text-[var(--foreground-secondary)]'>
|
<p className='text-sm leading-6 text-[var(--foreground-secondary)]'>
|
||||||
@@ -666,10 +684,12 @@ export function SettingsPage() {
|
|||||||
)
|
)
|
||||||
) : null}
|
) : null}
|
||||||
<div className='flex flex-wrap gap-2'>
|
<div className='flex flex-wrap gap-2'>
|
||||||
<SecondaryButton type='button' onClick={handleEmailVerification} disabled={busyKey === 'email-send'}>
|
<SecondaryButton type='button' onClick={handleEmailVerification}
|
||||||
|
disabled={busyKey === 'email-send'}>
|
||||||
{busyKey === 'email-send' ? '发送中...' : '发送验证码'}
|
{busyKey === 'email-send' ? '发送中...' : '发送验证码'}
|
||||||
</SecondaryButton>
|
</SecondaryButton>
|
||||||
<PrimaryButton type='button' onClick={handleBindEmail} disabled={busyKey === 'email-bind'}>
|
<PrimaryButton type='button' onClick={handleBindEmail}
|
||||||
|
disabled={busyKey === 'email-bind'}>
|
||||||
{busyKey === 'email-bind' ? '绑定中...' : '绑定邮箱'}
|
{busyKey === 'email-bind' ? '绑定中...' : '绑定邮箱'}
|
||||||
</PrimaryButton>
|
</PrimaryButton>
|
||||||
</div>
|
</div>
|
||||||
@@ -702,7 +722,8 @@ export function SettingsPage() {
|
|||||||
description='这些参数会通过心跳响应下发到 Agent,修改后下个周期即可生效。'
|
description='这些参数会通过心跳响应下发到 Agent,修改后下个周期即可生效。'
|
||||||
action={
|
action={
|
||||||
<div className='flex flex-wrap gap-2'>
|
<div className='flex flex-wrap gap-2'>
|
||||||
<SecondaryButton type='button' onClick={() => window.open('/swagger/index.html', '_blank', 'noopener,noreferrer')}>
|
<SecondaryButton type='button'
|
||||||
|
onClick={() => window.open('/swagger/index.html', '_blank', 'noopener,noreferrer')}>
|
||||||
打开接口文档
|
打开接口文档
|
||||||
</SecondaryButton>
|
</SecondaryButton>
|
||||||
<PrimaryButton
|
<PrimaryButton
|
||||||
@@ -741,30 +762,42 @@ export function SettingsPage() {
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
<div className='grid gap-5 md:grid-cols-3'>
|
<div className='grid gap-5 md:grid-cols-3'>
|
||||||
<ResourceField label={`心跳间隔 (${formatDurationLabel(operationFields.AgentHeartbeatInterval)})`}>
|
<ResourceField
|
||||||
|
label={`心跳间隔 (${formatDurationLabel(operationFields.AgentHeartbeatInterval)})`}>
|
||||||
<ResourceInput
|
<ResourceInput
|
||||||
type='number'
|
type='number'
|
||||||
value={operationFields.AgentHeartbeatInterval}
|
value={operationFields.AgentHeartbeatInterval}
|
||||||
onChange={(event) =>
|
onChange={(event) =>
|
||||||
setOperationFields((previous) => ({ ...previous, AgentHeartbeatInterval: event.target.value }))
|
setOperationFields((previous) => ({
|
||||||
|
...previous,
|
||||||
|
AgentHeartbeatInterval: event.target.value
|
||||||
|
}))
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</ResourceField>
|
</ResourceField>
|
||||||
<ResourceField label={`同步间隔 (${formatDurationLabel(operationFields.AgentSyncInterval)})`}>
|
<ResourceField
|
||||||
|
label={`同步间隔 (${formatDurationLabel(operationFields.AgentSyncInterval)})`}>
|
||||||
<ResourceInput
|
<ResourceInput
|
||||||
type='number'
|
type='number'
|
||||||
value={operationFields.AgentSyncInterval}
|
value={operationFields.AgentSyncInterval}
|
||||||
onChange={(event) =>
|
onChange={(event) =>
|
||||||
setOperationFields((previous) => ({ ...previous, AgentSyncInterval: event.target.value }))
|
setOperationFields((previous) => ({
|
||||||
|
...previous,
|
||||||
|
AgentSyncInterval: event.target.value
|
||||||
|
}))
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</ResourceField>
|
</ResourceField>
|
||||||
<ResourceField label={`离线阈值 (${formatDurationLabel(operationFields.NodeOfflineThreshold)})`}>
|
<ResourceField
|
||||||
|
label={`离线阈值 (${formatDurationLabel(operationFields.NodeOfflineThreshold)})`}>
|
||||||
<ResourceInput
|
<ResourceInput
|
||||||
type='number'
|
type='number'
|
||||||
value={operationFields.NodeOfflineThreshold}
|
value={operationFields.NodeOfflineThreshold}
|
||||||
onChange={(event) =>
|
onChange={(event) =>
|
||||||
setOperationFields((previous) => ({ ...previous, NodeOfflineThreshold: event.target.value }))
|
setOperationFields((previous) => ({
|
||||||
|
...previous,
|
||||||
|
NodeOfflineThreshold: event.target.value
|
||||||
|
}))
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</ResourceField>
|
</ResourceField>
|
||||||
@@ -795,7 +828,10 @@ export function SettingsPage() {
|
|||||||
<ResourceInput
|
<ResourceInput
|
||||||
value={operationFields.AgentUpdateRepo}
|
value={operationFields.AgentUpdateRepo}
|
||||||
onChange={(event) =>
|
onChange={(event) =>
|
||||||
setOperationFields((previous) => ({ ...previous, AgentUpdateRepo: event.target.value }))
|
setOperationFields((previous) => ({
|
||||||
|
...previous,
|
||||||
|
AgentUpdateRepo: event.target.value
|
||||||
|
}))
|
||||||
}
|
}
|
||||||
placeholder='Rain-kl/ATSFlare'
|
placeholder='Rain-kl/ATSFlare'
|
||||||
/>
|
/>
|
||||||
@@ -803,167 +839,6 @@ export function SettingsPage() {
|
|||||||
</AppCard>
|
</AppCard>
|
||||||
</div>
|
</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]'>
|
<div className='grid gap-6 xl:grid-cols-[1fr_1fr]'>
|
||||||
<AppCard
|
<AppCard
|
||||||
title='Discovery Token 与部署命令'
|
title='Discovery Token 与部署命令'
|
||||||
@@ -978,7 +853,8 @@ export function SettingsPage() {
|
|||||||
{rotateTokenMutation.isPending ? '生成中...' : '重新生成 Token'}
|
{rotateTokenMutation.isPending ? '生成中...' : '重新生成 Token'}
|
||||||
</SecondaryButton>
|
</SecondaryButton>
|
||||||
{discoveryCommand ? (
|
{discoveryCommand ? (
|
||||||
<PrimaryButton type='button' onClick={() => void copyToClipboard(discoveryCommand)}>
|
<PrimaryButton type='button'
|
||||||
|
onClick={() => void copyToClipboard(discoveryCommand)}>
|
||||||
复制命令
|
复制命令
|
||||||
</PrimaryButton>
|
</PrimaryButton>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -988,47 +864,61 @@ export function SettingsPage() {
|
|||||||
{bootstrapQuery.isLoading ? (
|
{bootstrapQuery.isLoading ? (
|
||||||
<LoadingState/>
|
<LoadingState/>
|
||||||
) : bootstrapQuery.isError ? (
|
) : bootstrapQuery.isError ? (
|
||||||
<ErrorState title='Discovery Token 加载失败' description={getErrorMessage(bootstrapQuery.error)} />
|
<ErrorState title='Discovery Token 加载失败'
|
||||||
|
description={getErrorMessage(bootstrapQuery.error)}/>
|
||||||
) : (
|
) : (
|
||||||
<div className='space-y-4'>
|
<div className='space-y-4'>
|
||||||
<ResourceField label='Server URL' hint='默认使用当前 ServerAddress,可按需改为外部访问地址。'>
|
<ResourceField label='Server URL'
|
||||||
|
hint='默认使用当前 ServerAddress,可按需改为外部访问地址。'>
|
||||||
<ResourceInput
|
<ResourceInput
|
||||||
value={operationFields.ServerAddress}
|
value={operationFields.ServerAddress}
|
||||||
onChange={(event) =>
|
onChange={(event) =>
|
||||||
setOperationFields((previous) => ({ ...previous, ServerAddress: event.target.value }))
|
setOperationFields((previous) => ({
|
||||||
|
...previous,
|
||||||
|
ServerAddress: event.target.value
|
||||||
|
}))
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</ResourceField>
|
</ResourceField>
|
||||||
<div className='rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-4'>
|
<div
|
||||||
<p className='text-xs uppercase tracking-[0.2em] text-[var(--foreground-muted)]'>Discovery Token</p>
|
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)]'>Discovery
|
||||||
|
Token</p>
|
||||||
<p className='mt-2 break-all text-sm text-[var(--foreground-primary)]'>
|
<p className='mt-2 break-all text-sm text-[var(--foreground-primary)]'>
|
||||||
{discoveryToken || '未生成'}
|
{discoveryToken || '未生成'}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{discoveryCommand ? <CodeBlock className='whitespace-pre-wrap'>{discoveryCommand}</CodeBlock> : null}
|
{discoveryCommand ? <CodeBlock
|
||||||
|
className='whitespace-pre-wrap'>{discoveryCommand}</CodeBlock> : null}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</AppCard>
|
</AppCard>
|
||||||
|
|
||||||
<AppCard title='版本与构建信息' description='用于确认当前前端与后端版本,以及静态导出构建模式。'>
|
<AppCard title='版本与构建信息' description='用于确认当前前端与后端版本,以及静态导出构建模式。'>
|
||||||
<div className='grid gap-4 md:grid-cols-2'>
|
<div className='grid gap-4 md:grid-cols-2'>
|
||||||
<div className='rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-4'>
|
<div
|
||||||
|
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)]'>前端版本</p>
|
<p className='text-xs uppercase tracking-[0.2em] text-[var(--foreground-muted)]'>前端版本</p>
|
||||||
<p className='mt-2 text-sm text-[var(--foreground-primary)]'>{publicEnv.appVersion}</p>
|
<p className='mt-2 text-sm text-[var(--foreground-primary)]'>{publicEnv.appVersion}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className='rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-4'>
|
<div
|
||||||
|
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)]'>服务端版本</p>
|
<p className='text-xs uppercase tracking-[0.2em] text-[var(--foreground-muted)]'>服务端版本</p>
|
||||||
<p className='mt-2 text-sm text-[var(--foreground-primary)]'>{publicStatus.version}</p>
|
<p className='mt-2 text-sm text-[var(--foreground-primary)]'>{publicStatus.version}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className='rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-4'>
|
<div
|
||||||
<p className='text-xs uppercase tracking-[0.2em] text-[var(--foreground-muted)]'>Server 启动时间</p>
|
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)]'>Server
|
||||||
|
启动时间</p>
|
||||||
<p className='mt-2 text-sm text-[var(--foreground-primary)]'>
|
<p className='mt-2 text-sm text-[var(--foreground-primary)]'>
|
||||||
{formatDateTime(new Date(publicStatus.start_time * 1000))}
|
{formatDateTime(new Date(publicStatus.start_time * 1000))}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className='rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-4'>
|
<div
|
||||||
|
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)]'>运行模式</p>
|
<p className='text-xs uppercase tracking-[0.2em] text-[var(--foreground-muted)]'>运行模式</p>
|
||||||
<p className='mt-2 text-sm text-[var(--foreground-primary)]'>静态导出 + Go Server 托管</p>
|
<p className='mt-2 text-sm text-[var(--foreground-primary)]'>静态导出 + Go Server
|
||||||
|
托管</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</AppCard>
|
</AppCard>
|
||||||
@@ -1126,7 +1016,10 @@ export function SettingsPage() {
|
|||||||
<ResourceInput
|
<ResourceInput
|
||||||
value={systemFields.ServerAddress}
|
value={systemFields.ServerAddress}
|
||||||
onChange={(event) =>
|
onChange={(event) =>
|
||||||
setSystemFields((previous) => ({ ...previous, ServerAddress: event.target.value }))
|
setSystemFields((previous) => ({
|
||||||
|
...previous,
|
||||||
|
ServerAddress: event.target.value
|
||||||
|
}))
|
||||||
}
|
}
|
||||||
placeholder='https://yourdomain.com'
|
placeholder='https://yourdomain.com'
|
||||||
/>
|
/>
|
||||||
@@ -1136,7 +1029,10 @@ export function SettingsPage() {
|
|||||||
<ResourceInput
|
<ResourceInput
|
||||||
value={systemFields.SMTPServer}
|
value={systemFields.SMTPServer}
|
||||||
onChange={(event) =>
|
onChange={(event) =>
|
||||||
setSystemFields((previous) => ({ ...previous, SMTPServer: event.target.value }))
|
setSystemFields((previous) => ({
|
||||||
|
...previous,
|
||||||
|
SMTPServer: event.target.value
|
||||||
|
}))
|
||||||
}
|
}
|
||||||
placeholder='smtp.qq.com'
|
placeholder='smtp.qq.com'
|
||||||
/>
|
/>
|
||||||
@@ -1145,7 +1041,10 @@ export function SettingsPage() {
|
|||||||
<ResourceInput
|
<ResourceInput
|
||||||
value={systemFields.SMTPPort}
|
value={systemFields.SMTPPort}
|
||||||
onChange={(event) =>
|
onChange={(event) =>
|
||||||
setSystemFields((previous) => ({ ...previous, SMTPPort: event.target.value }))
|
setSystemFields((previous) => ({
|
||||||
|
...previous,
|
||||||
|
SMTPPort: event.target.value
|
||||||
|
}))
|
||||||
}
|
}
|
||||||
placeholder='587'
|
placeholder='587'
|
||||||
/>
|
/>
|
||||||
@@ -1154,7 +1053,10 @@ export function SettingsPage() {
|
|||||||
<ResourceInput
|
<ResourceInput
|
||||||
value={systemFields.SMTPAccount}
|
value={systemFields.SMTPAccount}
|
||||||
onChange={(event) =>
|
onChange={(event) =>
|
||||||
setSystemFields((previous) => ({ ...previous, SMTPAccount: event.target.value }))
|
setSystemFields((previous) => ({
|
||||||
|
...previous,
|
||||||
|
SMTPAccount: event.target.value
|
||||||
|
}))
|
||||||
}
|
}
|
||||||
placeholder='name@example.com'
|
placeholder='name@example.com'
|
||||||
/>
|
/>
|
||||||
@@ -1164,7 +1066,10 @@ export function SettingsPage() {
|
|||||||
type='password'
|
type='password'
|
||||||
value={systemFields.SMTPToken}
|
value={systemFields.SMTPToken}
|
||||||
onChange={(event) =>
|
onChange={(event) =>
|
||||||
setSystemFields((previous) => ({ ...previous, SMTPToken: event.target.value }))
|
setSystemFields((previous) => ({
|
||||||
|
...previous,
|
||||||
|
SMTPToken: event.target.value
|
||||||
|
}))
|
||||||
}
|
}
|
||||||
placeholder='请输入新的 SMTP 凭证'
|
placeholder='请输入新的 SMTP 凭证'
|
||||||
/>
|
/>
|
||||||
@@ -1207,7 +1112,10 @@ export function SettingsPage() {
|
|||||||
<ResourceInput
|
<ResourceInput
|
||||||
value={systemFields.GitHubClientId}
|
value={systemFields.GitHubClientId}
|
||||||
onChange={(event) =>
|
onChange={(event) =>
|
||||||
setSystemFields((previous) => ({ ...previous, GitHubClientId: event.target.value }))
|
setSystemFields((previous) => ({
|
||||||
|
...previous,
|
||||||
|
GitHubClientId: event.target.value
|
||||||
|
}))
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</ResourceField>
|
</ResourceField>
|
||||||
@@ -1216,7 +1124,10 @@ export function SettingsPage() {
|
|||||||
type='password'
|
type='password'
|
||||||
value={systemFields.GitHubClientSecret}
|
value={systemFields.GitHubClientSecret}
|
||||||
onChange={(event) =>
|
onChange={(event) =>
|
||||||
setSystemFields((previous) => ({ ...previous, GitHubClientSecret: event.target.value }))
|
setSystemFields((previous) => ({
|
||||||
|
...previous,
|
||||||
|
GitHubClientSecret: event.target.value
|
||||||
|
}))
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</ResourceField>
|
</ResourceField>
|
||||||
@@ -1224,7 +1135,10 @@ export function SettingsPage() {
|
|||||||
<ResourceInput
|
<ResourceInput
|
||||||
value={systemFields.WeChatServerAddress}
|
value={systemFields.WeChatServerAddress}
|
||||||
onChange={(event) =>
|
onChange={(event) =>
|
||||||
setSystemFields((previous) => ({ ...previous, WeChatServerAddress: event.target.value }))
|
setSystemFields((previous) => ({
|
||||||
|
...previous,
|
||||||
|
WeChatServerAddress: event.target.value
|
||||||
|
}))
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</ResourceField>
|
</ResourceField>
|
||||||
@@ -1233,7 +1147,10 @@ export function SettingsPage() {
|
|||||||
type='password'
|
type='password'
|
||||||
value={systemFields.WeChatServerToken}
|
value={systemFields.WeChatServerToken}
|
||||||
onChange={(event) =>
|
onChange={(event) =>
|
||||||
setSystemFields((previous) => ({ ...previous, WeChatServerToken: event.target.value }))
|
setSystemFields((previous) => ({
|
||||||
|
...previous,
|
||||||
|
WeChatServerToken: event.target.value
|
||||||
|
}))
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</ResourceField>
|
</ResourceField>
|
||||||
@@ -1241,7 +1158,10 @@ export function SettingsPage() {
|
|||||||
<ResourceInput
|
<ResourceInput
|
||||||
value={systemFields.WeChatAccountQRCodeImageURL}
|
value={systemFields.WeChatAccountQRCodeImageURL}
|
||||||
onChange={(event) =>
|
onChange={(event) =>
|
||||||
setSystemFields((previous) => ({ ...previous, WeChatAccountQRCodeImageURL: event.target.value }))
|
setSystemFields((previous) => ({
|
||||||
|
...previous,
|
||||||
|
WeChatAccountQRCodeImageURL: event.target.value
|
||||||
|
}))
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</ResourceField>
|
</ResourceField>
|
||||||
@@ -1249,7 +1169,10 @@ export function SettingsPage() {
|
|||||||
<ResourceInput
|
<ResourceInput
|
||||||
value={systemFields.TurnstileSiteKey}
|
value={systemFields.TurnstileSiteKey}
|
||||||
onChange={(event) =>
|
onChange={(event) =>
|
||||||
setSystemFields((previous) => ({ ...previous, TurnstileSiteKey: event.target.value }))
|
setSystemFields((previous) => ({
|
||||||
|
...previous,
|
||||||
|
TurnstileSiteKey: event.target.value
|
||||||
|
}))
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</ResourceField>
|
</ResourceField>
|
||||||
@@ -1258,7 +1181,10 @@ export function SettingsPage() {
|
|||||||
type='password'
|
type='password'
|
||||||
value={systemFields.TurnstileSecretKey}
|
value={systemFields.TurnstileSecretKey}
|
||||||
onChange={(event) =>
|
onChange={(event) =>
|
||||||
setSystemFields((previous) => ({ ...previous, TurnstileSecretKey: event.target.value }))
|
setSystemFields((previous) => ({
|
||||||
|
...previous,
|
||||||
|
TurnstileSecretKey: event.target.value
|
||||||
|
}))
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</ResourceField>
|
</ResourceField>
|
||||||
@@ -1266,6 +1192,210 @@ export function SettingsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</AppCard>
|
</AppCard>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1343,7 +1473,8 @@ export function SettingsPage() {
|
|||||||
description='支持 Markdown / HTML 内容编辑,并可直接检查 GitHub 最新发行版。'
|
description='支持 Markdown / HTML 内容编辑,并可直接检查 GitHub 最新发行版。'
|
||||||
action={
|
action={
|
||||||
<div className='flex flex-wrap gap-2'>
|
<div className='flex flex-wrap gap-2'>
|
||||||
<SecondaryButton type='button' onClick={handleCheckLatestRelease} disabled={busyKey === 'latest-release'}>
|
<SecondaryButton type='button' onClick={handleCheckLatestRelease}
|
||||||
|
disabled={busyKey === 'latest-release'}>
|
||||||
{busyKey === 'latest-release' ? '检查中...' : '检查更新'}
|
{busyKey === 'latest-release' ? '检查中...' : '检查更新'}
|
||||||
</SecondaryButton>
|
</SecondaryButton>
|
||||||
<PrimaryButton
|
<PrimaryButton
|
||||||
@@ -1373,7 +1504,8 @@ export function SettingsPage() {
|
|||||||
</ResourceField>
|
</ResourceField>
|
||||||
|
|
||||||
{latestRelease ? (
|
{latestRelease ? (
|
||||||
<div className='space-y-4 rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-4'>
|
<div
|
||||||
|
className='space-y-4 rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-4'>
|
||||||
<div className='flex flex-wrap items-center gap-3'>
|
<div className='flex flex-wrap items-center gap-3'>
|
||||||
<p className='text-base font-semibold text-[var(--foreground-primary)]'>最新版本:{latestRelease.tag_name}</p>
|
<p className='text-base font-semibold text-[var(--foreground-primary)]'>最新版本:{latestRelease.tag_name}</p>
|
||||||
<StatusBadge label='GitHub Release' variant='info'/>
|
<StatusBadge label='GitHub Release' variant='info'/>
|
||||||
@@ -1395,7 +1527,8 @@ export function SettingsPage() {
|
|||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<EmptyState title='尚未检查更新' description='点击“检查更新”后会在这里展示最新 GitHub Release 信息。' />
|
<EmptyState title='尚未检查更新'
|
||||||
|
description='点击“检查更新”后会在这里展示最新 GitHub Release 信息。'/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</AppCard>
|
</AppCard>
|
||||||
|
|||||||
Reference in New Issue
Block a user