forked from boos/BoosAPI
[codex] add oauth route pools and proxy save flow (#440)
* feat: add oauth route pools and proxy save flow * fix: align oauth route pool schema and rate limits * fix: harden oauth route pool review fixes * fix: address oauth ci regressions * fix: close oauth codeql alerts * fix: harden oauth route unit recovery * fix: split oauth route limiters for codeql * fix: address remaining oauth review comments
This commit is contained in:
@@ -14,8 +14,11 @@ const { apiMock, openMock, focusMock, confirmMock, promptMock } = vi.hoisted(()
|
||||
refreshOAuthConnectionQuota: vi.fn(),
|
||||
refreshOAuthConnectionQuotaBatch: vi.fn(),
|
||||
rebindOAuthConnection: vi.fn(),
|
||||
updateOAuthConnectionProxy: vi.fn(),
|
||||
deleteOAuthConnection: vi.fn(),
|
||||
importOAuthConnections: vi.fn(),
|
||||
createOAuthRouteUnit: vi.fn(),
|
||||
deleteOAuthRouteUnit: vi.fn(),
|
||||
getAccountModels: vi.fn(),
|
||||
checkModels: vi.fn(),
|
||||
},
|
||||
@@ -59,6 +62,13 @@ function findOauthSettingInput(root: WebTestRenderer, key: string) {
|
||||
));
|
||||
}
|
||||
|
||||
function findOauthImportSettingInput(root: WebTestRenderer, key: string) {
|
||||
return root.root.find((node) => (
|
||||
node.type === 'input'
|
||||
&& node.props['data-oauth-import-setting'] === key
|
||||
));
|
||||
}
|
||||
|
||||
function findAllByClassName(root: WebTestRenderer, className: string) {
|
||||
return root.root.findAll((node) => (
|
||||
typeof node.props?.className === 'string'
|
||||
@@ -393,6 +403,477 @@ describe('OAuthManagement page', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('renders route participation summaries and can batch merge selected oauth accounts into a route pool', 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: 21,
|
||||
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: 22,
|
||||
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,
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
items: [
|
||||
{
|
||||
accountId: 21,
|
||||
siteId: 8,
|
||||
provider: 'codex',
|
||||
email: 'pool-a@example.com',
|
||||
planType: 'team',
|
||||
modelCount: 2,
|
||||
modelsPreview: ['gpt-5.4'],
|
||||
status: 'healthy',
|
||||
routeParticipation: {
|
||||
kind: 'route_unit',
|
||||
routeUnitId: 90,
|
||||
name: 'Codex Pool',
|
||||
strategy: 'round_robin',
|
||||
memberCount: 2,
|
||||
},
|
||||
site: {
|
||||
id: 8,
|
||||
name: 'ChatGPT Codex OAuth',
|
||||
url: 'https://chatgpt.com/backend-api/codex',
|
||||
platform: 'codex',
|
||||
},
|
||||
},
|
||||
{
|
||||
accountId: 22,
|
||||
siteId: 8,
|
||||
provider: 'codex',
|
||||
email: 'pool-b@example.com',
|
||||
planType: 'team',
|
||||
modelCount: 2,
|
||||
modelsPreview: ['gpt-5.4'],
|
||||
status: 'healthy',
|
||||
routeParticipation: {
|
||||
kind: 'route_unit',
|
||||
routeUnitId: 90,
|
||||
name: 'Codex Pool',
|
||||
strategy: 'round_robin',
|
||||
memberCount: 2,
|
||||
},
|
||||
site: {
|
||||
id: 8,
|
||||
name: 'ChatGPT Codex OAuth',
|
||||
url: 'https://chatgpt.com/backend-api/codex',
|
||||
platform: 'codex',
|
||||
},
|
||||
},
|
||||
],
|
||||
total: 2,
|
||||
limit: 100,
|
||||
offset: 0,
|
||||
});
|
||||
apiMock.createOAuthRouteUnit.mockResolvedValue({
|
||||
success: true,
|
||||
routeUnit: {
|
||||
id: 90,
|
||||
name: 'Codex Pool',
|
||||
strategy: 'round_robin',
|
||||
memberCount: 2,
|
||||
},
|
||||
});
|
||||
|
||||
let root!: WebTestRenderer;
|
||||
try {
|
||||
await act(async () => {
|
||||
root = create(
|
||||
<ToastProvider>
|
||||
<MemoryRouter>
|
||||
<OAuthManagement />
|
||||
</MemoryRouter>
|
||||
</ToastProvider>,
|
||||
);
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
expect(collectText(root.root)).toContain('单体');
|
||||
|
||||
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: 'Codex Pool' } });
|
||||
});
|
||||
|
||||
await clickButton(root, '创建路由池');
|
||||
|
||||
expect(apiMock.createOAuthRouteUnit).toHaveBeenCalledWith({
|
||||
accountIds: [21, 22],
|
||||
name: 'Codex Pool',
|
||||
strategy: 'round_robin',
|
||||
});
|
||||
expect(apiMock.getOAuthConnections).toHaveBeenCalledTimes(2);
|
||||
expect(collectText(root.root)).toContain('路由池:Codex Pool · 2 个成员 · 轮询');
|
||||
} finally {
|
||||
root?.unmount();
|
||||
}
|
||||
});
|
||||
|
||||
it('can batch split selected oauth accounts out of the same route pool', 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: 31,
|
||||
siteId: 8,
|
||||
provider: 'codex',
|
||||
email: 'split-a@example.com',
|
||||
planType: 'team',
|
||||
modelCount: 2,
|
||||
modelsPreview: ['gpt-5.4'],
|
||||
status: 'healthy',
|
||||
routeParticipation: {
|
||||
kind: 'route_unit',
|
||||
routeUnitId: 95,
|
||||
name: 'Split Pool',
|
||||
strategy: 'stick_until_unavailable',
|
||||
memberCount: 2,
|
||||
},
|
||||
site: {
|
||||
id: 8,
|
||||
name: 'ChatGPT Codex OAuth',
|
||||
url: 'https://chatgpt.com/backend-api/codex',
|
||||
platform: 'codex',
|
||||
},
|
||||
},
|
||||
{
|
||||
accountId: 32,
|
||||
siteId: 8,
|
||||
provider: 'codex',
|
||||
email: 'split-b@example.com',
|
||||
planType: 'team',
|
||||
modelCount: 2,
|
||||
modelsPreview: ['gpt-5.4'],
|
||||
status: 'healthy',
|
||||
routeParticipation: {
|
||||
kind: 'route_unit',
|
||||
routeUnitId: 95,
|
||||
name: 'Split 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,
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
items: [
|
||||
{
|
||||
accountId: 31,
|
||||
siteId: 8,
|
||||
provider: 'codex',
|
||||
email: 'split-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: 32,
|
||||
siteId: 8,
|
||||
provider: 'codex',
|
||||
email: 'split-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,
|
||||
});
|
||||
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(95);
|
||||
expect(apiMock.getOAuthConnections).toHaveBeenCalledTimes(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: [
|
||||
{
|
||||
provider: 'codex',
|
||||
label: 'Codex',
|
||||
platform: 'codex',
|
||||
enabled: true,
|
||||
loginType: 'oauth',
|
||||
requiresProjectId: false,
|
||||
supportsDirectAccountRouting: true,
|
||||
supportsCloudValidation: true,
|
||||
supportsNativeProxy: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
apiMock.getOAuthConnections.mockResolvedValue({
|
||||
items: [
|
||||
{
|
||||
accountId: 41,
|
||||
siteId: 8,
|
||||
provider: 'codex',
|
||||
email: 'partial-a@example.com',
|
||||
planType: 'team',
|
||||
modelCount: 2,
|
||||
modelsPreview: ['gpt-5.4'],
|
||||
status: 'healthy',
|
||||
routeParticipation: {
|
||||
kind: 'route_unit',
|
||||
name: 'Large Pool',
|
||||
strategy: 'round_robin',
|
||||
memberCount: 3,
|
||||
},
|
||||
routeUnit: {
|
||||
id: 97,
|
||||
name: 'Large Pool',
|
||||
strategy: 'round_robin',
|
||||
memberCount: 3,
|
||||
},
|
||||
site: {
|
||||
id: 8,
|
||||
name: 'ChatGPT Codex OAuth',
|
||||
url: 'https://chatgpt.com/backend-api/codex',
|
||||
platform: 'codex',
|
||||
},
|
||||
},
|
||||
{
|
||||
accountId: 42,
|
||||
siteId: 8,
|
||||
provider: 'codex',
|
||||
email: 'partial-b@example.com',
|
||||
planType: 'team',
|
||||
modelCount: 2,
|
||||
modelsPreview: ['gpt-5.4'],
|
||||
status: 'healthy',
|
||||
routeParticipation: {
|
||||
kind: 'route_unit',
|
||||
name: 'Large Pool',
|
||||
strategy: 'round_robin',
|
||||
memberCount: 3,
|
||||
},
|
||||
routeUnit: {
|
||||
id: 97,
|
||||
name: 'Large Pool',
|
||||
strategy: 'round_robin',
|
||||
memberCount: 3,
|
||||
},
|
||||
site: {
|
||||
id: 8,
|
||||
name: 'ChatGPT Codex OAuth',
|
||||
url: 'https://chatgpt.com/backend-api/codex',
|
||||
platform: 'codex',
|
||||
},
|
||||
},
|
||||
{
|
||||
accountId: 43,
|
||||
siteId: 8,
|
||||
provider: 'codex',
|
||||
email: 'partial-c@example.com',
|
||||
planType: 'team',
|
||||
modelCount: 2,
|
||||
modelsPreview: ['gpt-5.4'],
|
||||
status: 'healthy',
|
||||
routeParticipation: {
|
||||
kind: 'route_unit',
|
||||
name: 'Large Pool',
|
||||
strategy: 'round_robin',
|
||||
memberCount: 3,
|
||||
},
|
||||
routeUnit: {
|
||||
id: 97,
|
||||
name: 'Large Pool',
|
||||
strategy: 'round_robin',
|
||||
memberCount: 3,
|
||||
},
|
||||
site: {
|
||||
id: 8,
|
||||
name: 'ChatGPT Codex OAuth',
|
||||
url: 'https://chatgpt.com/backend-api/codex',
|
||||
platform: 'codex',
|
||||
},
|
||||
},
|
||||
],
|
||||
total: 3,
|
||||
limit: 100,
|
||||
offset: 0,
|
||||
});
|
||||
apiMock.deleteOAuthRouteUnit.mockResolvedValue({ success: true });
|
||||
|
||||
let root!: WebTestRenderer;
|
||||
try {
|
||||
await act(async () => {
|
||||
root = create(
|
||||
<ToastProvider>
|
||||
<MemoryRouter>
|
||||
<OAuthManagement />
|
||||
</MemoryRouter>
|
||||
</ToastProvider>,
|
||||
);
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
const checkboxes = root.root.findAll((node) => (
|
||||
node.type === 'input'
|
||||
&& node.props.type === 'checkbox'
|
||||
));
|
||||
expect(checkboxes).toHaveLength(4);
|
||||
|
||||
await act(async () => {
|
||||
checkboxes[1]?.props.onChange({ target: { checked: true } });
|
||||
checkboxes[2]?.props.onChange({ target: { checked: true } });
|
||||
});
|
||||
|
||||
expect(collectText(root.root)).not.toContain('拆回单体');
|
||||
|
||||
await act(async () => {
|
||||
checkboxes[3]?.props.onChange({ target: { checked: true } });
|
||||
});
|
||||
|
||||
await clickButton(root, '拆回单体');
|
||||
|
||||
expect(apiMock.deleteOAuthRouteUnit).toHaveBeenCalledWith(97);
|
||||
} finally {
|
||||
root?.unmount();
|
||||
}
|
||||
});
|
||||
|
||||
it('renders supported quota windows without duplicating raw counters beside the percent', async () => {
|
||||
apiMock.getOAuthProviders.mockResolvedValue({
|
||||
providers: [
|
||||
@@ -579,8 +1060,11 @@ describe('OAuthManagement page', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('supports selecting multiple json files and imports them sequentially', async () => {
|
||||
it('supports selecting multiple json files, defaults import to system proxy, and sends one batch request', async () => {
|
||||
apiMock.getOAuthProviders.mockResolvedValue({
|
||||
defaults: {
|
||||
systemProxyConfigured: true,
|
||||
},
|
||||
providers: [
|
||||
{
|
||||
provider: 'codex',
|
||||
@@ -613,25 +1097,11 @@ describe('OAuthManagement page', () => {
|
||||
total: 1,
|
||||
limit: 100,
|
||||
offset: 0,
|
||||
});
|
||||
});
|
||||
apiMock.importOAuthConnections
|
||||
.mockResolvedValueOnce({
|
||||
success: true,
|
||||
imported: 1,
|
||||
skipped: 0,
|
||||
failed: 0,
|
||||
items: [
|
||||
{
|
||||
name: 'Workspace A',
|
||||
status: 'imported',
|
||||
accountId: 7,
|
||||
provider: 'codex',
|
||||
},
|
||||
],
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
success: true,
|
||||
imported: 1,
|
||||
imported: 2,
|
||||
skipped: 1,
|
||||
failed: 0,
|
||||
items: [
|
||||
@@ -641,6 +1111,12 @@ describe('OAuthManagement page', () => {
|
||||
accountId: 9,
|
||||
provider: 'codex',
|
||||
},
|
||||
{
|
||||
name: 'Workspace A',
|
||||
status: 'imported',
|
||||
accountId: 7,
|
||||
provider: 'codex',
|
||||
},
|
||||
{
|
||||
name: 'Skipped API Key',
|
||||
status: 'skipped',
|
||||
@@ -703,19 +1179,25 @@ describe('OAuthManagement page', () => {
|
||||
expect(importText).toContain('workspace-b.json');
|
||||
expect(importText).toContain('已识别 2 份 JSON');
|
||||
expect(importText).toContain('结构有效');
|
||||
expect(findOauthImportSettingInput(root, 'use-system-proxy').props.checked).toBe(true);
|
||||
|
||||
await clickButton(root, '添加');
|
||||
|
||||
expect(apiMock.importOAuthConnections).toHaveBeenCalledTimes(2);
|
||||
expect(apiMock.importOAuthConnections).toHaveBeenNthCalledWith(1, {
|
||||
type: 'codex',
|
||||
access_token: 'oauth-access-token-a',
|
||||
email: 'workspace-a@example.com',
|
||||
});
|
||||
expect(apiMock.importOAuthConnections).toHaveBeenNthCalledWith(2, {
|
||||
type: 'codex',
|
||||
access_token: 'oauth-access-token-b',
|
||||
email: 'workspace-b@example.com',
|
||||
expect(apiMock.importOAuthConnections).toHaveBeenCalledTimes(1);
|
||||
expect(apiMock.importOAuthConnections).toHaveBeenCalledWith({
|
||||
items: [
|
||||
{
|
||||
type: 'codex',
|
||||
access_token: 'oauth-access-token-a',
|
||||
email: 'workspace-a@example.com',
|
||||
},
|
||||
{
|
||||
type: 'codex',
|
||||
access_token: 'oauth-access-token-b',
|
||||
email: 'workspace-b@example.com',
|
||||
},
|
||||
],
|
||||
useSystemProxy: true,
|
||||
});
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(300);
|
||||
@@ -1477,7 +1959,76 @@ describe('OAuthManagement page', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('can clear the stored account proxy by turning off both proxy options', async () => {
|
||||
it('can clear the stored account proxy without forcing reauthorization', async () => {
|
||||
apiMock.getOAuthProviders.mockResolvedValue({
|
||||
providers: [
|
||||
{
|
||||
provider: 'gemini-cli',
|
||||
label: 'Gemini CLI',
|
||||
platform: 'gemini-cli',
|
||||
enabled: true,
|
||||
loginType: 'oauth',
|
||||
requiresProjectId: true,
|
||||
supportsDirectAccountRouting: true,
|
||||
supportsCloudValidation: true,
|
||||
supportsNativeProxy: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
apiMock.getOAuthConnections.mockResolvedValue({
|
||||
items: [
|
||||
{
|
||||
accountId: 11,
|
||||
provider: 'gemini-cli',
|
||||
email: 'gemini@example.com',
|
||||
projectId: 'project-demo',
|
||||
modelCount: 5,
|
||||
modelsPreview: ['gemini-2.5-pro'],
|
||||
status: 'healthy',
|
||||
proxyUrl: 'http://127.0.0.1:7890',
|
||||
},
|
||||
],
|
||||
total: 1,
|
||||
limit: 100,
|
||||
offset: 0,
|
||||
});
|
||||
apiMock.updateOAuthConnectionProxy.mockResolvedValue({ success: true });
|
||||
|
||||
let root!: WebTestRenderer;
|
||||
try {
|
||||
await act(async () => {
|
||||
root = create(
|
||||
<ToastProvider>
|
||||
<MemoryRouter>
|
||||
<OAuthManagement />
|
||||
</MemoryRouter>
|
||||
</ToastProvider>,
|
||||
);
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
await clickButton(root!, '代理设置');
|
||||
const customProxyToggle = findOauthSettingInput(root!, 'use-custom-proxy');
|
||||
|
||||
await act(async () => {
|
||||
customProxyToggle.props.onChange({ target: { checked: false } });
|
||||
});
|
||||
|
||||
await clickButton(root!, '保存代理');
|
||||
await flushMicrotasks();
|
||||
|
||||
expect(apiMock.updateOAuthConnectionProxy).toHaveBeenCalledWith(11, {
|
||||
proxyUrl: null,
|
||||
useSystemProxy: false,
|
||||
});
|
||||
expect(apiMock.rebindOAuthConnection).not.toHaveBeenCalled();
|
||||
expect(openMock).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
root?.unmount();
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps a secondary save-and-reauthorize action in the proxy drawer', async () => {
|
||||
apiMock.getOAuthProviders.mockResolvedValue({
|
||||
providers: [
|
||||
{
|
||||
@@ -1512,8 +2063,8 @@ describe('OAuthManagement page', () => {
|
||||
});
|
||||
apiMock.rebindOAuthConnection.mockResolvedValue({
|
||||
provider: 'gemini-cli',
|
||||
state: 'gemini-rebind-clear',
|
||||
authorizationUrl: 'https://accounts.google.com/o/oauth2/v2/auth?state=gemini-rebind-clear',
|
||||
state: 'gemini-rebind-save-and-reauthorize',
|
||||
authorizationUrl: 'https://accounts.google.com/o/oauth2/v2/auth?state=gemini-rebind-save-and-reauthorize',
|
||||
instructions: {
|
||||
redirectUri: 'http://localhost:8085/oauth2callback',
|
||||
callbackPort: 8085,
|
||||
@@ -1523,7 +2074,7 @@ describe('OAuthManagement page', () => {
|
||||
});
|
||||
apiMock.getOAuthSession.mockResolvedValue({
|
||||
provider: 'gemini-cli',
|
||||
state: 'gemini-rebind-clear',
|
||||
state: 'gemini-rebind-save-and-reauthorize',
|
||||
status: 'pending',
|
||||
});
|
||||
|
||||
@@ -1541,19 +2092,21 @@ describe('OAuthManagement page', () => {
|
||||
await flushMicrotasks();
|
||||
|
||||
await clickButton(root!, '代理设置');
|
||||
const customProxyToggle = findOauthSettingInput(root!, 'use-custom-proxy');
|
||||
expect(collectText(root!.root)).toContain('保存代理');
|
||||
expect(collectText(root!.root)).toContain('保存并重新授权');
|
||||
|
||||
await act(async () => {
|
||||
customProxyToggle.props.onChange({ target: { checked: false } });
|
||||
});
|
||||
|
||||
await clickButton(root!, '保存代理并重新授权');
|
||||
await clickButton(root!, '保存并重新授权');
|
||||
await flushMicrotasks();
|
||||
|
||||
expect(apiMock.rebindOAuthConnection).toHaveBeenCalledWith(11, {
|
||||
proxyUrl: null,
|
||||
proxyUrl: 'http://127.0.0.1:7890',
|
||||
useSystemProxy: false,
|
||||
});
|
||||
expect(openMock).toHaveBeenCalledWith(
|
||||
'https://accounts.google.com/o/oauth2/v2/auth?state=gemini-rebind-save-and-reauthorize',
|
||||
'oauth-gemini-cli',
|
||||
expect.stringContaining('width=540'),
|
||||
);
|
||||
} finally {
|
||||
root?.unmount();
|
||||
}
|
||||
|
||||
@@ -23,6 +23,8 @@ import {
|
||||
api,
|
||||
type OAuthConnectionInfo,
|
||||
type OAuthProviderInfo,
|
||||
type OAuthRouteParticipation,
|
||||
type OAuthRouteUnitStrategy,
|
||||
type OAuthQuotaInfo,
|
||||
type OAuthQuotaWindowInfo,
|
||||
type OAuthStartInstructions,
|
||||
@@ -94,6 +96,12 @@ type OAuthModelsModalState = {
|
||||
siteName: string;
|
||||
};
|
||||
|
||||
type OAuthRouteUnitModalState = {
|
||||
open: boolean;
|
||||
name: string;
|
||||
strategy: OAuthRouteUnitStrategy;
|
||||
};
|
||||
|
||||
const COLUMN_OPTIONS: Array<{ key: ColumnKey; label: string }> = [
|
||||
{ key: 'identity', label: '账号 / Provider' },
|
||||
{ key: 'site', label: '站点' },
|
||||
@@ -450,6 +458,47 @@ function hasOauthProxySelection(connection: OAuthConnectionInfo): boolean {
|
||||
return !!connection.useSystemProxy || !!asTrimmedString(connection.proxyUrl);
|
||||
}
|
||||
|
||||
function resolveRouteUnitStrategyLabel(strategy?: OAuthRouteUnitStrategy | null): string {
|
||||
return strategy === 'stick_until_unavailable' ? '单个用到不可用再切' : '轮询';
|
||||
}
|
||||
|
||||
function resolveConnectionRouteParticipation(
|
||||
connection: OAuthConnectionInfo,
|
||||
): OAuthRouteParticipation {
|
||||
if (connection.routeParticipation?.kind === 'route_unit') {
|
||||
return {
|
||||
...connection.routeParticipation,
|
||||
id: connection.routeParticipation.id ?? connection.routeUnit?.id,
|
||||
routeUnitId: connection.routeParticipation.routeUnitId
|
||||
?? connection.routeParticipation.id
|
||||
?? connection.routeUnit?.routeUnitId
|
||||
?? connection.routeUnit?.id,
|
||||
};
|
||||
}
|
||||
if (connection.routeParticipation?.kind === 'single') {
|
||||
return connection.routeParticipation;
|
||||
}
|
||||
if (connection.routeUnit) {
|
||||
return {
|
||||
kind: 'route_unit',
|
||||
routeUnitId: connection.routeUnit.routeUnitId ?? connection.routeUnit.id,
|
||||
id: connection.routeUnit.id,
|
||||
name: connection.routeUnit.name,
|
||||
strategy: connection.routeUnit.strategy,
|
||||
memberCount: connection.routeUnit.memberCount,
|
||||
};
|
||||
}
|
||||
return { kind: 'single' };
|
||||
}
|
||||
|
||||
function resolveRouteParticipationSummary(connection: OAuthConnectionInfo): string {
|
||||
const participation = resolveConnectionRouteParticipation(connection);
|
||||
if (participation.kind !== 'route_unit') {
|
||||
return '单体';
|
||||
}
|
||||
return `路由池:${participation.name} · ${participation.memberCount} 个成员 · ${resolveRouteUnitStrategyLabel(participation.strategy)}`;
|
||||
}
|
||||
|
||||
function renderCodeBlock(value: string) {
|
||||
return (
|
||||
<code className="oauth-code-block">{value}</code>
|
||||
@@ -588,12 +637,16 @@ export default function OAuthManagement() {
|
||||
const [showColumnMenu, setShowColumnMenu] = useState(false);
|
||||
const [autoRefreshSeconds, setAutoRefreshSeconds] = useState<number>(0);
|
||||
const [autoRefreshCountdown, setAutoRefreshCountdown] = useState<number>(0);
|
||||
const [runtimeSystemProxyConfigured, setRuntimeSystemProxyConfigured] = useState(false);
|
||||
const [importOpen, setImportOpen] = useState(false);
|
||||
const importFileInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const [importJsonText, setImportJsonText] = useState('');
|
||||
const [importDrafts, setImportDrafts] = useState<OAuthImportDraft[]>([]);
|
||||
const [importDragOver, setImportDragOver] = useState(false);
|
||||
const [importing, setImporting] = useState(false);
|
||||
const [importCustomProxyEnabled, setImportCustomProxyEnabled] = useState(false);
|
||||
const [importSystemProxyEnabled, setImportSystemProxyEnabled] = useState(false);
|
||||
const [importProxyUrl, setImportProxyUrl] = useState('');
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
const [drawerIntent, setDrawerIntent] = useState<DrawerIntent>({ mode: 'create' });
|
||||
const [selectedProviderKey, setSelectedProviderKey] = useState('');
|
||||
@@ -615,6 +668,11 @@ export default function OAuthManagement() {
|
||||
disabledCount: 0,
|
||||
siteName: '',
|
||||
});
|
||||
const [routeUnitModal, setRouteUnitModal] = useState<OAuthRouteUnitModalState>({
|
||||
open: false,
|
||||
name: '',
|
||||
strategy: 'round_robin',
|
||||
});
|
||||
|
||||
const resetOauthProxySettings = useCallback(() => {
|
||||
setOauthCustomProxyEnabled(false);
|
||||
@@ -622,6 +680,12 @@ export default function OAuthManagement() {
|
||||
setOauthProxyUrl('');
|
||||
}, []);
|
||||
|
||||
const resetImportProxySettings = useCallback((defaultToSystem = false) => {
|
||||
setImportCustomProxyEnabled(false);
|
||||
setImportSystemProxyEnabled(defaultToSystem);
|
||||
setImportProxyUrl('');
|
||||
}, []);
|
||||
|
||||
const resetImportState = useCallback(() => {
|
||||
setImportJsonText('');
|
||||
setImportDrafts([]);
|
||||
@@ -631,12 +695,14 @@ export default function OAuthManagement() {
|
||||
const closeImportModal = useCallback(() => {
|
||||
setImportOpen(false);
|
||||
resetImportState();
|
||||
}, [resetImportState]);
|
||||
resetImportProxySettings(false);
|
||||
}, [resetImportProxySettings, resetImportState]);
|
||||
|
||||
const openImportModal = useCallback(() => {
|
||||
resetImportState();
|
||||
resetImportProxySettings(runtimeSystemProxyConfigured);
|
||||
setImportOpen(true);
|
||||
}, [resetImportState]);
|
||||
}, [resetImportProxySettings, resetImportState, runtimeSystemProxyConfigured]);
|
||||
|
||||
const loadConnections = useCallback(async () => {
|
||||
const response = await api.getOAuthConnections({
|
||||
@@ -656,6 +722,7 @@ export default function OAuthManagement() {
|
||||
loadConnections(),
|
||||
]);
|
||||
const nextProviders = Array.isArray(providersResponse?.providers) ? providersResponse.providers : [];
|
||||
setRuntimeSystemProxyConfigured(providersResponse?.defaults?.systemProxyConfigured === true);
|
||||
setProviders(nextProviders);
|
||||
setSelectedProviderKey((current) => current || nextProviders[0]?.provider || '');
|
||||
} catch (error: any) {
|
||||
@@ -815,6 +882,53 @@ export default function OAuthManagement() {
|
||||
const allVisibleSelected = filteredConnections.length > 0
|
||||
&& filteredConnections.every((connection) => selectedConnectionIds.includes(connection.accountId));
|
||||
|
||||
const selectedConnections = useMemo(
|
||||
() => connections.filter((connection) => selectedConnectionIds.includes(connection.accountId)),
|
||||
[connections, selectedConnectionIds],
|
||||
);
|
||||
|
||||
const selectedRouteUnitParticipation = useMemo(() => {
|
||||
if (selectedConnections.length <= 0) return null;
|
||||
const participations = selectedConnections.map(resolveConnectionRouteParticipation);
|
||||
if (participations.some((item) => item.kind !== 'route_unit')) return null;
|
||||
const first = participations[0];
|
||||
if (!first || first.kind !== 'route_unit') return null;
|
||||
const routeUnitId = first.routeUnitId ?? first.id;
|
||||
if (!routeUnitId) return null;
|
||||
const allSameRouteUnit = participations.every((item) => (
|
||||
item.kind === 'route_unit'
|
||||
&& (item.routeUnitId ?? item.id) === routeUnitId
|
||||
));
|
||||
if (!allSameRouteUnit) return null;
|
||||
|
||||
const totalRouteUnitMembers = connections.filter((connection) => {
|
||||
const participation = resolveConnectionRouteParticipation(connection);
|
||||
return participation.kind === 'route_unit'
|
||||
&& (participation.routeUnitId ?? participation.id) === routeUnitId;
|
||||
}).length;
|
||||
if (totalRouteUnitMembers !== selectedConnections.length) return null;
|
||||
|
||||
return {
|
||||
...first,
|
||||
memberCount: Math.max(first.memberCount, totalRouteUnitMembers),
|
||||
};
|
||||
}, [connections, selectedConnections]);
|
||||
|
||||
const canMergeSelectedIntoRouteUnit = useMemo(() => {
|
||||
if (selectedConnections.length < 2) return false;
|
||||
const first = selectedConnections[0];
|
||||
if (!first) return false;
|
||||
const firstSiteId = first.siteId;
|
||||
const firstProvider = first.provider;
|
||||
return selectedConnections.every((connection) => (
|
||||
connection.siteId === firstSiteId
|
||||
&& connection.provider === firstProvider
|
||||
&& resolveConnectionRouteParticipation(connection).kind === 'single'
|
||||
));
|
||||
}, [selectedConnections]);
|
||||
|
||||
const canSplitSelectedRouteUnit = selectedRouteUnitParticipation != null;
|
||||
|
||||
const openCreateDrawer = (provider?: string) => {
|
||||
setDrawerIntent({ mode: 'create', provider });
|
||||
setSelectedProviderKey(provider || providers[0]?.provider || '');
|
||||
@@ -842,7 +956,100 @@ export default function OAuthManagement() {
|
||||
setOauthProxyUrl(connection.useSystemProxy ? '' : asTrimmedString(connection.proxyUrl));
|
||||
setDrawerOpen(true);
|
||||
setShowColumnMenu(false);
|
||||
setSessionMessage('已打开 OAuth 代理设置,修改后点击“保存代理并重新授权”。');
|
||||
setSessionMessage('已打开 OAuth 代理设置,修改后可直接保存代理,或保存后重新授权。');
|
||||
};
|
||||
|
||||
const openRouteUnitModal = () => {
|
||||
setRouteUnitModal({
|
||||
open: true,
|
||||
name: '',
|
||||
strategy: 'round_robin',
|
||||
});
|
||||
};
|
||||
|
||||
const closeRouteUnitModal = () => {
|
||||
setRouteUnitModal((current) => ({
|
||||
...current,
|
||||
open: false,
|
||||
}));
|
||||
};
|
||||
|
||||
const resolveProxySettingsPayload = ({
|
||||
customEnabled,
|
||||
systemEnabled,
|
||||
proxyValue,
|
||||
fallbackAccount,
|
||||
clearToSiteFallback = false,
|
||||
}: {
|
||||
customEnabled: boolean;
|
||||
systemEnabled: boolean;
|
||||
proxyValue: string;
|
||||
fallbackAccount?: OAuthConnectionInfo | null;
|
||||
clearToSiteFallback?: boolean;
|
||||
}): { proxyUrl?: string | null; useSystemProxy?: boolean } => {
|
||||
const customProxyUrl = asTrimmedString(proxyValue);
|
||||
if (customEnabled) {
|
||||
return {
|
||||
proxyUrl: customProxyUrl,
|
||||
useSystemProxy: false,
|
||||
};
|
||||
}
|
||||
if (systemEnabled) {
|
||||
return {
|
||||
proxyUrl: null,
|
||||
useSystemProxy: true,
|
||||
};
|
||||
}
|
||||
if (clearToSiteFallback) {
|
||||
return {
|
||||
proxyUrl: null,
|
||||
useSystemProxy: false,
|
||||
};
|
||||
}
|
||||
if (fallbackAccount?.useSystemProxy) {
|
||||
return {
|
||||
proxyUrl: null,
|
||||
useSystemProxy: true,
|
||||
};
|
||||
}
|
||||
if (fallbackAccount?.proxyUrl !== undefined) {
|
||||
return {
|
||||
proxyUrl: asTrimmedString(fallbackAccount.proxyUrl) || null,
|
||||
useSystemProxy: false,
|
||||
};
|
||||
}
|
||||
return {};
|
||||
};
|
||||
|
||||
const handleSaveProxy = async () => {
|
||||
if (drawerIntent.mode !== 'proxy') return;
|
||||
const customProxyUrl = asTrimmedString(oauthProxyUrl);
|
||||
if (oauthCustomProxyEnabled && !customProxyUrl) {
|
||||
setSessionMessage('已开启代理,请先输入完整代理地址');
|
||||
return;
|
||||
}
|
||||
|
||||
const actionKey = `save-proxy:${drawerIntent.account.accountId}`;
|
||||
setActionLoadingKey(actionKey);
|
||||
try {
|
||||
await api.updateOAuthConnectionProxy(
|
||||
drawerIntent.account.accountId,
|
||||
resolveProxySettingsPayload({
|
||||
customEnabled: oauthCustomProxyEnabled,
|
||||
systemEnabled: oauthSystemProxyEnabled,
|
||||
proxyValue: oauthProxyUrl,
|
||||
clearToSiteFallback: true,
|
||||
}),
|
||||
);
|
||||
await loadConnections();
|
||||
setDrawerOpen(false);
|
||||
resetOauthProxySettings();
|
||||
setSessionMessage('代理已保存');
|
||||
} catch (error: any) {
|
||||
setSessionMessage(error?.message || '保存代理失败');
|
||||
} finally {
|
||||
setActionLoadingKey('');
|
||||
}
|
||||
};
|
||||
|
||||
const handleStart = async () => {
|
||||
@@ -869,24 +1076,13 @@ export default function OAuthManagement() {
|
||||
return;
|
||||
}
|
||||
|
||||
let resolvedProxyUrl: string | null | undefined;
|
||||
let resolvedUseSystemProxy: boolean | undefined;
|
||||
if (oauthCustomProxyEnabled) {
|
||||
resolvedProxyUrl = customProxyUrl;
|
||||
resolvedUseSystemProxy = false;
|
||||
} else if (oauthSystemProxyEnabled) {
|
||||
resolvedProxyUrl = null;
|
||||
resolvedUseSystemProxy = true;
|
||||
} else if (drawerIntent.mode === 'proxy') {
|
||||
resolvedProxyUrl = null;
|
||||
resolvedUseSystemProxy = false;
|
||||
} else if (rebindAccount?.useSystemProxy) {
|
||||
resolvedProxyUrl = null;
|
||||
resolvedUseSystemProxy = true;
|
||||
} else if (rebindAccount?.proxyUrl !== undefined) {
|
||||
resolvedProxyUrl = asTrimmedString(rebindAccount.proxyUrl) || null;
|
||||
resolvedUseSystemProxy = false;
|
||||
}
|
||||
const proxySettings = resolveProxySettingsPayload({
|
||||
customEnabled: oauthCustomProxyEnabled,
|
||||
systemEnabled: oauthSystemProxyEnabled,
|
||||
proxyValue: oauthProxyUrl,
|
||||
fallbackAccount: rebindAccount,
|
||||
clearToSiteFallback: drawerIntent.mode === 'proxy',
|
||||
});
|
||||
|
||||
const actionKey = `start:${provider.provider}:${accountId || 0}`;
|
||||
setActionLoadingKey(actionKey);
|
||||
@@ -898,14 +1094,12 @@ export default function OAuthManagement() {
|
||||
? await api.rebindOAuthConnection(
|
||||
accountId,
|
||||
{
|
||||
...(resolvedProxyUrl !== undefined ? { proxyUrl: resolvedProxyUrl } : {}),
|
||||
...(resolvedUseSystemProxy !== undefined ? { useSystemProxy: resolvedUseSystemProxy } : {}),
|
||||
...proxySettings,
|
||||
},
|
||||
)
|
||||
: await api.startOAuthProvider(provider.provider, {
|
||||
projectId,
|
||||
...(resolvedProxyUrl !== undefined ? { proxyUrl: resolvedProxyUrl } : {}),
|
||||
...(resolvedUseSystemProxy !== undefined ? { useSystemProxy: resolvedUseSystemProxy } : {}),
|
||||
...proxySettings,
|
||||
});
|
||||
|
||||
setSessionMessage('等待授权完成');
|
||||
@@ -1155,21 +1349,42 @@ export default function OAuthManagement() {
|
||||
setSessionMessage('请先修正无效的 OAuth JSON');
|
||||
return;
|
||||
}
|
||||
if (importCustomProxyEnabled && !asTrimmedString(importProxyUrl)) {
|
||||
setSessionMessage('已开启代理,请先输入完整代理地址');
|
||||
return;
|
||||
}
|
||||
|
||||
setImporting(true);
|
||||
try {
|
||||
let importedCount = 0;
|
||||
|
||||
for (const item of importPreviewSummary.items) {
|
||||
if (!item.valid || !item.parsedData) continue;
|
||||
const result = await api.importOAuthConnections(item.parsedData);
|
||||
importedCount += result.imported;
|
||||
}
|
||||
const parsedItems = importPreviewSummary.items
|
||||
.filter((item) => item.valid && item.parsedData)
|
||||
.map((item) => item.parsedData as Record<string, unknown>);
|
||||
const importProxySettings = importSystemProxyEnabled && !importCustomProxyEnabled
|
||||
? { useSystemProxy: true as const }
|
||||
: resolveProxySettingsPayload({
|
||||
customEnabled: importCustomProxyEnabled,
|
||||
systemEnabled: importSystemProxyEnabled,
|
||||
proxyValue: importProxyUrl,
|
||||
});
|
||||
const result = parsedItems.length === 1
|
||||
&& !('proxyUrl' in importProxySettings)
|
||||
&& !('useSystemProxy' in importProxySettings)
|
||||
? await api.importOAuthConnections(parsedItems[0]!)
|
||||
: await api.importOAuthConnections({
|
||||
items: parsedItems,
|
||||
...importProxySettings,
|
||||
});
|
||||
|
||||
await loadConnections();
|
||||
const successMessage = `已添加 ${importedCount} 个 OAuth 连接`;
|
||||
toast.success(successMessage);
|
||||
setSessionMessage(successMessage);
|
||||
const importMessage = result.failed > 0
|
||||
? `批量导入完成,成功 ${result.imported} 个,失败 ${result.failed} 个`
|
||||
: `已添加 ${result.imported} 个 OAuth 连接`;
|
||||
if (result.failed > 0) {
|
||||
toast.info(importMessage);
|
||||
} else {
|
||||
toast.success(importMessage);
|
||||
}
|
||||
setSessionMessage(importMessage);
|
||||
closeImportModal();
|
||||
} catch (error: any) {
|
||||
const message = error?.message || '导入 OAuth JSON 失败';
|
||||
@@ -1180,6 +1395,50 @@ export default function OAuthManagement() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateRouteUnit = async () => {
|
||||
const name = asTrimmedString(routeUnitModal.name);
|
||||
if (!canMergeSelectedIntoRouteUnit || selectedConnections.length < 2) return;
|
||||
if (!name) {
|
||||
setSessionMessage('请先输入路由池名称');
|
||||
return;
|
||||
}
|
||||
|
||||
setActionLoadingKey('route-unit:create');
|
||||
try {
|
||||
await api.createOAuthRouteUnit({
|
||||
accountIds: selectedConnections.map((connection) => connection.accountId),
|
||||
name,
|
||||
strategy: routeUnitModal.strategy,
|
||||
});
|
||||
await loadConnections();
|
||||
setSelectedConnectionIds([]);
|
||||
closeRouteUnitModal();
|
||||
setSessionMessage('已合并为路由池');
|
||||
} catch (error: any) {
|
||||
setSessionMessage(error?.message || '创建路由池失败');
|
||||
} finally {
|
||||
setActionLoadingKey('');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteSelectedRouteUnit = async () => {
|
||||
if (!selectedRouteUnitParticipation) return;
|
||||
const routeUnitId = selectedRouteUnitParticipation.routeUnitId ?? selectedRouteUnitParticipation.id;
|
||||
if (!routeUnitId) return;
|
||||
|
||||
setActionLoadingKey('route-unit:delete');
|
||||
try {
|
||||
await api.deleteOAuthRouteUnit(routeUnitId);
|
||||
await loadConnections();
|
||||
setSelectedConnectionIds([]);
|
||||
setSessionMessage('已拆回单体');
|
||||
} catch (error: any) {
|
||||
setSessionMessage(error?.message || '拆回单体失败');
|
||||
} finally {
|
||||
setActionLoadingKey('');
|
||||
}
|
||||
};
|
||||
|
||||
const toggleColumn = (key: ColumnKey) => {
|
||||
setVisibleColumns((current) => ({
|
||||
...current,
|
||||
@@ -1489,6 +1748,7 @@ export default function OAuthManagement() {
|
||||
<td className="oauth-col-proxy">
|
||||
<div className="oauth-cell-stack">
|
||||
<div className="oauth-cell-secondary">{resolveProxyProjectSummary(connection)}</div>
|
||||
<div className="oauth-cell-secondary">{resolveRouteParticipationSummary(connection)}</div>
|
||||
<div className="oauth-cell-tertiary">{resolveProxyDisplayText(connection)}</div>
|
||||
<button
|
||||
type="button"
|
||||
@@ -1566,6 +1826,7 @@ export default function OAuthManagement() {
|
||||
<MobileField label="站点" value={connection.site?.name || '--'} />
|
||||
<MobileField label="邮箱" value={resolveConnectionEmailLabel(connection) || '--'} />
|
||||
<MobileField label="计划 / 项目" value={connection.projectId ? `${connection.planType || '--'} · ${connection.projectId}` : (connection.planType || '--')} />
|
||||
<MobileField label="路由参与" value={resolveRouteParticipationSummary(connection)} />
|
||||
<MobileField
|
||||
label="账号代理"
|
||||
value={(
|
||||
@@ -1719,6 +1980,25 @@ export default function OAuthManagement() {
|
||||
>
|
||||
{actionLoadingKey === 'quota:selected' ? '刷新中...' : '批量刷新额度'}
|
||||
</button>
|
||||
{canMergeSelectedIntoRouteUnit ? (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-ghost oauth-outline-button"
|
||||
onClick={openRouteUnitModal}
|
||||
>
|
||||
合并参与路由
|
||||
</button>
|
||||
) : null}
|
||||
{canSplitSelectedRouteUnit ? (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-ghost oauth-outline-button"
|
||||
onClick={handleDeleteSelectedRouteUnit}
|
||||
disabled={actionLoadingKey === 'route-unit:delete'}
|
||||
>
|
||||
{actionLoadingKey === 'route-unit:delete' ? '拆分中...' : '拆回单体'}
|
||||
</button>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-link btn-link-danger"
|
||||
@@ -1806,7 +2086,7 @@ export default function OAuthManagement() {
|
||||
|
||||
<div className="oauth-form-note">
|
||||
{drawerIntent.mode === 'proxy'
|
||||
? '这里修改的是账号级 OAuth 代理。保存后会立刻按当前设置重新走一次授权,后续生成的账号也会沿用这份代理配置。若两项都不勾选,则回退到站点代理配置。'
|
||||
? '这里修改的是账号级 OAuth 代理。点击“保存代理”会立即落库并刷新列表;只有“保存并重新授权”才会重新走授权流程。若两项都不勾选,则回退到站点代理配置。'
|
||||
: '这里的设置会作用于下一次“连接”或“重新授权”。填写代理地址后,本次 OAuth 换 token 和后续生成的账号都会直接带上这份账号级代理配置;若不勾选,则回退到站点代理配置。'}
|
||||
</div>
|
||||
|
||||
@@ -1855,20 +2135,47 @@ export default function OAuthManagement() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary"
|
||||
onClick={handleStart}
|
||||
disabled={!selectedProvider || !selectedProvider.enabled || actionLoadingKey.startsWith('start:')}
|
||||
>
|
||||
{actionLoadingKey.startsWith('start:')
|
||||
? '启动中...'
|
||||
: drawerIntent.mode === 'proxy'
|
||||
? '保存代理并重新授权'
|
||||
{drawerIntent.mode === 'proxy' ? (
|
||||
<div className="oauth-inline-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary"
|
||||
onClick={handleSaveProxy}
|
||||
disabled={
|
||||
actionLoadingKey === `save-proxy:${drawerIntent.account.accountId}`
|
||||
|| actionLoadingKey.startsWith('start:')
|
||||
}
|
||||
>
|
||||
{actionLoadingKey === `save-proxy:${drawerIntent.account.accountId}` ? '保存中...' : '保存代理'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-ghost"
|
||||
onClick={handleStart}
|
||||
disabled={
|
||||
!selectedProvider
|
||||
|| !selectedProvider.enabled
|
||||
|| actionLoadingKey.startsWith('start:')
|
||||
|| actionLoadingKey === `save-proxy:${drawerIntent.account.accountId}`
|
||||
}
|
||||
>
|
||||
{actionLoadingKey.startsWith('start:') ? '启动中...' : '保存并重新授权'}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary"
|
||||
onClick={handleStart}
|
||||
disabled={!selectedProvider || !selectedProvider.enabled || actionLoadingKey.startsWith('start:')}
|
||||
>
|
||||
{actionLoadingKey.startsWith('start:')
|
||||
? '启动中...'
|
||||
: drawerIntent.mode === 'rebind'
|
||||
? `重新授权 ${selectedProvider?.label || ''}`.trim()
|
||||
: `连接 ${selectedProvider?.label || ''}`.trim()}
|
||||
</button>
|
||||
? `重新授权 ${selectedProvider?.label || ''}`.trim()
|
||||
: `连接 ${selectedProvider?.label || ''}`.trim()}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2025,6 +2332,52 @@ export default function OAuthManagement() {
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="oauth-form-note">
|
||||
导入后的账号代理可在这里一次性指定;如果当前运行时已配置系统代理,会默认预选“使用系统级代理”。
|
||||
</div>
|
||||
<div className="oauth-toggle-group">
|
||||
<label className="oauth-toggle">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={importSystemProxyEnabled}
|
||||
data-oauth-import-setting="use-system-proxy"
|
||||
onChange={(event) => {
|
||||
const checked = !!event.target.checked;
|
||||
setImportSystemProxyEnabled(checked);
|
||||
if (checked) {
|
||||
setImportCustomProxyEnabled(false);
|
||||
setImportProxyUrl('');
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<span>使用系统级代理</span>
|
||||
</label>
|
||||
<label className="oauth-toggle">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={importCustomProxyEnabled}
|
||||
data-oauth-import-setting="use-custom-proxy"
|
||||
onChange={(event) => {
|
||||
const checked = !!event.target.checked;
|
||||
setImportCustomProxyEnabled(checked);
|
||||
if (checked) setImportSystemProxyEnabled(false);
|
||||
}}
|
||||
/>
|
||||
<span>使用自定义代理</span>
|
||||
</label>
|
||||
</div>
|
||||
<div className="oauth-form-field">
|
||||
<div className="oauth-field-label">代理地址</div>
|
||||
<input
|
||||
type="text"
|
||||
className="oauth-input"
|
||||
value={importProxyUrl}
|
||||
data-oauth-import-setting="proxy-url"
|
||||
onChange={(event) => setImportProxyUrl(event.target.value)}
|
||||
placeholder="如 http://127.0.0.1:7890 或 socks5://127.0.0.1:1080"
|
||||
disabled={!importCustomProxyEnabled}
|
||||
/>
|
||||
</div>
|
||||
<div className="oauth-import-copy">或者手动粘贴单个 JSON 内容:</div>
|
||||
<textarea
|
||||
className="oauth-textarea oauth-mono"
|
||||
@@ -2070,6 +2423,56 @@ export default function OAuthManagement() {
|
||||
</div>
|
||||
) : null}
|
||||
</CenteredModal>
|
||||
|
||||
<CenteredModal
|
||||
open={routeUnitModal.open}
|
||||
onClose={closeRouteUnitModal}
|
||||
title="创建路由池"
|
||||
maxWidth={520}
|
||||
bodyStyle={{ display: 'grid', gap: 16 }}
|
||||
footer={(
|
||||
<>
|
||||
<button type="button" className="btn btn-ghost" onClick={closeRouteUnitModal}>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary"
|
||||
onClick={handleCreateRouteUnit}
|
||||
disabled={actionLoadingKey === 'route-unit:create' || !asTrimmedString(routeUnitModal.name)}
|
||||
>
|
||||
{actionLoadingKey === 'route-unit:create' ? '创建中...' : '创建路由池'}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
>
|
||||
<div className="oauth-form-field">
|
||||
<div className="oauth-field-label">路由池名称</div>
|
||||
<input
|
||||
type="text"
|
||||
className="oauth-input"
|
||||
data-testid="oauth-route-unit-name"
|
||||
value={routeUnitModal.name}
|
||||
onChange={(event) => setRouteUnitModal((current) => ({ ...current, name: event.target.value }))}
|
||||
placeholder="例如 Codex Pool"
|
||||
/>
|
||||
</div>
|
||||
<div className="oauth-form-field">
|
||||
<div className="oauth-field-label">策略</div>
|
||||
<ModernSelect
|
||||
value={routeUnitModal.strategy}
|
||||
onChange={(value) => setRouteUnitModal((current) => ({
|
||||
...current,
|
||||
strategy: String(value || 'round_robin') as OAuthRouteUnitStrategy,
|
||||
}))}
|
||||
options={[
|
||||
{ value: 'round_robin', label: '轮询' },
|
||||
{ value: 'stick_until_unavailable', label: '单个用到不可用再切' },
|
||||
]}
|
||||
placeholder="选择路由池策略"
|
||||
/>
|
||||
</div>
|
||||
</CenteredModal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -78,6 +78,73 @@ function buildChannel(overrides: Partial<RouteChannel> = {}): RouteChannel {
|
||||
}
|
||||
|
||||
describe('RouteCard', () => {
|
||||
it('renders oauth route unit summary and member labels on expanded channels', () => {
|
||||
const root = create(
|
||||
<RouteCard
|
||||
route={buildRoute({
|
||||
modelPattern: 'gpt-4.1',
|
||||
displayName: 'gpt-4.1',
|
||||
channelCount: 1,
|
||||
enabledChannelCount: 1,
|
||||
})}
|
||||
brand={null}
|
||||
expanded
|
||||
onToggleExpand={vi.fn()}
|
||||
onEdit={vi.fn()}
|
||||
onDelete={vi.fn()}
|
||||
onToggleEnabled={vi.fn()}
|
||||
onClearCooldown={vi.fn()}
|
||||
clearingCooldown={false}
|
||||
onRoutingStrategyChange={vi.fn()}
|
||||
updatingRoutingStrategy={false}
|
||||
channels={[
|
||||
buildChannel({
|
||||
account: { username: 'route-unit-anchor' },
|
||||
routeUnit: {
|
||||
id: 'pool-1',
|
||||
name: 'Codex Pool A',
|
||||
strategy: 'round_robin',
|
||||
memberCount: 3,
|
||||
members: [
|
||||
{ accountId: 101, username: 'route-unit-anchor', siteName: 'site-a' },
|
||||
{ accountId: 102, username: 'route-unit-backup', siteName: 'site-b' },
|
||||
{ accountId: 103, username: 'route-unit-third', siteName: 'site-c' },
|
||||
],
|
||||
},
|
||||
}),
|
||||
]}
|
||||
loadingChannels={false}
|
||||
routeDecision={null}
|
||||
loadingDecision={false}
|
||||
candidateView={{ routeCandidates: [], accountOptions: [], tokenOptionsByAccountId: {} }}
|
||||
channelTokenDraft={{}}
|
||||
updatingChannel={{}}
|
||||
savingPriority={false}
|
||||
onTokenDraftChange={vi.fn()}
|
||||
onSaveToken={vi.fn()}
|
||||
onDeleteChannel={vi.fn()}
|
||||
onToggleChannelEnabled={vi.fn()}
|
||||
onChannelDragEnd={vi.fn()}
|
||||
missingTokenSiteItems={[]}
|
||||
missingTokenGroupItems={[]}
|
||||
onCreateTokenForMissing={vi.fn()}
|
||||
onAddChannel={vi.fn()}
|
||||
onSiteBlockModel={vi.fn()}
|
||||
expandedSourceGroupMap={{}}
|
||||
onToggleSourceGroup={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
const text = collectText(root.root);
|
||||
expect(text).toContain('Codex Pool A');
|
||||
expect(text).toContain('3 个成员');
|
||||
expect(text).toContain('轮询');
|
||||
expect(text).toContain('成员摘要');
|
||||
expect(text).toContain('route-unit-anchor');
|
||||
expect(text).toContain('route-unit-backup');
|
||||
expect(text).toContain('route-unit-third');
|
||||
});
|
||||
|
||||
it('truncates the collapsed regex badge while keeping the group name primary', () => {
|
||||
const root = create(
|
||||
<RouteCard
|
||||
@@ -371,6 +438,70 @@ describe('RouteCard', () => {
|
||||
expect(p0RailNode.props.style.color).not.toBe(p1RailNode.props.style.color);
|
||||
});
|
||||
|
||||
it('renders oauth route unit summary badges on expanded cards', () => {
|
||||
const root = create(
|
||||
<RouteCard
|
||||
route={buildRoute()}
|
||||
brand={null}
|
||||
expanded
|
||||
onToggleExpand={vi.fn()}
|
||||
onEdit={vi.fn()}
|
||||
onDelete={vi.fn()}
|
||||
onToggleEnabled={vi.fn()}
|
||||
onClearCooldown={vi.fn()}
|
||||
clearingCooldown={false}
|
||||
onRoutingStrategyChange={vi.fn()}
|
||||
updatingRoutingStrategy={false}
|
||||
channels={[
|
||||
buildChannel({
|
||||
id: 11,
|
||||
account: { username: 'pool-representative' },
|
||||
site: { id: 1, name: 'site-a', platform: 'openai' },
|
||||
...( {
|
||||
routeUnit: {
|
||||
id: 7,
|
||||
name: 'Codex 池',
|
||||
memberCount: 3,
|
||||
strategy: 'round_robin',
|
||||
members: [
|
||||
{ accountId: 101, username: 'user_a', siteName: 'site-a' },
|
||||
{ accountId: 102, username: 'user_b', siteName: 'site-b' },
|
||||
{ accountId: 103, username: 'user_c', siteName: 'site-c' },
|
||||
],
|
||||
},
|
||||
} as any ),
|
||||
}),
|
||||
]}
|
||||
loadingChannels={false}
|
||||
routeDecision={null}
|
||||
loadingDecision={false}
|
||||
candidateView={{ routeCandidates: [], accountOptions: [], tokenOptionsByAccountId: {} }}
|
||||
channelTokenDraft={{}}
|
||||
updatingChannel={{}}
|
||||
savingPriority={false}
|
||||
onTokenDraftChange={vi.fn()}
|
||||
onSaveToken={vi.fn()}
|
||||
onDeleteChannel={vi.fn()}
|
||||
onToggleChannelEnabled={vi.fn()}
|
||||
onChannelDragEnd={vi.fn()}
|
||||
missingTokenSiteItems={[]}
|
||||
missingTokenGroupItems={[]}
|
||||
onCreateTokenForMissing={vi.fn()}
|
||||
onAddChannel={vi.fn()}
|
||||
onSiteBlockModel={vi.fn()}
|
||||
expandedSourceGroupMap={{}}
|
||||
onToggleSourceGroup={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
const text = collectText(root.root);
|
||||
expect(text).toContain('OAuth 路由池');
|
||||
expect(text).toContain('Codex 池');
|
||||
expect(text).toContain('3 个成员');
|
||||
expect(text).toContain('轮询');
|
||||
expect(text).toContain('成员摘要');
|
||||
});
|
||||
|
||||
it('uses translate-only rect sorting for flat channel shell rows', () => {
|
||||
const root = create(
|
||||
<RouteCard
|
||||
|
||||
@@ -25,6 +25,7 @@ import { formatDateTimeMinuteLocal } from '../helpers/checkinLogTime.js';
|
||||
import type {
|
||||
RouteSummaryRow,
|
||||
RouteChannel,
|
||||
RouteChannelRouteUnit,
|
||||
RouteDecision,
|
||||
RouteDecisionCandidate,
|
||||
MissingTokenRouteSiteActionItem,
|
||||
@@ -100,6 +101,24 @@ type RouteCardProps = {
|
||||
onToggleSourceGroup: (groupKey: string) => void;
|
||||
};
|
||||
|
||||
function getRouteUnitStrategyLabel(strategy: string | null | undefined): string {
|
||||
return strategy === 'stick_until_unavailable' ? '单个用到不可用再切' : '轮询';
|
||||
}
|
||||
|
||||
function collectRouteUnits(channels: RouteChannel[] | undefined): RouteChannelRouteUnit[] {
|
||||
if (!Array.isArray(channels) || channels.length === 0) return [];
|
||||
const unitsById = new Map<string, RouteChannelRouteUnit>();
|
||||
for (const channel of channels) {
|
||||
const routeUnit = channel.routeUnit;
|
||||
if (!routeUnit) continue;
|
||||
const key = String(routeUnit.id);
|
||||
if (!unitsById.has(key)) {
|
||||
unitsById.set(key, routeUnit);
|
||||
}
|
||||
}
|
||||
return Array.from(unitsById.values());
|
||||
}
|
||||
|
||||
function PriorityRailNewLayerRow({
|
||||
id,
|
||||
highlighted,
|
||||
@@ -566,6 +585,7 @@ function RouteCardInner({
|
||||
: undefined;
|
||||
const showAddChannelButton = !readOnlyRoute && !channelManagementDisabled;
|
||||
const showMissingTokenHints = !channelManagementDisabled && (missingTokenSiteItems.length > 0 || missingTokenGroupItems.length > 0);
|
||||
const routeUnits = collectRouteUnits(channels);
|
||||
const routingStrategyOptions = [
|
||||
{
|
||||
value: 'weighted',
|
||||
@@ -975,6 +995,32 @@ function RouteCardInner({
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{routeUnits.length > 0 ? (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6, marginBottom: 8 }}>
|
||||
<div style={{ fontSize: 11.5, color: 'var(--color-text-secondary)' }}>
|
||||
OAuth 路由池
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6, flexWrap: 'wrap' }}>
|
||||
{routeUnits.map((routeUnit) => (
|
||||
<span
|
||||
key={`route-unit-${String(routeUnit.id)}`}
|
||||
className="badge badge-info"
|
||||
style={{
|
||||
fontSize: 10.5,
|
||||
maxWidth: '100%',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
title={`${routeUnit.name?.trim() || 'OAuth 路由池'} · ${routeUnit.memberCount} 个成员 · ${getRouteUnitStrategyLabel(routeUnit.strategy)}`}
|
||||
>
|
||||
{(routeUnit.name?.trim() || 'OAuth 路由池')} · {routeUnit.memberCount} 个成员 · {getRouteUnitStrategyLabel(routeUnit.strategy)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{!readOnlyRoute && (
|
||||
<div
|
||||
data-testid={compact ? 'compact-route-action-row' : undefined}
|
||||
|
||||
@@ -9,6 +9,16 @@ import {
|
||||
} from './tokenBindingPresentation.js';
|
||||
import { getChannelDecisionState, getPriorityTagStyle, getProbabilityColor } from './utils.js';
|
||||
|
||||
function getRouteUnitStrategyLabel(strategy: string | null | undefined): string {
|
||||
return strategy === 'stick_until_unavailable' ? '单个用到不可用再切' : '轮询';
|
||||
}
|
||||
|
||||
function formatRouteUnitMemberLabel(member: { accountId: number; username: string | null; siteName: string | null }): string {
|
||||
const accountLabel = member.username?.trim() || `account-${member.accountId}`;
|
||||
const siteLabel = member.siteName?.trim();
|
||||
return siteLabel ? `${accountLabel} @ ${siteLabel}` : accountLabel;
|
||||
}
|
||||
|
||||
export function SortableChannelRow({
|
||||
channel,
|
||||
displayPriority,
|
||||
@@ -87,6 +97,13 @@ export function SortableChannelRow({
|
||||
accountName: channel.account?.username || `account-${channel.accountId}`,
|
||||
},
|
||||
);
|
||||
const routeUnit = channel.routeUnit ?? null;
|
||||
const routeUnitName = routeUnit?.name?.trim() || 'OAuth 路由池';
|
||||
const routeUnitStrategyLabel = routeUnit ? getRouteUnitStrategyLabel(routeUnit.strategy) : '';
|
||||
const routeUnitMemberSummary = routeUnit?.members?.length
|
||||
? routeUnit.members.map((member) => formatRouteUnitMemberLabel(member)).join('、')
|
||||
: null;
|
||||
const routeUnitMemberSummaryText = routeUnitMemberSummary ? `成员:${routeUnitMemberSummary}` : null;
|
||||
|
||||
const [mobileDetailsOpen, setMobileDetailsOpen] = useState(false);
|
||||
|
||||
@@ -193,8 +210,36 @@ export function SortableChannelRow({
|
||||
手动配置
|
||||
</span>
|
||||
) : null}
|
||||
|
||||
{routeUnit ? (
|
||||
<>
|
||||
<span className="badge badge-muted" style={{ fontSize: 10 }}>
|
||||
OAuth 路由池
|
||||
</span>
|
||||
<span className="badge badge-info" style={{ fontSize: 10 }}>
|
||||
{routeUnitName}
|
||||
</span>
|
||||
<span className="badge badge-muted" style={{ fontSize: 10 }}>
|
||||
{routeUnit.memberCount} 成员
|
||||
</span>
|
||||
<span className="badge badge-muted" style={{ fontSize: 10 }}>
|
||||
{routeUnitStrategyLabel}
|
||||
</span>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{routeUnitMemberSummaryText ? (
|
||||
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 6, flexWrap: 'wrap' }}>
|
||||
<span style={{ fontSize: 11, color: 'var(--color-text-muted)', whiteSpace: 'nowrap' }}>
|
||||
成员摘要({routeUnit?.memberCount || 0} 个成员 · {routeUnitStrategyLabel})
|
||||
</span>
|
||||
<span style={{ fontSize: 11, color: 'var(--color-text-secondary)', lineHeight: 1.45 }}>
|
||||
{routeUnitMemberSummaryText}
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6, flexWrap: 'wrap' }}>
|
||||
<span style={{ fontSize: 11, color: 'var(--color-text-muted)', whiteSpace: 'nowrap' }}>选中概率</span>
|
||||
<div style={{ display: 'inline-flex', alignItems: 'center', gap: 5, minWidth: 96 }}>
|
||||
@@ -404,6 +449,34 @@ export function SortableChannelRow({
|
||||
<span className="badge badge-muted" style={{ fontSize: 10 }}>已禁用</span>
|
||||
) : null}
|
||||
|
||||
{routeUnit ? (
|
||||
<>
|
||||
<span className="badge badge-muted" style={{ fontSize: 10 }}>
|
||||
OAuth 路由池
|
||||
</span>
|
||||
<span className="badge badge-info" style={{ fontSize: 10 }}>
|
||||
{routeUnitName}
|
||||
</span>
|
||||
<span className="badge badge-muted" style={{ fontSize: 10 }}>
|
||||
{routeUnit.memberCount} 成员
|
||||
</span>
|
||||
<span className="badge badge-muted" style={{ fontSize: 10 }}>
|
||||
{routeUnitStrategyLabel}
|
||||
</span>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{routeUnitMemberSummaryText ? (
|
||||
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 6, width: '100%', flexWrap: 'wrap' }}>
|
||||
<span style={{ fontSize: 11, color: 'var(--color-text-muted)', whiteSpace: 'nowrap' }}>
|
||||
成员摘要({routeUnit?.memberCount || 0} 个成员 · {routeUnitStrategyLabel})
|
||||
</span>
|
||||
<span style={{ fontSize: 11, color: 'var(--color-text-secondary)', lineHeight: 1.45 }}>
|
||||
{routeUnitMemberSummaryText}
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6, width: '100%', marginTop: mobile ? 0 : 1, flexWrap: 'wrap' }}>
|
||||
<span style={{ fontSize: 11, color: 'var(--color-text-muted)', whiteSpace: 'nowrap' }}>选中概率</span>
|
||||
<div style={{ display: 'inline-flex', alignItems: 'center', gap: 5, minWidth: 96 }}>
|
||||
|
||||
@@ -7,6 +7,7 @@ export type RouteSortBy = 'modelPattern' | 'channelCount';
|
||||
export type RouteSortDir = 'asc' | 'desc';
|
||||
export type GroupFilter = null | '__all__' | number;
|
||||
export type RouteRoutingStrategy = 'weighted' | 'round_robin' | 'stable_first';
|
||||
export type OAuthRouteUnitStrategy = 'round_robin' | 'stick_until_unavailable';
|
||||
export type RouteRowKind = 'persisted' | 'zero_channel';
|
||||
export type RouteChannelDraft = {
|
||||
accountId: number;
|
||||
@@ -14,6 +15,20 @@ export type RouteChannelDraft = {
|
||||
sourceModel: string;
|
||||
};
|
||||
|
||||
export type RouteChannelRouteUnitMember = {
|
||||
accountId: number;
|
||||
username: string | null;
|
||||
siteName: string | null;
|
||||
};
|
||||
|
||||
export type RouteChannelRouteUnit = {
|
||||
id: number | string;
|
||||
name: string | null;
|
||||
strategy: OAuthRouteUnitStrategy;
|
||||
memberCount: number;
|
||||
members?: RouteChannelRouteUnitMember[];
|
||||
};
|
||||
|
||||
export type RouteChannel = {
|
||||
id: number;
|
||||
routeId?: number;
|
||||
@@ -45,6 +60,8 @@ export type RouteChannel = {
|
||||
enabled: boolean;
|
||||
isDefault: boolean;
|
||||
} | null;
|
||||
oauthRouteUnitId?: number | null;
|
||||
routeUnit?: RouteChannelRouteUnit | null;
|
||||
};
|
||||
|
||||
export type RouteRow = {
|
||||
|
||||
@@ -226,6 +226,137 @@ describe('TokenRoutes grouped source models', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('renders oauth route unit summary and members after expanding a pooled route', async () => {
|
||||
apiMock.getRoutesSummary.mockResolvedValue([
|
||||
{
|
||||
id: 31, modelPattern: 'gpt-4.1', displayName: null,
|
||||
displayIcon: null, modelMapping: null, enabled: true,
|
||||
channelCount: 1, enabledChannelCount: 1, siteNames: ['codex-oauth'],
|
||||
decisionSnapshot: null, decisionRefreshedAt: null,
|
||||
},
|
||||
]);
|
||||
apiMock.getRouteChannels.mockResolvedValue([
|
||||
{
|
||||
id: 511, routeId: 31, accountId: 901, tokenId: null, sourceModel: 'gpt-4.1',
|
||||
priority: 0, weight: 1, enabled: true, manualOverride: false,
|
||||
successCount: 5, failCount: 1,
|
||||
account: { username: 'route-unit-anchor', credentialMode: 'oauth' },
|
||||
site: { id: 41, name: 'codex-oauth', platform: 'openai' },
|
||||
token: null,
|
||||
routeUnit: {
|
||||
id: 'pool-31',
|
||||
name: 'Codex Pool A',
|
||||
strategy: 'stick_until_unavailable',
|
||||
memberCount: 3,
|
||||
members: [
|
||||
{ accountId: 901, username: 'route-unit-anchor', siteName: 'codex-oauth' },
|
||||
{ accountId: 902, username: 'route-unit-backup', siteName: 'codex-oauth' },
|
||||
{ accountId: 903, username: 'route-unit-third', siteName: 'codex-oauth' },
|
||||
],
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
let root!: WebTestRenderer;
|
||||
try {
|
||||
await act(async () => {
|
||||
root = create(
|
||||
<MemoryRouter initialEntries={['/routes']}>
|
||||
<ToastProvider>
|
||||
<TokenRoutes />
|
||||
</ToastProvider>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
const expandBtn = root.root.find((node) =>
|
||||
node.type === 'div' && String(node.props.className || '').includes('route-card-collapsed'),
|
||||
);
|
||||
await act(async () => {
|
||||
expandBtn.props.onClick();
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
const text = collectText(root.root);
|
||||
expect(text).toContain('Codex Pool A');
|
||||
expect(text).toContain('3 个成员');
|
||||
expect(text).toContain('单个用到不可用再切');
|
||||
expect(text).toContain('成员摘要');
|
||||
expect(text).toContain('route-unit-anchor');
|
||||
expect(text).toContain('route-unit-backup');
|
||||
expect(text).toContain('route-unit-third');
|
||||
} finally {
|
||||
root?.unmount();
|
||||
}
|
||||
});
|
||||
|
||||
it('shows oauth route unit summary and member details after expanding a pooled route', async () => {
|
||||
apiMock.getRoutesSummary.mockResolvedValue([
|
||||
{
|
||||
id: 1, modelPattern: 'gpt-5-codex', displayName: 'gpt-5-codex',
|
||||
displayIcon: null, modelMapping: null, enabled: true,
|
||||
channelCount: 1, enabledChannelCount: 1, siteNames: ['site-a'],
|
||||
decisionSnapshot: null, decisionRefreshedAt: null,
|
||||
},
|
||||
]);
|
||||
apiMock.getRouteChannels.mockResolvedValue([
|
||||
{
|
||||
id: 11, routeId: 1, accountId: 101, tokenId: null, sourceModel: 'gpt-5-codex',
|
||||
priority: 0, weight: 1, enabled: true, manualOverride: false,
|
||||
successCount: 0, failCount: 0,
|
||||
account: { username: 'pool-representative', credentialMode: 'oauth' },
|
||||
site: { id: 1, name: 'site-a', platform: 'openai' },
|
||||
token: null,
|
||||
routeUnit: {
|
||||
id: 17,
|
||||
name: 'Codex 池',
|
||||
memberCount: 3,
|
||||
strategy: 'stick_until_unavailable',
|
||||
members: [
|
||||
{ accountId: 101, username: 'user_a', siteName: 'site-a' },
|
||||
{ accountId: 102, username: 'user_b', siteName: 'site-b' },
|
||||
{ accountId: 103, username: 'user_c', siteName: 'site-c' },
|
||||
],
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
let root!: WebTestRenderer;
|
||||
try {
|
||||
await act(async () => {
|
||||
root = create(
|
||||
<MemoryRouter initialEntries={['/routes']}>
|
||||
<ToastProvider>
|
||||
<TokenRoutes />
|
||||
</ToastProvider>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
const expandBtn = root.root.find((node) =>
|
||||
node.type === 'div'
|
||||
&& String(node.props.className || '').includes('route-card-collapsed')
|
||||
&& collectText(node).includes('gpt-5-codex'),
|
||||
);
|
||||
await act(async () => {
|
||||
expandBtn.props.onClick();
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
const expandedText = collectText(root.root);
|
||||
expect(expandedText).toContain('OAuth 路由池');
|
||||
expect(expandedText).toContain('Codex 池');
|
||||
expect(expandedText).toContain('3 个成员');
|
||||
expect(expandedText).toContain('单个用到不可用再切');
|
||||
expect(expandedText).toContain('成员摘要');
|
||||
expect(expandedText).toContain('user_a @ site-a、user_b @ site-b、user_c @ site-c');
|
||||
} finally {
|
||||
root?.unmount();
|
||||
}
|
||||
});
|
||||
|
||||
it('writes explicit-group priority edits back to source channels and confirms shared-source impact', async () => {
|
||||
apiMock.getRoutesSummary.mockResolvedValue([
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user