diff --git a/apps/web/src/features/aiProviders/AiProvidersClaudeEditPage.tsx b/apps/web/src/features/aiProviders/AiProvidersClaudeEditPage.tsx index b424ed386..b59f40bc2 100644 --- a/apps/web/src/features/aiProviders/AiProvidersClaudeEditPage.tsx +++ b/apps/web/src/features/aiProviders/AiProvidersClaudeEditPage.tsx @@ -394,6 +394,7 @@ export function AiProvidersClaudeEditPage() {
{t('ai_providers.claude_models_hint')}
+
{t('ai_providers.model_alias_scope_hint')}
(null); + /** Synchronous lock so double-clicks in the same tick cannot start two enable/disable writes. */ + const configSwitchingLockRef = useRef(false); // 表格筛选 / 排序 / 详情状态 const [kindFilter, setKindFilter] = useState('all'); @@ -319,6 +321,7 @@ export function AiProvidersPage() { actions: Map ) => { if (actions.size === 0) return; + if (configSwitchingLockRef.current || configSwitchingKey) return; const rowByKey = new Map(rows.map((row) => [row.key, row])); const previous = { @@ -428,6 +431,7 @@ export function AiProvidersPage() { return; } + configSwitchingLockRef.current = true; setConfigSwitchingKey('health-check'); const applyLocalState = ( @@ -552,8 +556,9 @@ export function AiProvidersPage() { showNotification(`${t('notification.update_failed')}: ${message}`, 'error'); throw err; } finally { - setConfigSwitchingKey(null); - } + configSwitchingLockRef.current = false; + setConfigSwitchingKey(null); + } }; const setHealthCheckProviderEnabled = async (providerKey: string, enabled: boolean) => { @@ -566,12 +571,15 @@ export function AiProvidersPage() { index: number, enabled: boolean ) => { + if (configSwitchingLockRef.current || configSwitchingKey) return; + if (provider === 'gemini' || provider === 'interactions') { const source = provider === 'gemini' ? geminiKeys : interactionsKeys; const current = source[index]; if (!current) return; const switchingKey = `${provider}:${current.apiKey}`; + configSwitchingLockRef.current = true; setConfigSwitchingKey(switchingKey); const previousList = source; @@ -615,6 +623,7 @@ export function AiProvidersPage() { } showNotification(`${t('notification.update_failed')}: ${message}`, 'error'); } finally { + configSwitchingLockRef.current = false; setConfigSwitchingKey(null); } return; @@ -632,6 +641,7 @@ export function AiProvidersPage() { if (!current) return; const switchingKey = `${provider}:${current.apiKey}`; + configSwitchingLockRef.current = true; setConfigSwitchingKey(switchingKey); const previousList = source; @@ -695,15 +705,18 @@ export function AiProvidersPage() { } showNotification(`${t('notification.update_failed')}: ${message}`, 'error'); } finally { - setConfigSwitchingKey(null); - } + configSwitchingLockRef.current = false; + setConfigSwitchingKey(null); + } }; const setOpenAIProviderEnabled = async (index: number, enabled: boolean) => { + if (configSwitchingLockRef.current || configSwitchingKey) return; const current = openaiProviders[index]; if (!current) return; const switchingKey = `openai:${current.name}:${index}`; + configSwitchingLockRef.current = true; setConfigSwitchingKey(switchingKey); const previousList = openaiProviders; @@ -728,8 +741,9 @@ export function AiProvidersPage() { clearCache('openai-compatibility'); showNotification(`${t('notification.update_failed')}: ${message}`, 'error'); } finally { - setConfigSwitchingKey(null); - } + configSwitchingLockRef.current = false; + setConfigSwitchingKey(null); + } }; const setProviderWebsocketsEnabled = async ( @@ -742,7 +756,9 @@ export function AiProvidersPage() { const current = source[index]; if (!current) return; + if (configSwitchingLockRef.current || configSwitchingKey) return; const switchingKey = `${provider}:${current.apiKey}:websockets`; + configSwitchingLockRef.current = true; setConfigSwitchingKey(switchingKey); const previousList = source; @@ -794,8 +810,9 @@ export function AiProvidersPage() { } showNotification(`${t('notification.update_failed')}: ${message}`, 'error'); } finally { - setConfigSwitchingKey(null); - } + configSwitchingLockRef.current = false; + setConfigSwitchingKey(null); + } }; const setProviderCloakEnabled = async ( @@ -807,7 +824,9 @@ export function AiProvidersPage() { const current = source[index]; if (!current) return; + if (configSwitchingLockRef.current || configSwitchingKey) return; const switchingKey = `${provider}:${current.apiKey}:cloak`; + configSwitchingLockRef.current = true; setConfigSwitchingKey(switchingKey); const previousList = source; @@ -852,8 +871,9 @@ export function AiProvidersPage() { } showNotification(`${t('notification.update_failed')}: ${message}`, 'error'); } finally { - setConfigSwitchingKey(null); - } + configSwitchingLockRef.current = false; + setConfigSwitchingKey(null); + } }; const setProviderDisableCoolingEnabled = async ( @@ -911,6 +931,7 @@ export function AiProvidersPage() { } showNotification(`${t('notification.update_failed')}: ${message}`, 'error'); } finally { + configSwitchingLockRef.current = false; setConfigSwitchingKey(null); } return; @@ -942,6 +963,7 @@ export function AiProvidersPage() { clearCache('openai-compatibility'); showNotification(`${t('notification.update_failed')}: ${message}`, 'error'); } finally { + configSwitchingLockRef.current = false; setConfigSwitchingKey(null); } return; @@ -1004,8 +1026,9 @@ export function AiProvidersPage() { } showNotification(`${t('notification.update_failed')}: ${message}`, 'error'); } finally { - setConfigSwitchingKey(null); - } + configSwitchingLockRef.current = false; + setConfigSwitchingKey(null); + } }; const setProviderPriority = async (row: ProviderRow, priority: number) => { @@ -1062,6 +1085,7 @@ export function AiProvidersPage() { } showNotification(`${t('notification.update_failed')}: ${message}`, 'error'); } finally { + configSwitchingLockRef.current = false; setConfigSwitchingKey(null); } return; @@ -1095,6 +1119,7 @@ export function AiProvidersPage() { clearCache('openai-compatibility'); showNotification(`${t('notification.update_failed')}: ${message}`, 'error'); } finally { + configSwitchingLockRef.current = false; setConfigSwitchingKey(null); } return; @@ -1174,8 +1199,9 @@ export function AiProvidersPage() { } showNotification(`${t('notification.update_failed')}: ${message}`, 'error'); } finally { - setConfigSwitchingKey(null); - } + configSwitchingLockRef.current = false; + setConfigSwitchingKey(null); + } }; // 删除(按 provider 分派,沿用既有 API 契约) diff --git a/apps/web/src/features/authFiles/AuthFilesOAuthModelAliasEditPage.tsx b/apps/web/src/features/authFiles/AuthFilesOAuthModelAliasEditPage.tsx index 613ae079d..65b669861 100644 --- a/apps/web/src/features/authFiles/AuthFilesOAuthModelAliasEditPage.tsx +++ b/apps/web/src/features/authFiles/AuthFilesOAuthModelAliasEditPage.tsx @@ -16,6 +16,7 @@ import type { AuthFileItem, OAuthModelAliasEntry } from '@/types'; import { generateId, getErrorMessage } from '@/utils/helpers'; import styles from './AuthFilesOAuthModelAliasEditPage.module.scss'; import { isOAuthAliasDraftDirty } from './oauthEditorState'; +import { normalizeOAuthAliasEntries } from './oauthAliasValidation'; type AuthFileModelItem = { id: string; display_name?: string; type?: string; owned_by?: string }; @@ -336,23 +337,34 @@ export function AuthFilesOAuthModelAliasEditPage() { return; } - const seen = new Set(); - const normalized = mappings - .map((entry) => { - const name = String(entry.name ?? '').trim(); - const alias = String(entry.alias ?? '').trim(); - if (!name || !alias) return null; - const key = `${name.toLowerCase()}::${alias.toLowerCase()}::${entry.fork ? '1' : '0'}::${entry.forceMapping ? '1' : '0'}`; - if (seen.has(key)) return null; - seen.add(key); - return { - name, - alias, - ...(entry.fork ? { fork: true } : {}), - ...(entry.forceMapping ? { forceMapping: true } : {}), - }; - }) - .filter(Boolean) as OAuthModelAliasEntry[]; + const normalization = normalizeOAuthAliasEntries(mappings); + // Whole-form validation: refuse to save when any row would be dropped by CPA rules. + // This keeps the draft visible so the user can fix invalid rows instead of silently + // persisting a partial subset. + const firstIssue = normalization.issues[0]; + if (firstIssue) { + if (firstIssue.code === 'same_as_name') { + showNotification(t('oauth_model_alias.alias_same_as_name'), 'error'); + return; + } + if (firstIssue.code === 'duplicate_alias') { + showNotification( + t('oauth_model_alias.alias_duplicate', { alias: firstIssue.alias ?? '' }), + 'error' + ); + return; + } + if (firstIssue.code === 'empty_fields' || firstIssue.code === 'duplicate_entry') { + showNotification(t('oauth_model_alias.alias_incomplete'), 'error'); + return; + } + } + + const normalized = normalization.accepted; + if (normalized.length === 0 && mappings.some((entry) => entry.name.trim() || entry.alias.trim())) { + showNotification(t('oauth_model_alias.alias_incomplete'), 'error'); + return; + } setSaving(true); try { @@ -434,6 +446,11 @@ export function AuthFilesOAuthModelAliasEditPage() {
{headerHint}
+
+
{t('oauth_model_alias.scope_hint')}
+
{t('oauth_model_alias.usage_hint')}
+
+
diff --git a/apps/web/src/features/authFiles/AuthFilesPage.module.scss b/apps/web/src/features/authFiles/AuthFilesPage.module.scss index cd4c01321..52d0905ec 100644 --- a/apps/web/src/features/authFiles/AuthFilesPage.module.scss +++ b/apps/web/src/features/authFiles/AuthFilesPage.module.scss @@ -1949,6 +1949,13 @@ gap: $spacing-sm; } +.cardScopeHint { + margin: 0 0 $spacing-sm; + color: var(--text-secondary); + font-size: 12px; + line-height: 1.45; +} + .excludedItem { display: flex; justify-content: space-between; diff --git a/apps/web/src/features/authFiles/components/OAuthExcludedCard.tsx b/apps/web/src/features/authFiles/components/OAuthExcludedCard.tsx index a163bc2ff..676535bb0 100644 --- a/apps/web/src/features/authFiles/components/OAuthExcludedCard.tsx +++ b/apps/web/src/features/authFiles/components/OAuthExcludedCard.tsx @@ -29,6 +29,9 @@ export function OAuthExcludedCard(props: OAuthExcludedCardProps) { } > + {loadState === 'ready' ? ( +
{t('oauth_excluded.scope_hint')}
+ ) : null} {loadState === 'unsupported' ? ( + {models?.length ? ( +
+ {models.slice(0, 3).join(' · ')} + {models.length > 3 ? ` · +${models.length - 3}` : ''} +
+ ) : null}