diff --git a/src/web/index.css b/src/web/index.css index ebbc7ec..84f1093 100644 --- a/src/web/index.css +++ b/src/web/index.css @@ -2950,6 +2950,44 @@ textarea { margin-bottom: 16px; } +.oauth-page-message-info { + border: 1px solid color-mix(in srgb, var(--color-primary) 18%, var(--color-border-light)); + background: color-mix(in srgb, var(--color-primary) 6%, var(--color-bg-card)); +} + +.oauth-page-message-success { + border: 1px solid color-mix(in srgb, var(--color-success) 22%, var(--color-border-light)); + background: color-mix(in srgb, var(--color-success) 8%, var(--color-bg-card)); +} + +.oauth-page-message-error { + border: 1px solid color-mix(in srgb, var(--color-danger) 22%, var(--color-border-light)); + background: color-mix(in srgb, var(--color-danger) 7%, var(--color-bg-card)); +} + +.oauth-page-message-head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 10px; + flex-wrap: wrap; +} + +.oauth-page-message-meta { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; + margin-top: 10px; +} + +.oauth-page-message-detail { + width: 100%; + font-size: 12px; + line-height: 1.6; + color: var(--color-text-secondary); +} + .oauth-workbench-card { padding: 16px; } diff --git a/src/web/pages/OAuthManagement.test.tsx b/src/web/pages/OAuthManagement.test.tsx index ef19cb1..f890107 100644 --- a/src/web/pages/OAuthManagement.test.tsx +++ b/src/web/pages/OAuthManagement.test.tsx @@ -571,6 +571,11 @@ describe('OAuthManagement page', () => { strategy: 'round_robin', }); expect(apiMock.getOAuthConnections).toHaveBeenCalledTimes(2); + expect(collectText(root.root)).toContain('已创建路由池'); + expect(collectText(root.root)).toContain('Codex Pool'); + expect(collectText(root.root)).toContain('2 个成员'); + expect(collectText(root.root)).toContain('轮询'); + expect(collectText(root.root)).toContain('已将选中的 OAuth 账号合并为一个路由池,后续会以单个路由单元参与路由。'); expect(collectText(root.root)).toContain('路由池:Codex Pool · 2 个成员 · 轮询'); } finally { root?.unmount(); @@ -727,6 +732,227 @@ describe('OAuthManagement page', () => { } }); + it('keeps created route unit feedback when the connection list refresh fails', async () => { + apiMock.getOAuthProviders.mockResolvedValue({ + providers: [ + { + provider: 'codex', + label: 'Codex', + platform: 'codex', + enabled: true, + loginType: 'oauth', + requiresProjectId: false, + supportsDirectAccountRouting: true, + supportsCloudValidation: true, + supportsNativeProxy: true, + }, + ], + }); + apiMock.getOAuthConnections + .mockResolvedValueOnce({ + items: [ + { + accountId: 51, + siteId: 8, + provider: 'codex', + email: 'pool-a@example.com', + planType: 'team', + modelCount: 2, + modelsPreview: ['gpt-5.4'], + status: 'healthy', + routeParticipation: { kind: 'single' }, + site: { + id: 8, + name: 'ChatGPT Codex OAuth', + url: 'https://chatgpt.com/backend-api/codex', + platform: 'codex', + }, + }, + { + accountId: 52, + siteId: 8, + provider: 'codex', + email: 'pool-b@example.com', + planType: 'team', + modelCount: 2, + modelsPreview: ['gpt-5.4'], + status: 'healthy', + routeParticipation: { kind: 'single' }, + site: { + id: 8, + name: 'ChatGPT Codex OAuth', + url: 'https://chatgpt.com/backend-api/codex', + platform: 'codex', + }, + }, + ], + total: 2, + limit: 100, + offset: 0, + }) + .mockRejectedValueOnce(new Error('列表刷新失败')); + apiMock.createOAuthRouteUnit.mockResolvedValue({ success: true }); + + let root!: WebTestRenderer; + try { + await act(async () => { + root = create( + + + + + , + ); + }); + await flushMicrotasks(); + + const selectAll = root.root.find((node) => ( + node.type === 'input' + && node.props.type === 'checkbox' + && node.props['data-testid'] === 'oauth-select-all' + )); + + await act(async () => { + selectAll.props.onChange({ target: { checked: true } }); + }); + + await clickButton(root, '合并参与路由'); + + const nameInput = root.root.find((node) => ( + node.type === 'input' + && node.props['data-testid'] === 'oauth-route-unit-name' + )); + await act(async () => { + nameInput.props.onChange({ target: { value: 'Fallback Pool' } }); + }); + + await clickButton(root, '创建路由池'); + + expect(apiMock.createOAuthRouteUnit).toHaveBeenCalledWith({ + accountIds: [51, 52], + name: 'Fallback Pool', + strategy: 'round_robin', + }); + expect(apiMock.getOAuthConnections).toHaveBeenCalledTimes(2); + expect(collectText(root.root)).toContain('已创建路由池,但连接列表刷新失败'); + expect(collectText(root.root)).toContain('Fallback Pool'); + expect(collectText(root.root)).toContain('2 个成员'); + expect(collectText(root.root)).toContain('轮询'); + expect(collectText(root.root)).not.toContain('已选 2 项'); + } finally { + root?.unmount(); + } + }); + + it('keeps split feedback when the connection list refresh fails', async () => { + apiMock.getOAuthProviders.mockResolvedValue({ + providers: [ + { + provider: 'codex', + label: 'Codex', + platform: 'codex', + enabled: true, + loginType: 'oauth', + requiresProjectId: false, + supportsDirectAccountRouting: true, + supportsCloudValidation: true, + supportsNativeProxy: true, + }, + ], + }); + apiMock.getOAuthConnections + .mockResolvedValueOnce({ + items: [ + { + accountId: 61, + siteId: 8, + provider: 'codex', + email: 'split-a@example.com', + planType: 'team', + modelCount: 2, + modelsPreview: ['gpt-5.4'], + status: 'healthy', + routeParticipation: { + kind: 'route_unit', + routeUnitId: 96, + name: 'Sticky Pool', + strategy: 'stick_until_unavailable', + memberCount: 2, + }, + site: { + id: 8, + name: 'ChatGPT Codex OAuth', + url: 'https://chatgpt.com/backend-api/codex', + platform: 'codex', + }, + }, + { + accountId: 62, + siteId: 8, + provider: 'codex', + email: 'split-b@example.com', + planType: 'team', + modelCount: 2, + modelsPreview: ['gpt-5.4'], + status: 'healthy', + routeParticipation: { + kind: 'route_unit', + routeUnitId: 96, + name: 'Sticky Pool', + strategy: 'stick_until_unavailable', + memberCount: 2, + }, + site: { + id: 8, + name: 'ChatGPT Codex OAuth', + url: 'https://chatgpt.com/backend-api/codex', + platform: 'codex', + }, + }, + ], + total: 2, + limit: 100, + offset: 0, + }) + .mockRejectedValueOnce(new Error('列表刷新失败')); + apiMock.deleteOAuthRouteUnit.mockResolvedValue({ success: true }); + + let root!: WebTestRenderer; + try { + await act(async () => { + root = create( + + + + + , + ); + }); + await flushMicrotasks(); + + const selectAll = root.root.find((node) => ( + node.type === 'input' + && node.props.type === 'checkbox' + && node.props['data-testid'] === 'oauth-select-all' + )); + + await act(async () => { + selectAll.props.onChange({ target: { checked: true } }); + }); + + await clickButton(root, '拆回单体'); + + expect(apiMock.deleteOAuthRouteUnit).toHaveBeenCalledWith(96); + expect(apiMock.getOAuthConnections).toHaveBeenCalledTimes(2); + expect(collectText(root.root)).toContain('已拆回单体,但连接列表刷新失败'); + expect(collectText(root.root)).toContain('Sticky Pool'); + expect(collectText(root.root)).toContain('2 个成员'); + expect(collectText(root.root)).toContain('单个用到不可用再切'); + } finally { + root?.unmount(); + } + }); + it('only enables splitting when the full route pool is selected and falls back to routeUnit ids', async () => { apiMock.getOAuthProviders.mockResolvedValue({ providers: [ diff --git a/src/web/pages/OAuthManagement.tsx b/src/web/pages/OAuthManagement.tsx index 199b3e0..a6d5dd0 100644 --- a/src/web/pages/OAuthManagement.tsx +++ b/src/web/pages/OAuthManagement.tsx @@ -102,6 +102,19 @@ type OAuthRouteUnitModalState = { strategy: OAuthRouteUnitStrategy; }; +type SessionRouteUnitFeedback = { + action: 'created' | 'deleted'; + name: string; + memberCount: number; + strategy: OAuthRouteUnitStrategy; +}; + +type SessionFeedback = { + message: string; + tone: 'info' | 'success' | 'error'; + routeUnit?: SessionRouteUnitFeedback | null; +}; + const COLUMN_OPTIONS: Array<{ key: ColumnKey; label: string }> = [ { key: 'identity', label: '账号 / Provider' }, { key: 'site', label: '站点' }, @@ -619,7 +632,7 @@ export default function OAuthManagement() { const [providers, setProviders] = useState([]); const [connections, setConnections] = useState([]); const [loaded, setLoaded] = useState(false); - const [sessionMessage, setSessionMessage] = useState(''); + const [sessionFeedback, setSessionFeedback] = useState(null); const [actionLoadingKey, setActionLoadingKey] = useState(''); const [selectedConnectionIds, setSelectedConnectionIds] = useState([]); const [showMobileFilters, setShowMobileFilters] = useState(false); @@ -674,6 +687,47 @@ export default function OAuthManagement() { strategy: 'round_robin', }); + const setSessionMessage = useCallback(( + message: string, + tone: SessionFeedback['tone'], + options?: { + routeUnit?: SessionRouteUnitFeedback | null; + }, + ) => { + setSessionFeedback({ + message, + tone, + routeUnit: options?.routeUnit ?? null, + }); + }, []); + + const setSessionInfo = useCallback(( + message: string, + options?: { + routeUnit?: SessionRouteUnitFeedback | null; + }, + ) => { + setSessionMessage(message, 'info', options); + }, [setSessionMessage]); + + const setSessionSuccess = useCallback(( + message: string, + options?: { + routeUnit?: SessionRouteUnitFeedback | null; + }, + ) => { + setSessionMessage(message, 'success', options); + }, [setSessionMessage]); + + const setSessionError = useCallback(( + message: string, + options?: { + routeUnit?: SessionRouteUnitFeedback | null; + }, + ) => { + setSessionMessage(message, 'error', options); + }, [setSessionMessage]); + const resetOauthProxySettings = useCallback(() => { setOauthCustomProxyEnabled(false); setOauthSystemProxyEnabled(false); @@ -727,7 +781,7 @@ export default function OAuthManagement() { setSelectedProviderKey((current) => current || nextProviders[0]?.provider || ''); } catch (error: any) { console.error('failed to load oauth management data', error); - setSessionMessage(error?.message || 'OAuth 管理数据加载失败'); + setSessionError(error?.message || 'OAuth 管理数据加载失败'); } finally { setLoaded(true); } @@ -748,7 +802,7 @@ export default function OAuthManagement() { setAutoRefreshCountdown((current) => { if (current <= 1) { void loadConnections().catch((error: any) => { - setSessionMessage(error?.message || 'OAuth 连接列表刷新失败'); + setSessionError(error?.message || 'OAuth 连接列表刷新失败'); }); return autoRefreshSeconds; } @@ -770,7 +824,7 @@ export default function OAuthManagement() { setSelectedProviderKey(provider); setDrawerProjectId(''); setDrawerOpen(true); - setSessionMessage('从建站流程跳转到 OAuth 管理,请在这里完成授权。'); + setSessionInfo('从建站流程跳转到 OAuth 管理,请在这里完成授权。'); }, [loaded, location.search, providers]); useEffect(() => { @@ -785,23 +839,23 @@ export default function OAuthManagement() { if (cancelled) return; if (session.status === 'pending') { - setSessionMessage('等待授权完成'); + setSessionInfo('等待授权完成'); timer = setTimeout(poll, POLL_INTERVAL_MS); return; } if (session.status === 'success') { - setSessionMessage('授权成功'); + setSessionSuccess('授权成功'); await loadConnections(); setActiveSession(null); return; } - setSessionMessage(`授权失败:${session.error || '未知错误'}`); + setSessionError(`授权失败:${session.error || '未知错误'}`); setActiveSession(null); } catch (error: any) { if (cancelled) return; - setSessionMessage(error?.message || 'OAuth 会话状态查询失败'); + setSessionError(error?.message || 'OAuth 会话状态查询失败'); setActiveSession(null); } }; @@ -956,7 +1010,7 @@ export default function OAuthManagement() { setOauthProxyUrl(connection.useSystemProxy ? '' : asTrimmedString(connection.proxyUrl)); setDrawerOpen(true); setShowColumnMenu(false); - setSessionMessage('已打开 OAuth 代理设置,修改后可直接保存代理,或保存后重新授权。'); + setSessionInfo('已打开 OAuth 代理设置,修改后可直接保存代理,或保存后重新授权。'); }; const openRouteUnitModal = () => { @@ -1025,7 +1079,7 @@ export default function OAuthManagement() { if (drawerIntent.mode !== 'proxy') return; const customProxyUrl = asTrimmedString(oauthProxyUrl); if (oauthCustomProxyEnabled && !customProxyUrl) { - setSessionMessage('已开启代理,请先输入完整代理地址'); + setSessionError('已开启代理,请先输入完整代理地址'); return; } @@ -1044,9 +1098,9 @@ export default function OAuthManagement() { await loadConnections(); setDrawerOpen(false); resetOauthProxySettings(); - setSessionMessage('代理已保存'); + setSessionSuccess('代理已保存'); } catch (error: any) { - setSessionMessage(error?.message || '保存代理失败'); + setSessionError(error?.message || '保存代理失败'); } finally { setActionLoadingKey(''); } @@ -1060,11 +1114,11 @@ export default function OAuthManagement() { || providers[0] || null; if (!provider) { - setSessionMessage('当前没有可用的 OAuth Provider。'); + setSessionError('当前没有可用的 OAuth Provider。'); return; } if (!provider.enabled) { - setSessionMessage(`${provider.label} 当前环境未启用`); + setSessionError(`${provider.label} 当前环境未启用`); return; } @@ -1072,7 +1126,7 @@ export default function OAuthManagement() { const accountId = rebindAccount?.accountId; const customProxyUrl = asTrimmedString(oauthProxyUrl); if (oauthCustomProxyEnabled && !customProxyUrl) { - setSessionMessage('已开启代理,请先输入完整代理地址'); + setSessionError('已开启代理,请先输入完整代理地址'); return; } @@ -1102,7 +1156,7 @@ export default function OAuthManagement() { ...proxySettings, }); - setSessionMessage('等待授权完成'); + setSessionInfo('等待授权完成'); setActiveSession({ provider: started.provider, state: started.state, @@ -1112,7 +1166,7 @@ export default function OAuthManagement() { resetOauthProxySettings(); openOAuthPopup(provider.provider, started.authorizationUrl); } catch (error: any) { - setSessionMessage(error?.message || '无法启动 OAuth 授权'); + setSessionError(error?.message || '无法启动 OAuth 授权'); } finally { setActionLoadingKey(''); } @@ -1122,15 +1176,15 @@ export default function OAuthManagement() { if (!activeSession) return; const callbackUrl = manualCallbackUrl.trim(); if (!callbackUrl) { - setSessionMessage('请输入完整的回调 URL'); + setSessionError('请输入完整的回调 URL'); return; } setManualCallbackSubmitting(true); try { await api.submitOAuthManualCallback(activeSession.state, callbackUrl); - setSessionMessage('回调已提交,等待授权完成'); + setSessionInfo('回调已提交,等待授权完成'); } catch (error: any) { - setSessionMessage(error?.message || '提交回调 URL 失败'); + setSessionError(error?.message || '提交回调 URL 失败'); } finally { setManualCallbackSubmitting(false); } @@ -1145,11 +1199,11 @@ export default function OAuthManagement() { setActionLoadingKey(actionKey); try { await api.deleteOAuthConnection(accountId); - setSessionMessage('连接已删除'); + setSessionSuccess('连接已删除'); await loadConnections(); setSelectedConnectionIds((current) => current.filter((id) => id !== accountId)); } catch (error: any) { - setSessionMessage(error?.message || '删除 OAuth 连接失败'); + setSessionError(error?.message || '删除 OAuth 连接失败'); } finally { setActionLoadingKey(''); } @@ -1167,9 +1221,11 @@ export default function OAuthManagement() { const failed = results.filter((item) => item.status === 'rejected').length; await loadConnections(); setSelectedConnectionIds([]); - setSessionMessage(failed > 0 - ? `批量删除完成,${failed} 个连接删除失败` - : `已删除 ${results.length} 个 OAuth 连接`); + if (failed > 0) { + setSessionInfo(`批量删除完成,${failed} 个连接删除失败`); + } else { + setSessionSuccess(`已删除 ${results.length} 个 OAuth 连接`); + } } finally { setActionLoadingKey(''); } @@ -1180,10 +1236,10 @@ export default function OAuthManagement() { setActionLoadingKey(actionKey); try { await api.refreshOAuthConnectionQuota(accountId); - setSessionMessage('额度信息已刷新'); + setSessionSuccess('额度信息已刷新'); await loadConnections(); } catch (error: any) { - setSessionMessage(error?.message || '刷新额度失败'); + setSessionError(error?.message || '刷新额度失败'); } finally { setActionLoadingKey(''); } @@ -1195,11 +1251,13 @@ export default function OAuthManagement() { try { const result = await api.refreshOAuthConnectionQuotaBatch(selectedConnectionIds); await loadConnections(); - setSessionMessage(result.failed > 0 - ? `批量刷新完成,成功 ${result.refreshed} 个,失败 ${result.failed} 个` - : `已批量刷新 ${result.refreshed} 个 OAuth 连接`); + if (result.failed > 0) { + setSessionInfo(`批量刷新完成,成功 ${result.refreshed} 个,失败 ${result.failed} 个`); + } else { + setSessionSuccess(`已批量刷新 ${result.refreshed} 个 OAuth 连接`); + } } catch (error: any) { - setSessionMessage(error?.message || '批量刷新额度失败'); + setSessionError(error?.message || '批量刷新额度失败'); } finally { setActionLoadingKey(''); } @@ -1251,11 +1309,11 @@ export default function OAuthManagement() { applyLoadedModelsModal(connection, result); if (options.refreshUpstream) { await loadConnections(); - setSessionMessage('模型列表已刷新'); + setSessionSuccess('模型列表已刷新'); } } catch (error: any) { if (modelsModalRequestSeqRef.current !== requestId) return; - setSessionMessage(error?.message || '加载模型列表失败'); + setSessionError(error?.message || '加载模型列表失败'); setModelsModal((current) => ( options.closeOnError ? { @@ -1342,15 +1400,15 @@ export default function OAuthManagement() { const handleImport = async () => { if (importSources.length <= 0) { - setSessionMessage('请先选择 JSON 文件或粘贴 OAuth 连接 JSON 内容'); + setSessionError('请先选择 JSON 文件或粘贴 OAuth 连接 JSON 内容'); return; } if (!importPreviewSummary?.canImport) { - setSessionMessage('请先修正无效的 OAuth JSON'); + setSessionError('请先修正无效的 OAuth JSON'); return; } if (importCustomProxyEnabled && !asTrimmedString(importProxyUrl)) { - setSessionMessage('已开启代理,请先输入完整代理地址'); + setSessionError('已开启代理,请先输入完整代理地址'); return; } @@ -1384,12 +1442,16 @@ export default function OAuthManagement() { } else { toast.success(importMessage); } - setSessionMessage(importMessage); + if (result.failed > 0) { + setSessionInfo(importMessage); + } else { + setSessionSuccess(importMessage); + } closeImportModal(); } catch (error: any) { const message = error?.message || '导入 OAuth JSON 失败'; toast.error(message); - setSessionMessage(message); + setSessionError(message); } finally { setImporting(false); } @@ -1399,23 +1461,49 @@ export default function OAuthManagement() { const name = asTrimmedString(routeUnitModal.name); if (!canMergeSelectedIntoRouteUnit || selectedConnections.length < 2) return; if (!name) { - setSessionMessage('请先输入路由池名称'); + setSessionError('请先输入路由池名称'); return; } setActionLoadingKey('route-unit:create'); try { - await api.createOAuthRouteUnit({ + const result = await api.createOAuthRouteUnit({ accountIds: selectedConnections.map((connection) => connection.accountId), name, strategy: routeUnitModal.strategy, }); - await loadConnections(); + const routeUnitDefaults: SessionRouteUnitFeedback = { + action: 'created' as const, + name: asTrimmedString(name) || name, + memberCount: selectedConnections.length, + strategy: routeUnitModal.strategy, + }; + const routeUnit: SessionRouteUnitFeedback = result.routeUnit + ? { + ...routeUnitDefaults, + name: asTrimmedString(result.routeUnit.name) || routeUnitDefaults.name, + memberCount: result.routeUnit.memberCount || routeUnitDefaults.memberCount, + strategy: result.routeUnit.strategy || routeUnitDefaults.strategy, + } + : routeUnitDefaults; + toast.success(`已创建路由池:${routeUnit.name}`); + setSessionSuccess('已创建路由池', { + routeUnit, + }); setSelectedConnectionIds([]); closeRouteUnitModal(); - setSessionMessage('已合并为路由池'); + try { + await loadConnections(); + } catch { + toast.error('OAuth 连接列表刷新失败'); + setSessionError('已创建路由池,但连接列表刷新失败', { + routeUnit, + }); + } } catch (error: any) { - setSessionMessage(error?.message || '创建路由池失败'); + const message = error?.message || '创建路由池失败'; + toast.error(message); + setSessionError(message); } finally { setActionLoadingKey(''); } @@ -1428,12 +1516,30 @@ export default function OAuthManagement() { setActionLoadingKey('route-unit:delete'); try { + const routeUnitFeedback = { + action: 'deleted' as const, + name: selectedRouteUnitParticipation.name, + memberCount: selectedRouteUnitParticipation.memberCount, + strategy: selectedRouteUnitParticipation.strategy, + }; await api.deleteOAuthRouteUnit(routeUnitId); - await loadConnections(); + toast.success(`已拆回单体:${routeUnitFeedback.name}`); + setSessionSuccess('已拆回单体', { + routeUnit: routeUnitFeedback, + }); setSelectedConnectionIds([]); - setSessionMessage('已拆回单体'); + try { + await loadConnections(); + } catch { + toast.error('OAuth 连接列表刷新失败'); + setSessionError('已拆回单体,但连接列表刷新失败', { + routeUnit: routeUnitFeedback, + }); + } } catch (error: any) { - setSessionMessage(error?.message || '拆回单体失败'); + const message = error?.message || '拆回单体失败'; + toast.error(message); + setSessionError(message); } finally { setActionLoadingKey(''); } @@ -1930,9 +2036,26 @@ export default function OAuthManagement() { ) : null} - {sessionMessage ? ( -
-
{sessionMessage}
+ {sessionFeedback ? ( +
+
+
{sessionFeedback.message}
+ + {sessionFeedback.tone === 'success' ? '成功' : sessionFeedback.tone === 'error' ? '失败' : '提示'} + +
+ {sessionFeedback.routeUnit ? ( +
+ {sessionFeedback.routeUnit.name} + {sessionFeedback.routeUnit.memberCount} 个成员 + {resolveRouteUnitStrategyLabel(sessionFeedback.routeUnit.strategy)} +
+ {sessionFeedback.routeUnit.action === 'created' + ? '已将选中的 OAuth 账号合并为一个路由池,后续会以单个路由单元参与路由。' + : '该路由池已拆分回单体账号,后续会分别参与路由。'} +
+
+ ) : null}
) : null}