feat: surface oauth quota details
This commit is contained in:
@@ -283,6 +283,33 @@ export type OAuthSessionInfo = {
|
||||
error?: string;
|
||||
};
|
||||
|
||||
export type OAuthQuotaWindowInfo = {
|
||||
supported: boolean;
|
||||
limit?: number | null;
|
||||
used?: number | null;
|
||||
remaining?: number | null;
|
||||
resetAt?: string | null;
|
||||
message?: string | null;
|
||||
};
|
||||
|
||||
export type OAuthQuotaInfo = {
|
||||
status: 'supported' | 'unsupported' | 'error';
|
||||
source: 'official' | 'reverse_engineered';
|
||||
lastSyncAt?: string | null;
|
||||
lastError?: string | null;
|
||||
providerMessage?: string | null;
|
||||
subscription?: {
|
||||
planType?: string | null;
|
||||
activeStart?: string | null;
|
||||
activeUntil?: string | null;
|
||||
} | null;
|
||||
windows: {
|
||||
fiveHour: OAuthQuotaWindowInfo;
|
||||
sevenDay: OAuthQuotaWindowInfo;
|
||||
};
|
||||
lastLimitResetAt?: string | null;
|
||||
};
|
||||
|
||||
export type OAuthConnectionInfo = {
|
||||
accountId: number;
|
||||
siteId: number;
|
||||
@@ -295,6 +322,7 @@ export type OAuthConnectionInfo = {
|
||||
modelCount: number;
|
||||
modelsPreview: string[];
|
||||
status: 'healthy' | 'abnormal';
|
||||
quota?: OAuthQuotaInfo | null;
|
||||
routeChannelCount?: number;
|
||||
lastModelSyncAt?: string | null;
|
||||
lastModelSyncError?: string | null;
|
||||
@@ -432,6 +460,10 @@ export const api = {
|
||||
}) as Promise<{ success: true }>,
|
||||
getOAuthConnections: (params?: { limit?: number; offset?: number }) =>
|
||||
request(`/api/oauth/connections${buildQueryString(params)}`) as Promise<OAuthConnectionsResponse>,
|
||||
refreshOAuthConnectionQuota: (accountId: number) => request(`/api/oauth/connections/${accountId}/quota/refresh`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({}),
|
||||
}) as Promise<{ success: true; quota: OAuthQuotaInfo }>,
|
||||
rebindOAuthConnection: (accountId: number) => request(`/api/oauth/connections/${accountId}/rebind`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({}),
|
||||
|
||||
@@ -11,6 +11,7 @@ const { apiMock, openMock, focusMock, confirmMock, promptMock } = vi.hoisted(()
|
||||
startOAuthProvider: vi.fn(),
|
||||
getOAuthSession: vi.fn(),
|
||||
submitOAuthManualCallback: vi.fn(),
|
||||
refreshOAuthConnectionQuota: vi.fn(),
|
||||
rebindOAuthConnection: vi.fn(),
|
||||
deleteOAuthConnection: vi.fn(),
|
||||
},
|
||||
@@ -684,6 +685,26 @@ describe('OAuthManagement page', () => {
|
||||
modelCount: 11,
|
||||
modelsPreview: ['gpt-5.4', 'gpt-5.3-codex', 'gpt-5.2-codex'],
|
||||
status: 'abnormal',
|
||||
quota: {
|
||||
status: 'supported',
|
||||
source: 'reverse_engineered',
|
||||
subscription: {
|
||||
planType: 'team',
|
||||
activeStart: '2026-03-01T00:00:00.000Z',
|
||||
activeUntil: '2026-04-01T00:00:00.000Z',
|
||||
},
|
||||
lastLimitResetAt: '2026-03-17T13:00:00.000Z',
|
||||
windows: {
|
||||
fiveHour: {
|
||||
supported: false,
|
||||
message: 'official 5h quota window is not exposed by current codex oauth artifacts',
|
||||
},
|
||||
sevenDay: {
|
||||
supported: false,
|
||||
message: 'official 7d quota window is not exposed by current codex oauth artifacts',
|
||||
},
|
||||
},
|
||||
},
|
||||
routeChannelCount: 1,
|
||||
lastModelSyncAt: '2026-03-17T08:00:00.000Z',
|
||||
lastModelSyncError: 'Codex 模型获取失败(HTTP 403: forbidden)',
|
||||
@@ -713,6 +734,12 @@ describe('OAuthManagement page', () => {
|
||||
expect(text).toContain('异常');
|
||||
expect(text).toContain('1 条路由');
|
||||
expect(text).toContain('Codex 模型获取失败');
|
||||
expect(text).toContain('额度状态');
|
||||
expect(text).toContain('team');
|
||||
expect(text).toContain('2026-03-01T00:00:00.000Z');
|
||||
expect(text).toContain('2026-04-01T00:00:00.000Z');
|
||||
expect(text).toContain('2026-03-17T13:00:00.000Z');
|
||||
expect(text).toContain('官方未提供');
|
||||
});
|
||||
|
||||
const deleteButton = root!.root.find((node) => (
|
||||
@@ -735,4 +762,139 @@ describe('OAuthManagement page', () => {
|
||||
root?.unmount();
|
||||
}
|
||||
});
|
||||
|
||||
it('refreshes quota for a connection and reloads the connection list', 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: 7,
|
||||
provider: 'codex',
|
||||
email: 'codex-user@example.com',
|
||||
planType: 'team',
|
||||
modelCount: 11,
|
||||
modelsPreview: ['gpt-5.4'],
|
||||
status: 'healthy',
|
||||
quota: {
|
||||
status: 'supported',
|
||||
source: 'reverse_engineered',
|
||||
windows: {
|
||||
fiveHour: {
|
||||
supported: false,
|
||||
message: 'official 5h quota window is not exposed by current codex oauth artifacts',
|
||||
},
|
||||
sevenDay: {
|
||||
supported: false,
|
||||
message: 'official 7d quota window is not exposed by current codex oauth artifacts',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
total: 1,
|
||||
limit: 100,
|
||||
offset: 0,
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
items: [
|
||||
{
|
||||
accountId: 7,
|
||||
provider: 'codex',
|
||||
email: 'codex-user@example.com',
|
||||
planType: 'team',
|
||||
modelCount: 11,
|
||||
modelsPreview: ['gpt-5.4'],
|
||||
status: 'healthy',
|
||||
quota: {
|
||||
status: 'supported',
|
||||
source: 'reverse_engineered',
|
||||
lastSyncAt: '2026-03-18T01:00:00.000Z',
|
||||
windows: {
|
||||
fiveHour: {
|
||||
supported: false,
|
||||
message: 'official 5h quota window is not exposed by current codex oauth artifacts',
|
||||
},
|
||||
sevenDay: {
|
||||
supported: false,
|
||||
message: 'official 7d quota window is not exposed by current codex oauth artifacts',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
total: 1,
|
||||
limit: 100,
|
||||
offset: 0,
|
||||
});
|
||||
apiMock.refreshOAuthConnectionQuota.mockResolvedValue({
|
||||
success: true,
|
||||
quota: {
|
||||
status: 'supported',
|
||||
source: 'reverse_engineered',
|
||||
lastSyncAt: '2026-03-18T01:00:00.000Z',
|
||||
windows: {
|
||||
fiveHour: {
|
||||
supported: false,
|
||||
message: 'official 5h quota window is not exposed by current codex oauth artifacts',
|
||||
},
|
||||
sevenDay: {
|
||||
supported: false,
|
||||
message: 'official 7d quota window is not exposed by current codex oauth artifacts',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
let root: ReturnType<typeof create> | null = null;
|
||||
try {
|
||||
await act(async () => {
|
||||
root = create(
|
||||
<ToastProvider>
|
||||
<MemoryRouter>
|
||||
<OAuthManagement />
|
||||
</MemoryRouter>
|
||||
</ToastProvider>,
|
||||
);
|
||||
});
|
||||
await vi.waitFor(async () => {
|
||||
await flushMicrotasks();
|
||||
expect(collectText(root!.root)).toContain('刷新额度');
|
||||
});
|
||||
|
||||
const refreshButton = root!.root.find((node) => (
|
||||
node.type === 'button'
|
||||
&& typeof node.props.onClick === 'function'
|
||||
&& collectText(node).includes('刷新额度')
|
||||
));
|
||||
|
||||
await act(async () => {
|
||||
await refreshButton.props.onClick();
|
||||
});
|
||||
await vi.waitFor(async () => {
|
||||
await flushMicrotasks();
|
||||
});
|
||||
|
||||
expect(apiMock.refreshOAuthConnectionQuota).toHaveBeenCalledWith(7);
|
||||
expect(apiMock.getOAuthConnections).toHaveBeenCalledTimes(2);
|
||||
expect(collectText(root!.root)).toContain('额度信息已刷新');
|
||||
expect(collectText(root!.root)).toContain('2026-03-18T01:00:00.000Z');
|
||||
} finally {
|
||||
root?.unmount();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
import { useEffect, useState, type ReactNode } from 'react';
|
||||
import { api, type OAuthConnectionInfo, type OAuthProviderInfo, type OAuthStartInstructions } from '../api.js';
|
||||
import {
|
||||
api,
|
||||
type OAuthConnectionInfo,
|
||||
type OAuthProviderInfo,
|
||||
type OAuthQuotaInfo,
|
||||
type OAuthQuotaWindowInfo,
|
||||
type OAuthStartInstructions,
|
||||
} from '../api.js';
|
||||
|
||||
const POLL_INTERVAL_MS = 1500;
|
||||
const CONNECTION_PAGE_LIMIT = 100;
|
||||
@@ -57,6 +64,35 @@ function resolveConnectionEmailLabel(connection: OAuthConnectionInfo): string {
|
||||
return email;
|
||||
}
|
||||
|
||||
function resolveQuotaStatusLabel(status?: OAuthQuotaInfo['status']): string {
|
||||
if (status === 'unsupported') return '不支持';
|
||||
if (status === 'error') return '获取失败';
|
||||
return '支持';
|
||||
}
|
||||
|
||||
function resolveQuotaSourceLabel(source?: OAuthQuotaInfo['source']): string {
|
||||
return source === 'official' ? '官方' : '本地信号';
|
||||
}
|
||||
|
||||
function resolveQuotaWindowSummary(window?: OAuthQuotaWindowInfo | null): string {
|
||||
if (!window || !window.supported) return '官方未提供';
|
||||
|
||||
const parts: string[] = [];
|
||||
if (typeof window.remaining === 'number') {
|
||||
parts.push(`剩余 ${window.remaining}`);
|
||||
}
|
||||
if (typeof window.used === 'number' && typeof window.limit === 'number') {
|
||||
parts.push(`已用 ${window.used} / ${window.limit}`);
|
||||
} else if (typeof window.limit === 'number') {
|
||||
parts.push(`总量 ${window.limit}`);
|
||||
}
|
||||
if (window.resetAt) {
|
||||
parts.push(`重置 ${window.resetAt}`);
|
||||
}
|
||||
|
||||
return parts.length > 0 ? parts.join(' · ') : '官方未提供';
|
||||
}
|
||||
|
||||
function renderCodeBlock(value: string) {
|
||||
return (
|
||||
<code style={{
|
||||
@@ -264,6 +300,20 @@ export default function OAuthManagement() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleRefreshQuota = async (accountId: number) => {
|
||||
const actionKey = `quota:${accountId}`;
|
||||
setActionLoadingKey(actionKey);
|
||||
try {
|
||||
await api.refreshOAuthConnectionQuota(accountId);
|
||||
setSessionMessage('额度信息已刷新');
|
||||
await loadConnections();
|
||||
} catch (error: any) {
|
||||
setSessionMessage(error?.message || '刷新额度失败');
|
||||
} finally {
|
||||
setActionLoadingKey('');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="page-container">
|
||||
<div className="page-header">
|
||||
@@ -416,9 +466,11 @@ export default function OAuthManagement() {
|
||||
<div style={{ display: 'grid', gap: 12 }}>
|
||||
{connections.map((connection) => {
|
||||
const rebindActionKey = `start:${connection.provider}:${connection.accountId}`;
|
||||
const refreshQuotaActionKey = `quota:${connection.accountId}`;
|
||||
const deleteActionKey = `delete:${connection.accountId}`;
|
||||
const primaryTitle = resolveConnectionPrimaryTitle(connection);
|
||||
const emailLabel = resolveConnectionEmailLabel(connection);
|
||||
const quota = connection.quota;
|
||||
return (
|
||||
<div key={connection.accountId} style={{ border: '1px solid var(--color-border)', borderRadius: 'var(--radius-md)', padding: 14 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 12 }}>
|
||||
@@ -460,8 +512,72 @@ export default function OAuthManagement() {
|
||||
{connection.modelsPreview.join(', ')}
|
||||
</div>
|
||||
)}
|
||||
{quota && (
|
||||
<div style={{
|
||||
marginTop: 12,
|
||||
paddingTop: 12,
|
||||
borderTop: '1px dashed var(--color-border)',
|
||||
display: 'grid',
|
||||
gap: 6,
|
||||
}}
|
||||
>
|
||||
<div style={{ fontSize: 13, fontWeight: 600, color: 'var(--color-text-primary)' }}>额度状态</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>
|
||||
{resolveQuotaStatusLabel(quota.status)} · {resolveQuotaSourceLabel(quota.source)}
|
||||
{quota.lastSyncAt ? ` · 最近刷新: ${quota.lastSyncAt}` : ''}
|
||||
</div>
|
||||
{quota.subscription?.planType && (
|
||||
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>
|
||||
订阅类型: {quota.subscription.planType}
|
||||
</div>
|
||||
)}
|
||||
{quota.subscription?.activeStart && (
|
||||
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>
|
||||
订阅开始: {quota.subscription.activeStart}
|
||||
</div>
|
||||
)}
|
||||
{quota.subscription?.activeUntil && (
|
||||
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>
|
||||
订阅到期: {quota.subscription.activeUntil}
|
||||
</div>
|
||||
)}
|
||||
{quota.lastLimitResetAt && (
|
||||
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>
|
||||
最近重置提示: {quota.lastLimitResetAt}
|
||||
</div>
|
||||
)}
|
||||
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>
|
||||
5 小时窗口: {resolveQuotaWindowSummary(quota.windows?.fiveHour)}
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>
|
||||
7 天窗口: {resolveQuotaWindowSummary(quota.windows?.sevenDay)}
|
||||
</div>
|
||||
{quota.providerMessage && (
|
||||
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', lineHeight: 1.5 }}>
|
||||
{quota.providerMessage}
|
||||
</div>
|
||||
)}
|
||||
{quota.lastError && (
|
||||
<div style={{ fontSize: 12, color: 'var(--color-danger)' }}>
|
||||
{quota.lastError}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-ghost"
|
||||
onClick={() => handleRefreshQuota(connection.accountId)}
|
||||
disabled={
|
||||
actionLoadingKey === rebindActionKey
|
||||
|| actionLoadingKey === refreshQuotaActionKey
|
||||
|| actionLoadingKey === deleteActionKey
|
||||
}
|
||||
>
|
||||
{actionLoadingKey === refreshQuotaActionKey ? '刷新中...' : '刷新额度'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-ghost"
|
||||
@@ -479,7 +595,11 @@ export default function OAuthManagement() {
|
||||
},
|
||||
connection.accountId,
|
||||
)}
|
||||
disabled={actionLoadingKey === rebindActionKey || actionLoadingKey === deleteActionKey}
|
||||
disabled={
|
||||
actionLoadingKey === rebindActionKey
|
||||
|| actionLoadingKey === refreshQuotaActionKey
|
||||
|| actionLoadingKey === deleteActionKey
|
||||
}
|
||||
>
|
||||
{actionLoadingKey === rebindActionKey ? '启动中...' : '重新授权'}
|
||||
</button>
|
||||
@@ -487,7 +607,11 @@ export default function OAuthManagement() {
|
||||
type="button"
|
||||
className="btn btn-ghost"
|
||||
onClick={() => handleDelete(connection.accountId)}
|
||||
disabled={actionLoadingKey === rebindActionKey || actionLoadingKey === deleteActionKey}
|
||||
disabled={
|
||||
actionLoadingKey === rebindActionKey
|
||||
|| actionLoadingKey === refreshQuotaActionKey
|
||||
|| actionLoadingKey === deleteActionKey
|
||||
}
|
||||
>
|
||||
{actionLoadingKey === deleteActionKey ? '删除中...' : '删除连接'}
|
||||
</button>
|
||||
|
||||
Reference in New Issue
Block a user