[codex] improve oauth route unit feedback (#445)

* feat: improve oauth route unit feedback

* fix: harden oauth route unit feedback flow
This commit is contained in:
Cita
2026-04-06 15:03:33 +08:00
committed by GitHub
parent d1b42b2236
commit 448f488f95
3 changed files with 436 additions and 49 deletions
+38
View File
@@ -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;
}
+226
View File
@@ -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(
<ToastProvider>
<MemoryRouter>
<OAuthManagement />
</MemoryRouter>
</ToastProvider>,
);
});
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(
<ToastProvider>
<MemoryRouter>
<OAuthManagement />
</MemoryRouter>
</ToastProvider>,
);
});
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: [
+172 -49
View File
@@ -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<OAuthProviderInfo[]>([]);
const [connections, setConnections] = useState<OAuthConnectionInfo[]>([]);
const [loaded, setLoaded] = useState(false);
const [sessionMessage, setSessionMessage] = useState('');
const [sessionFeedback, setSessionFeedback] = useState<SessionFeedback | null>(null);
const [actionLoadingKey, setActionLoadingKey] = useState('');
const [selectedConnectionIds, setSelectedConnectionIds] = useState<number[]>([]);
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}
</div>
{sessionMessage ? (
<div className="card oauth-page-message">
<div className="oauth-page-message-text">{sessionMessage}</div>
{sessionFeedback ? (
<div className={`card oauth-page-message oauth-page-message-${sessionFeedback.tone}`.trim()}>
<div className="oauth-page-message-head">
<div className="oauth-page-message-text">{sessionFeedback.message}</div>
<span className={`badge ${sessionFeedback.tone === 'success' ? 'badge-success' : sessionFeedback.tone === 'error' ? 'badge-danger' : 'badge-info'}`}>
{sessionFeedback.tone === 'success' ? '成功' : sessionFeedback.tone === 'error' ? '失败' : '提示'}
</span>
</div>
{sessionFeedback.routeUnit ? (
<div className="oauth-page-message-meta">
<span className="badge badge-info">{sessionFeedback.routeUnit.name}</span>
<span className="badge badge-muted">{sessionFeedback.routeUnit.memberCount} 个成员</span>
<span className="badge badge-muted">{resolveRouteUnitStrategyLabel(sessionFeedback.routeUnit.strategy)}</span>
<div className="oauth-page-message-detail">
{sessionFeedback.routeUnit.action === 'created'
? '已将选中的 OAuth 账号合并为一个路由池,后续会以单个路由单元参与路由。'
: '该路由池已拆分回单体账号,后续会分别参与路由。'}
</div>
</div>
) : null}
</div>
) : null}