[优化] 界面优化

This commit is contained in:
ryan
2026-03-11 19:59:16 +08:00
parent db3f9b0c0a
commit 0edc024cbd
2 changed files with 1555 additions and 1353 deletions
+75 -6
View File
@@ -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>