feat: surface admin settings attention states

This commit is contained in:
boos
2026-06-06 03:57:15 +08:00
parent 4448567368
commit 870b2a2da3
+241 -37
View File
@@ -56,6 +56,19 @@ const CHECKIN_INTERVAL_OPTIONS = Array.from({ length: 24 }, (_, index) => {
type DbDialect = 'sqlite' | 'mysql' | 'postgres';
type RouteCooldownUnit = typeof ROUTE_COOLDOWN_UNIT_OPTIONS[number]['value'];
type SettingsPillTone = 'neutral' | 'primary' | 'danger' | 'warning';
type SettingsNavItem = {
id: string;
label: string;
badge: string;
tone: SettingsPillTone;
};
type SettingsAttentionItem = {
key: string;
title: string;
detail: string;
href: string;
tone: SettingsPillTone;
};
type PayloadRulesEditorSectionKey = PayloadRuleAction;
type PayloadRulesEditorDrafts = Record<PayloadRulesEditorSectionKey, string>;
@@ -638,6 +651,38 @@ export default function Settings() {
const settingsAnchorCardStyle: React.CSSProperties = {
scrollMarginTop: 96,
};
const settingsAttentionPanelStyle: React.CSSProperties = {
maxWidth: 1120,
display: 'grid',
gap: 12,
padding: '14px 16px',
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)',
marginBottom: 16,
};
const settingsAttentionHeaderStyle: React.CSSProperties = {
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
gap: 12,
flexWrap: 'wrap',
};
const settingsAttentionGridStyle: React.CSSProperties = {
display: 'grid',
gridTemplateColumns: isMobile ? '1fr' : 'repeat(2, minmax(0, 1fr))',
gap: 10,
};
const settingsAttentionItemStyle: React.CSSProperties = {
display: 'grid',
gap: 4,
padding: '12px 14px',
borderRadius: 'var(--radius-sm)',
border: '1px solid var(--color-border-light)',
background: 'color-mix(in srgb, var(--color-bg) 78%, var(--color-bg-card))',
textDecoration: 'none',
};
const getSettingsPillStyle = (tone: SettingsPillTone): React.CSSProperties => {
const toneStyles: Record<SettingsPillTone, React.CSSProperties> = {
@@ -707,20 +752,67 @@ export default function Settings() {
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 settingsSections: SettingsNavItem[] = [
{ id: 'settings-access', label: '管理员令牌', badge: maskedToken ? '已配置' : '检查', tone: maskedToken ? 'neutral' : 'warning' },
{ id: 'settings-schedule', label: '定时任务', badge: runtime.checkinScheduleMode === 'interval' ? `${runtime.checkinIntervalHours}h` : 'Cron', tone: 'neutral' },
{ id: 'settings-proxy', label: '代理与失败判定', badge: systemProxySummary, tone: systemProxySummary === '已配置' ? 'primary' : 'neutral' },
{ id: 'settings-payload', label: 'Payload 规则', badge: configuredPayloadRuleCount > 0 ? `${configuredPayloadRuleCount}` : '0', tone: payloadAdvancedDirty ? 'warning' : configuredPayloadRuleCount > 0 ? 'primary' : 'neutral' },
{ id: 'settings-transport', label: '上游传输', badge: runtime.codexUpstreamWebsocketEnabled ? 'WS' : 'HTTP', tone: runtime.codexUpstreamWebsocketEnabled ? 'primary' : 'neutral' },
{ id: 'settings-probe', label: '批量测活', badge: modelAvailabilityProbeStatusLabel, tone: modelAvailabilityProbeStatusTone },
{ id: 'settings-downstream-token', label: '下游令牌', badge: runtime.proxyTokenMasked ? '已配置' : '未设置', tone: runtime.proxyTokenMasked ? 'primary' : 'warning' },
{ id: 'settings-routing', label: '路由策略', badge: activeRoutingProfileLabel, tone: activeRoutingProfile === 'balanced' ? 'neutral' : 'primary' },
{ id: 'settings-governance', label: '模型治理', badge: blockedBrands.length + allowedModels.length > 0 ? '已配置' : '开放', tone: blockedBrands.length + allowedModels.length > 0 ? 'primary' : 'neutral' },
{ id: 'settings-database', label: '数据库', badge: runtimeDatabaseSummary, tone: runtimeDatabaseState?.restartRequired ? 'warning' : 'neutral' },
{ id: 'settings-maintenance', label: '维护与危险操作', badge: '谨慎', tone: 'danger' },
{ id: 'settings-security', label: '会话与安全', badge: securitySummary, tone: runtime.adminIpAllowlist?.length ? 'primary' : 'neutral' },
];
const settingsAttentionItems: SettingsAttentionItem[] = [
runtimeDatabaseState?.restartRequired
? {
key: 'database-restart',
title: '数据库配置待重启',
detail: '运行数据库和已保存配置不一致,重启后才会切换到新连接。',
href: '#settings-database',
tone: 'warning',
}
: null,
payloadAdvancedDirty
? {
key: 'payload-dirty',
title: 'Payload 高级 JSON 待同步',
detail: '高级编辑器有未同步或未保存内容,离开页面前建议保存。',
href: '#settings-payload',
tone: 'warning',
}
: null,
modelAvailabilityProbeDirty
? {
key: 'probe-dirty',
title: '批量测活开关待保存',
detail: '测活状态已修改但尚未保存,当前运行状态不会立刻改变。',
href: '#settings-probe',
tone: 'warning',
}
: null,
savedModelAvailabilityProbeEnabled
? {
key: 'probe-enabled',
title: '批量测活处于开启状态',
detail: '请确认你的上游中转站允许主动探测,避免触发风控。',
href: '#settings-probe',
tone: 'danger',
}
: null,
!runtime.proxyTokenMasked
? {
key: 'downstream-token',
title: '下游访问令牌未设置',
detail: '下游客户端调用代理接口前,需要先配置 PROXY_TOKEN。',
href: '#settings-downstream-token',
tone: 'warning',
}
: null,
].filter((item): item is SettingsAttentionItem => item !== null);
const syncPayloadRuleDraftsFromObject = (value: unknown) => {
setPayloadRuleDrafts(normalizePayloadRulesForEditor(value));
@@ -1449,6 +1541,43 @@ export default function Settings() {
</div>
</div>
<div style={settingsAttentionPanelStyle} aria-label="运行关注事项">
<div style={settingsAttentionHeaderStyle}>
<div>
<div style={settingsModernTitleStyle}>运行关注事项</div>
<div style={settingsModernDescriptionStyle}>
自动汇总需要管理员确认的设置状态;没有异常时保持安静。
</div>
</div>
<span style={getSettingsPillStyle(settingsAttentionItems.length > 0 ? 'warning' : 'primary')}>
{settingsAttentionItems.length > 0 ? `${settingsAttentionItems.length} 项待确认` : '当前配置平稳'}
</span>
</div>
{settingsAttentionItems.length > 0 ? (
<div style={settingsAttentionGridStyle}>
{settingsAttentionItems.map((item) => (
<a key={item.key} href={item.href} style={settingsAttentionItemStyle}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 10 }}>
<span style={{ fontSize: 13, fontWeight: 700, color: 'var(--color-text-primary)' }}>
{item.title}
</span>
<span style={getSettingsPillStyle(item.tone)}>
{item.tone === 'danger' ? '高风险' : item.tone === 'warning' ? '待处理' : '提示'}
</span>
</div>
<div style={{ fontSize: 12, lineHeight: 1.7, color: 'var(--color-text-muted)' }}>
{item.detail}
</div>
</a>
))}
</div>
) : (
<div style={{ ...settingsModernFieldHintStyle, marginTop: 0 }}>
暂无待处理事项。可以继续检查下方分区,或直接保持当前运行配置。
</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' }}>
@@ -1457,14 +1586,24 @@ export default function Settings() {
{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>
<span style={getSettingsPillStyle(section.tone)}>{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>
<div style={settingsModernHeaderStyle}>
<div style={settingsModernTitleBlockStyle}>
<div style={settingsModernTitleStyle}>管理员登录令牌</div>
<div style={settingsModernDescriptionStyle}>
用于管理后台登录。修改后建议同步更新部署侧保存的管理员入口说明。
</div>
</div>
<span style={getSettingsPillStyle(maskedToken ? 'primary' : 'warning')}>
{maskedToken ? '已配置' : '待检查'}
</span>
</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 || '****'}
</code>
@@ -1479,7 +1618,17 @@ export default function Settings() {
</div>
<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={settingsModernHeaderStyle}>
<div style={settingsModernTitleBlockStyle}>
<div style={settingsModernTitleStyle}>定时任务</div>
<div style={settingsModernDescriptionStyle}>
控制账号签到、余额刷新和日志清理节奏,适合按站点规模调小或调大频率。
</div>
</div>
<span style={getSettingsPillStyle('neutral')}>
{runtime.checkinScheduleMode === 'interval' ? `${runtime.checkinIntervalHours} 小时签到` : 'Cron 签到'}
</span>
</div>
<div style={{ display: 'grid', gridTemplateColumns: isMobile ? '1fr' : '180px 180px auto', gap: 12, alignItems: 'end', marginBottom: 12 }}>
<div>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 6 }}>签到方式</div>
@@ -1600,9 +1749,16 @@ export default function Settings() {
</div>
<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 }}>
配置一个全局出站代理地址,站点页可按站点决定是否启用系统代理。
<div style={settingsModernHeaderStyle}>
<div style={settingsModernTitleBlockStyle}>
<div style={settingsModernTitleStyle}>系统代理</div>
<div style={settingsModernDescriptionStyle}>
配置一个全局出站代理地址,站点页可按站点决定是否启用系统代理。
</div>
</div>
<span style={getSettingsPillStyle(runtime.systemProxyUrl.trim() ? 'primary' : 'neutral')}>
{systemProxySummary}
</span>
</div>
<input
value={runtime.systemProxyUrl}
@@ -1642,9 +1798,16 @@ export default function Settings() {
</div>
<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 }}>
命中任一关键词或空内容时判定失败,可触发重试。
<div style={settingsModernHeaderStyle}>
<div style={settingsModernTitleBlockStyle}>
<div style={settingsModernTitleStyle}>代理失败判定</div>
<div style={settingsModernDescriptionStyle}>
命中任一关键词或空内容时判定失败,可触发重试。
</div>
</div>
<span style={getSettingsPillStyle(runtime.proxyEmptyContentFailEnabled || proxyErrorKeywordsText.trim() ? 'primary' : 'neutral')}>
{runtime.proxyEmptyContentFailEnabled || proxyErrorKeywordsText.trim() ? '已配置' : '默认'}
</span>
</div>
<textarea
value={proxyErrorKeywordsText}
@@ -2193,9 +2356,21 @@ export default function Settings() {
</div>
<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 }}>
先选择预设策略,只有需要精调时再展开高级参数。
<div style={settingsModernHeaderStyle}>
<div style={settingsModernTitleBlockStyle}>
<div style={settingsModernTitleStyle}>路由策略</div>
<div style={settingsModernDescriptionStyle}>
先选择预设策略,只有需要精调时再展开高级参数。
</div>
</div>
<div style={settingsModernPillRowStyle}>
<span style={getSettingsPillStyle(activeRoutingProfile === 'balanced' ? 'neutral' : 'primary')}>
{activeRoutingProfileLabel}
</span>
<span style={getSettingsPillStyle(runtime.disableCrossProtocolFallback ? 'warning' : 'neutral')}>
{runtime.disableCrossProtocolFallback ? '禁止跨协议' : '允许兼容切换'}
</span>
</div>
</div>
<div style={{ marginBottom: 12, maxWidth: 280 }}>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 6 }}>
@@ -2390,9 +2565,16 @@ export default function Settings() {
{/* Global Brand Filter */}
<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 }}>
屏蔽选定品牌后,路由重建时将自动跳过匹配该品牌的所有模型。点击品牌切换屏蔽状态,保存后自动触发路由重建。
<div style={settingsModernHeaderStyle}>
<div style={settingsModernTitleBlockStyle}>
<div style={settingsModernTitleStyle}>全局品牌屏蔽</div>
<div style={settingsModernDescriptionStyle}>
屏蔽选定品牌后,路由重建时将自动跳过匹配该品牌的所有模型。点击品牌切换屏蔽状态,保存后自动触发路由重建。
</div>
</div>
<span style={getSettingsPillStyle(blockedBrands.length > 0 ? 'warning' : 'neutral')}>
{blockedBrands.length > 0 ? `已屏蔽 ${blockedBrands.length}` : '未屏蔽'}
</span>
</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6, marginBottom: 12 }}>
{(allBrandNames || []).map((brand) => {
@@ -2439,9 +2621,16 @@ export default function Settings() {
{/* Global Allowed Models Whitelist */}
<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 }}>
配置白名单后,路由重建和候选生成将只针对白名单中的模型。留空表示允许所有模型(向后兼容)。保存后自动触发路由重建。
<div style={settingsModernHeaderStyle}>
<div style={settingsModernTitleBlockStyle}>
<div style={settingsModernTitleStyle}>全局模型白名单</div>
<div style={settingsModernDescriptionStyle}>
配置白名单后,路由重建和候选生成将只针对白名单中的模型。留空表示允许所有模型(向后兼容)。保存后自动触发路由重建。
</div>
</div>
<span style={getSettingsPillStyle(allowedModels.length > 0 ? 'primary' : 'neutral')}>
{allowedModels.length > 0 ? `${allowedModels.length} 个模型` : '全部开放'}
</span>
</div>
<div style={{ marginBottom: 12 }}>
<div style={{ display: 'flex', gap: 8, marginBottom: 8 }}>
@@ -2549,9 +2738,21 @@ export default function Settings() {
</div>
<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 }}>
可先测试连接,再迁移数据;迁移完成后可保存为运行数据库配置(重启容器后生效)。
<div style={settingsModernHeaderStyle}>
<div style={settingsModernTitleBlockStyle}>
<div style={settingsModernTitleStyle}>数据库迁移(SQLite / MySQL / PostgreSQL)</div>
<div style={settingsModernDescriptionStyle}>
可先测试连接,再迁移数据;迁移完成后可保存为运行数据库配置(重启容器后生效)。
</div>
</div>
<div style={settingsModernPillRowStyle}>
<span style={getSettingsPillStyle(runtimeDatabaseState?.restartRequired ? 'warning' : 'neutral')}>
{runtimeDatabaseSummary}
</span>
<span style={getSettingsPillStyle(migrationOverwrite ? 'warning' : 'neutral')}>
{migrationOverwrite ? '允许覆盖' : '不覆盖'}
</span>
</div>
</div>
<div style={{ display: 'grid', gridTemplateColumns: isMobile ? '1fr' : '180px 1fr', gap: 10, marginBottom: 10, alignItems: 'center' }}>
@@ -2696,9 +2897,12 @@ export default function Settings() {
</div>
{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 || '未配置'}){runtimeDatabaseState.active.ssl && '(已启用 SSL)'}</div>
<div>
<div style={{ ...settingsModernFieldCardStyle, marginBottom: migrationSummary ? 12 : 0 }}>
<div style={settingsModernFieldLabelStyle}>当前数据库状态</div>
<div style={{ fontSize: 12, color: 'var(--color-text-secondary)', lineHeight: 1.8 }}>
当前运行:{runtimeDatabaseState.active.dialect}({runtimeDatabaseState.active.connection || '未配置'}){runtimeDatabaseState.active.ssl && '(已启用 SSL)'}
</div>
<div style={{ fontSize: 12, color: 'var(--color-text-secondary)', lineHeight: 1.8 }}>
已保存待生效:
{runtimeDatabaseState.saved
? ` ${runtimeDatabaseState.saved.dialect}(${runtimeDatabaseState.saved.connection})${runtimeDatabaseState.saved.ssl ? '(已启用 SSL)' : ''}`