feat: optimize admin settings page

This commit is contained in:
boos
2026-06-06 03:49:30 +08:00
parent fae0f607aa
commit 4448567368
+178 -37
View File
@@ -574,6 +574,70 @@ export default function Settings() {
alignItems: 'center',
gap: 10,
};
const settingsModernSectionStyle: React.CSSProperties = {
display: 'flex',
flexDirection: 'column',
gap: 16,
scrollMarginTop: 96,
};
const settingsSummaryGridStyle: React.CSSProperties = {
display: 'grid',
gridTemplateColumns: isMobile ? '1fr' : 'repeat(4, minmax(0, 1fr))',
gap: 12,
maxWidth: 1120,
marginBottom: 16,
};
const settingsSummaryCardStyle: React.CSSProperties = {
display: 'grid',
gap: 8,
padding: '14px 16px',
borderRadius: 'var(--radius-md)',
border: '1px solid var(--color-border-light)',
background: 'color-mix(in srgb, var(--color-bg-card) 92%, var(--color-bg))',
boxShadow: 'var(--shadow-card)',
minWidth: 0,
};
const settingsShellStyle: React.CSSProperties = {
maxWidth: 1120,
display: 'grid',
gridTemplateColumns: isMobile ? '1fr' : '220px minmax(0, 1fr)',
alignItems: 'start',
gap: isMobile ? 14 : 18,
};
const settingsNavStyle: React.CSSProperties = {
display: 'grid',
gap: 6,
padding: 12,
borderRadius: 'var(--radius-md)',
border: '1px solid var(--color-border-light)',
background: 'color-mix(in srgb, var(--color-bg-card) 94%, var(--color-bg))',
boxShadow: 'var(--shadow-card)',
position: isMobile ? 'static' : 'sticky',
top: 88,
maxHeight: isMobile ? undefined : 'calc(100dvh - 112px)',
overflowY: 'auto',
};
const settingsNavLinkStyle: React.CSSProperties = {
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
gap: 10,
padding: '8px 10px',
borderRadius: 'var(--radius-sm)',
color: 'var(--color-text-secondary)',
textDecoration: 'none',
fontSize: 13,
lineHeight: 1.35,
};
const settingsContentStyle: React.CSSProperties = {
display: 'flex',
flexDirection: 'column',
gap: 16,
minWidth: 0,
};
const settingsAnchorCardStyle: React.CSSProperties = {
scrollMarginTop: 96,
};
const getSettingsPillStyle = (tone: SettingsPillTone): React.CSSProperties => {
const toneStyles: Record<SettingsPillTone, React.CSSProperties> = {
@@ -627,6 +691,36 @@ export default function Settings() {
: savedModelAvailabilityProbeEnabled
? '已启用'
: '已关闭';
const activeRoutingProfileLabel = activeRoutingProfile === 'stable'
? '稳定优先'
: activeRoutingProfile === 'cost'
? '成本优先'
: '均衡';
const runtimeDatabaseSummary = runtimeDatabaseState
? `${runtimeDatabaseState.active.dialect}${runtimeDatabaseState.restartRequired ? ' · 待重启' : ''}`
: '加载中';
const systemProxySummary = runtime.systemProxyUrl.trim() ? '已配置' : '未配置';
const securitySummary = runtime.adminIpAllowlist?.length
? `${runtime.adminIpAllowlist.length} 条白名单`
: '未限制 IP';
const modelGovernanceSummary = [
blockedBrands.length > 0 ? `${blockedBrands.length} 个品牌屏蔽` : '',
allowedModels.length > 0 ? `${allowedModels.length} 个模型白名单` : '',
].filter(Boolean).join(' / ') || '全部模型开放';
const settingsSections = [
{ id: 'settings-access', label: '管理员令牌', badge: maskedToken ? '已配置' : '检查' },
{ id: 'settings-schedule', label: '定时任务', badge: runtime.checkinScheduleMode === 'interval' ? `${runtime.checkinIntervalHours}h` : 'Cron' },
{ id: 'settings-proxy', label: '代理与失败判定', badge: systemProxySummary },
{ id: 'settings-payload', label: 'Payload 规则', badge: configuredPayloadRuleCount > 0 ? `${configuredPayloadRuleCount}` : '0' },
{ id: 'settings-transport', label: '上游传输', badge: runtime.codexUpstreamWebsocketEnabled ? 'WS' : 'HTTP' },
{ id: 'settings-probe', label: '批量测活', badge: modelAvailabilityProbeStatusLabel },
{ id: 'settings-downstream-token', label: '下游令牌', badge: runtime.proxyTokenMasked ? '已配置' : '未设置' },
{ id: 'settings-routing', label: '路由策略', badge: activeRoutingProfileLabel },
{ id: 'settings-governance', label: '模型治理', badge: blockedBrands.length + allowedModels.length > 0 ? '已配置' : '开放' },
{ id: 'settings-database', label: '数据库', badge: runtimeDatabaseSummary },
{ id: 'settings-maintenance', label: '维护与危险操作', badge: '谨慎' },
{ id: 'settings-security', label: '会话与安全', badge: securitySummary },
];
const syncPayloadRuleDraftsFromObject = (value: unknown) => {
setPayloadRuleDrafts(normalizePayloadRulesForEditor(value));
@@ -824,7 +918,7 @@ export default function Settings() {
const res = await api.updateRuntimeSettings({ proxyToken: `${PROXY_TOKEN_PREFIX}${suffix}` });
setRuntime((prev) => ({ ...prev, proxyTokenMasked: res.proxyTokenMasked || prev.proxyTokenMasked }));
setProxyTokenSuffix('');
toast.success('Proxy token updated');
toast.success('下游访问令牌已更新');
} catch (err: any) {
toast.error(err?.message || '保存失败');
} finally {
@@ -1070,7 +1164,7 @@ export default function Settings() {
),
disableCrossProtocolFallback: runtime.disableCrossProtocolFallback,
});
toast.success('Routing weights saved');
toast.success('路由策略已保存');
} catch (err: any) {
toast.error(err?.message || '保存失败');
} finally {
@@ -1144,7 +1238,7 @@ export default function Settings() {
? res.currentAdminIp
: prev.currentAdminIp,
}));
toast.success('Security settings saved');
toast.success('安全设置已保存');
} catch (err: any) {
toast.error(err?.message || '保存失败');
} finally {
@@ -1209,7 +1303,7 @@ export default function Settings() {
const handleTestExternalDatabaseConnection = async () => {
if (!effectiveMigrationConnectionString) {
toast.info('Please fill target database connection first');
toast.info('请先填写目标数据库连接');
return;
}
@@ -1226,9 +1320,9 @@ export default function Settings() {
connectionString: effectiveMigrationConnectionString,
ssl: migrationSsl,
});
toast.success(`Connection success: ${res.connection || migrationDialect}`);
toast.success(`连接测试成功:${res.connection || migrationDialect}`);
} catch (err: any) {
toast.error(err?.message || 'Target database connection failed');
toast.error(err?.message || '目标数据库连接失败');
} finally {
setTestingMigrationConnection(false);
}
@@ -1236,7 +1330,7 @@ export default function Settings() {
const handleMigrateToExternalDatabase = async () => {
if (!effectiveMigrationConnectionString) {
toast.info('Please fill target database connection first');
toast.info('请先填写目标数据库连接');
return;
}
@@ -1247,8 +1341,8 @@ export default function Settings() {
}
const warning = migrationOverwrite
? 'Confirm migration and overwrite existing data in target database?'
: 'Confirm migration to target database? If target has data, migration may fail.';
? '确认迁移并覆盖目标数据库现有数据?'
: '确认迁移到目标数据库?如果目标库已有数据,迁移可能失败。';
if (!window.confirm(warning)) return;
setMigratingDatabase(true);
@@ -1260,9 +1354,9 @@ export default function Settings() {
ssl: migrationSsl,
});
setMigrationSummary(res);
toast.success(res?.message || 'Database migration completed');
toast.success(res?.message || '数据库迁移已完成');
} catch (err: any) {
toast.error(err?.message || 'Database migration failed');
toast.error(err?.message || '数据库迁移失败');
} finally {
setMigratingDatabase(false);
}
@@ -1270,7 +1364,7 @@ export default function Settings() {
const handleSaveRuntimeDatabaseConfig = async () => {
if (!effectiveMigrationConnectionString) {
toast.info('Please fill target database connection first');
toast.info('请先填写目标数据库连接');
return;
}
@@ -1302,9 +1396,9 @@ export default function Settings() {
: null,
restartRequired: !!res?.restartRequired,
});
toast.success(res?.message || 'Runtime database config saved');
toast.success(res?.message || '运行数据库配置已保存');
} catch (err: any) {
toast.error(err?.message || 'Runtime database config save failed');
toast.error(err?.message || '运行数据库配置保存失败');
} finally {
setSavingRuntimeDatabase(false);
}
@@ -1322,11 +1416,54 @@ export default function Settings() {
return (
<div className="animate-fade-in">
<div className="page-header">
<h2 className="page-title">系统设置</h2>
<div>
<h2 className="page-title">系统设置</h2>
<div style={{ fontSize: 13, color: 'var(--color-text-muted)', lineHeight: 1.7, marginTop: 6 }}>
管理员运行台:集中配置访问令牌、代理、路由、模型治理、数据库和安全策略。
</div>
</div>
</div>
<div style={{ maxWidth: 720, display: 'flex', flexDirection: 'column', gap: 16 }}>
<div className="card animate-slide-up stagger-1" style={{ padding: 20 }}>
<div style={settingsSummaryGridStyle}>
<div style={settingsSummaryCardStyle}>
<div style={settingsModernFieldLabelStyle}>运行数据库</div>
<div style={{ fontSize: 18, fontWeight: 700, color: 'var(--color-text-primary)' }}>{runtimeDatabaseSummary}</div>
<div style={settingsModernFieldHintStyle}>
{runtimeDatabaseState?.active.connection || '使用默认本地数据库'}
</div>
</div>
<div style={settingsSummaryCardStyle}>
<div style={settingsModernFieldLabelStyle}>上游传输</div>
<div style={{ fontSize: 18, fontWeight: 700, color: 'var(--color-text-primary)' }}>{proxyTransportModeLabel}</div>
<div style={settingsModernFieldHintStyle}>{proxyTransportQueueLabel}</div>
</div>
<div style={settingsSummaryCardStyle}>
<div style={settingsModernFieldLabelStyle}>路由策略</div>
<div style={{ fontSize: 18, fontWeight: 700, color: 'var(--color-text-primary)' }}>{activeRoutingProfileLabel}</div>
<div style={settingsModernFieldHintStyle}>失败冷却上限 {runtime.routeFailureCooldownMaxValue} {ROUTE_COOLDOWN_UNIT_OPTIONS.find((item) => item.value === runtime.routeFailureCooldownMaxUnit)?.label || ''}</div>
</div>
<div style={settingsSummaryCardStyle}>
<div style={settingsModernFieldLabelStyle}>模型治理</div>
<div style={{ fontSize: 18, fontWeight: 700, color: 'var(--color-text-primary)' }}>{modelGovernanceSummary}</div>
<div style={settingsModernFieldHintStyle}>批量测活:{modelAvailabilityProbeStatusLabel}</div>
</div>
</div>
<div style={settingsShellStyle}>
<aside style={settingsNavStyle} aria-label="设置分区导航">
<div style={{ fontSize: 12, fontWeight: 700, color: 'var(--color-text-muted)', padding: '2px 10px 6px' }}>
设置分区
</div>
{settingsSections.map((section) => (
<a key={section.id} href={`#${section.id}`} style={settingsNavLinkStyle}>
<span>{section.label}</span>
<span style={getSettingsPillStyle(section.badge === '谨慎' ? 'danger' : 'neutral')}>{section.badge}</span>
</a>
))}
</aside>
<div style={settingsContentStyle}>
<div id="settings-access" className="card animate-slide-up stagger-1" style={{ ...settingsModernCardStyle, ...settingsAnchorCardStyle }}>
<div style={{ fontWeight: 600, fontSize: 14, marginBottom: 12 }}>管理员登录令牌</div>
<code style={{ display: 'block', padding: '10px 14px', background: 'var(--color-bg)', borderRadius: 'var(--radius-sm)', fontSize: 13, fontFamily: 'var(--font-mono)', color: 'var(--color-text-secondary)', border: '1px solid var(--color-border-light)', marginBottom: 12 }}>
{maskedToken || '****'}
@@ -1341,7 +1478,7 @@ export default function Settings() {
/>
</div>
<div className="card animate-slide-up stagger-2" style={{ padding: 20 }}>
<div id="settings-schedule" className="card animate-slide-up stagger-2" style={{ ...settingsModernCardStyle, ...settingsAnchorCardStyle }}>
<div style={{ fontWeight: 600, fontSize: 14, marginBottom: 12 }}>定时任务</div>
<div style={{ display: 'grid', gridTemplateColumns: isMobile ? '1fr' : '180px 180px auto', gap: 12, alignItems: 'end', marginBottom: 12 }}>
<div>
@@ -1462,7 +1599,7 @@ export default function Settings() {
</div>
</div>
<div className="card animate-slide-up stagger-3" style={{ padding: 20 }}>
<div id="settings-proxy" className="card animate-slide-up stagger-3" style={{ ...settingsModernCardStyle, ...settingsAnchorCardStyle }}>
<div style={{ fontWeight: 600, fontSize: 14, marginBottom: 8 }}>系统代理</div>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 12 }}>
配置一个全局出站代理地址,站点页可按站点决定是否启用系统代理。
@@ -1504,7 +1641,7 @@ export default function Settings() {
)}
</div>
<div className="card animate-slide-up stagger-4" style={{ padding: 20 }}>
<div className="card animate-slide-up stagger-4" style={settingsModernCardStyle}>
<div style={{ fontWeight: 600, fontSize: 14, marginBottom: 8 }}>代理失败判定</div>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 12 }}>
命中任一关键词或空内容时判定失败,可触发重试。
@@ -1536,7 +1673,7 @@ export default function Settings() {
</div>
</div>
<div className="card animate-slide-up stagger-4" style={settingsModernCardStyle} data-settings-card="payload-rules">
<div id="settings-payload" className="card animate-slide-up stagger-4" style={{ ...settingsModernCardStyle, ...settingsAnchorCardStyle }} data-settings-card="payload-rules">
<div style={settingsModernHeaderStyle}>
<div style={settingsModernTitleBlockStyle}>
<div style={settingsModernTitleStyle}>Payload 规则</div>
@@ -1794,7 +1931,7 @@ export default function Settings() {
</div>
</div>
<div className="card animate-slide-up stagger-4" style={settingsModernCardStyle} data-settings-card="proxy-transport">
<div id="settings-transport" className="card animate-slide-up stagger-4" style={{ ...settingsModernCardStyle, ...settingsAnchorCardStyle }} data-settings-card="proxy-transport">
<div style={settingsModernHeaderStyle}>
<div style={settingsModernTitleBlockStyle}>
<div style={settingsModernTitleStyle}>Codex 上游传输与会话并发</div>
@@ -1891,7 +2028,7 @@ export default function Settings() {
</div>
</div>
<div className="card animate-slide-up stagger-4" style={settingsModernDangerCardStyle} data-settings-card="model-availability-probe">
<div id="settings-probe" className="card animate-slide-up stagger-4" style={{ ...settingsModernDangerCardStyle, ...settingsAnchorCardStyle }} data-settings-card="model-availability-probe">
<div style={settingsModernHeaderStyle}>
<div style={settingsModernTitleBlockStyle}>
<div style={{ ...settingsModernTitleStyle, color: 'var(--color-danger)' }}>批量测活</div>
@@ -1962,7 +2099,7 @@ export default function Settings() {
</div>
</div>
<div className="card animate-slide-up stagger-4" style={{ padding: 20 }}>
<div id="settings-downstream-token" className="card animate-slide-up stagger-4" style={{ ...settingsModernCardStyle, ...settingsAnchorCardStyle }}>
<div style={{ fontWeight: 600, fontSize: 14, marginBottom: 8 }}>下游访问令牌(PROXY_TOKEN)</div>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 12 }}>
用于下游站点或客户端访问本服务代理接口。前缀 sk- 固定不可修改,只需填写后缀。
@@ -2055,7 +2192,7 @@ export default function Settings() {
</button>
</div>
<div className="card animate-slide-up stagger-5" style={{ padding: 20 }}>
<div id="settings-routing" className="card animate-slide-up stagger-5" style={{ ...settingsModernCardStyle, ...settingsAnchorCardStyle }}>
<div style={{ fontWeight: 600, fontSize: 14, marginBottom: 10 }}>路由策略</div>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 12 }}>
先选择预设策略,只有需要精调时再展开高级参数。
@@ -2252,7 +2389,7 @@ export default function Settings() {
</div>
{/* Global Brand Filter */}
<div className="card animate-slide-up stagger-6" style={{ padding: 20 }}>
<div id="settings-governance" className="card animate-slide-up stagger-6" style={{ ...settingsModernCardStyle, ...settingsAnchorCardStyle }}>
<div style={{ fontWeight: 600, fontSize: 14, marginBottom: 8 }}>全局品牌屏蔽</div>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 12, lineHeight: 1.6 }}>
屏蔽选定品牌后,路由重建时将自动跳过匹配该品牌的所有模型。点击品牌切换屏蔽状态,保存后自动触发路由重建。
@@ -2301,7 +2438,7 @@ export default function Settings() {
</div>
{/* Global Allowed Models Whitelist */}
<div className="card animate-slide-up stagger-7" style={{ padding: 20 }}>
<div className="card animate-slide-up stagger-7" style={settingsModernCardStyle}>
<div style={{ fontWeight: 600, fontSize: 14, marginBottom: 8 }}>全局模型白名单</div>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 12, lineHeight: 1.6 }}>
配置白名单后,路由重建和候选生成将只针对白名单中的模型。留空表示允许所有模型(向后兼容)。保存后自动触发路由重建。
@@ -2411,7 +2548,7 @@ export default function Settings() {
</button>
</div>
<div className="card animate-slide-up stagger-8" style={{ padding: 20 }}>
<div id="settings-database" className="card animate-slide-up stagger-8" style={{ ...settingsModernCardStyle, ...settingsAnchorCardStyle }}>
<div style={{ fontWeight: 600, fontSize: 14, marginBottom: 10 }}>数据库迁移(SQLite / MySQL / PostgreSQL)</div>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 12 }}>
可先测试连接,再迁移数据;迁移完成后可保存为运行数据库配置(重启容器后生效)。
@@ -2462,19 +2599,19 @@ export default function Settings() {
<input
value={shorthandConnection.host}
onChange={(e) => setShorthandConnection((prev) => ({ ...prev, host: e.target.value }))}
placeholder="Host (required)"
placeholder="主机地址(必填)"
style={inputStyle}
/>
<input
value={shorthandConnection.user}
onChange={(e) => setShorthandConnection((prev) => ({ ...prev, user: e.target.value }))}
placeholder="User (required)"
placeholder="用户名(必填)"
style={inputStyle}
/>
<input
value={shorthandConnection.password}
onChange={(e) => setShorthandConnection((prev) => ({ ...prev, password: e.target.value }))}
placeholder="Password (required)"
placeholder="密码(必填)"
type="password"
style={inputStyle}
/>
@@ -2505,7 +2642,7 @@ export default function Settings() {
</div>
)}
<code style={{ display: 'block', padding: '10px 14px', background: 'var(--color-bg)', borderRadius: 'var(--radius-sm)', fontSize: 12, fontFamily: 'var(--font-mono)', color: 'var(--color-text-secondary)', border: '1px solid var(--color-border-light)' }}>
{generatedConnectionString || 'Fill host/user/password to generate connection string'}
{generatedConnectionString || '填写主机、用户名、密码后自动生成连接串'}
</code>
</div>
)}
@@ -2560,11 +2697,11 @@ export default function Settings() {
{runtimeDatabaseState && (
<div style={{ border: '1px solid var(--color-border-light)', borderRadius: 'var(--radius-sm)', padding: 10, fontSize: 12, color: 'var(--color-text-secondary)', lineHeight: 1.8, marginBottom: migrationSummary ? 12 : 0 }}>
<div>当前运行:{runtimeDatabaseState.active.dialect}({runtimeDatabaseState.active.connection || '(empty)' }){runtimeDatabaseState.active.ssl && ' [SSL]'}</div>
<div>当前运行:{runtimeDatabaseState.active.dialect}({runtimeDatabaseState.active.connection || '未配置'}){runtimeDatabaseState.active.ssl && '(已启用 SSL)'}</div>
<div>
已保存待生效:
{runtimeDatabaseState.saved
? ` ${runtimeDatabaseState.saved.dialect}(${runtimeDatabaseState.saved.connection})${runtimeDatabaseState.saved.ssl ? ' [SSL]' : ''}`
? ` ${runtimeDatabaseState.saved.dialect}(${runtimeDatabaseState.saved.connection})${runtimeDatabaseState.saved.ssl ? '(已启用 SSL)' : ''}`
: ' 未保存'}
</div>
{runtimeDatabaseState.restartRequired && (
@@ -2582,9 +2719,10 @@ export default function Settings() {
)}
</div>
<div id="settings-maintenance" style={settingsModernSectionStyle}>
<UpdateCenterSection />
<div className="card animate-slide-up stagger-6" style={{ padding: 20 }}>
<div className="card animate-slide-up stagger-6" style={settingsModernCardStyle}>
<div style={{ fontWeight: 600, fontSize: 14, marginBottom: 12 }}>维护工具</div>
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
<button onClick={handleClearCache} disabled={clearingCache} className="btn btn-ghost" style={{ border: '1px solid var(--color-border)' }}>
@@ -2596,7 +2734,7 @@ export default function Settings() {
</div>
</div>
<div className="card animate-slide-up stagger-7" style={{ padding: 20, border: '1px solid color-mix(in srgb, var(--color-danger) 30%, var(--color-border))' }}>
<div className="card animate-slide-up stagger-7" style={settingsModernDangerCardStyle}>
<div style={{ fontWeight: 600, fontSize: 14, marginBottom: 10, color: 'var(--color-danger)' }}>危险操作</div>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', lineHeight: 1.8, marginBottom: 12 }}>
重新初始化系统会清空当前 BoosAPI 使用中的全部数据库内容;若当前运行在外部 MySQL/Postgres,也会先清空该外部库中的 BoosAPI 数据,然后切回默认 SQLite。
@@ -2609,7 +2747,9 @@ export default function Settings() {
</button>
</div>
<div className="card animate-slide-up stagger-7" style={{ padding: 20 }}>
</div>
<div id="settings-security" className="card animate-slide-up stagger-7" style={{ ...settingsModernCardStyle, ...settingsAnchorCardStyle }}>
<div style={{ fontWeight: 600, fontSize: 14, marginBottom: 10 }}>会话与安全</div>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 12 }}>
登录会话默认 12 小时自动过期。可选配置管理端 IP 白名单,支持每行一个 IP 或 IPv4 CIDR 网段。
@@ -2643,6 +2783,7 @@ export default function Settings() {
</div>
</div>
</div>
</div>
<FactoryResetModal
presence={factoryResetPresence}
factoryResetting={factoryResetting}