forked from boos/BoosAPI
feat: enhance OAuthManagement with improved provider handling and user feedback
This commit is contained in:
@@ -89,8 +89,9 @@ describe('chat proxy codex oauth compatibility', () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
const { claudeMessagesProxyRoute } = await import('./chat.js');
|
const { chatProxyRoute, claudeMessagesProxyRoute } = await import('./chat.js');
|
||||||
app = Fastify();
|
app = Fastify();
|
||||||
|
await app.register(chatProxyRoute);
|
||||||
await app.register(claudeMessagesProxyRoute);
|
await app.register(claudeMessagesProxyRoute);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -176,4 +177,72 @@ describe('chat proxy codex oauth compatibility', () => {
|
|||||||
expect(body.content?.[0]?.type).toBe('text');
|
expect(body.content?.[0]?.type).toBe('text');
|
||||||
expect(body.content?.[0]?.text).toContain('pong from codex');
|
expect(body.content?.[0]?.text).toContain('pong from codex');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('translates codex SSE into Claude messages stream events instead of leaking raw response events', async () => {
|
||||||
|
fetchMock.mockResolvedValue(createSseResponse([
|
||||||
|
'event: response.created\n',
|
||||||
|
'data: {"type":"response.created","response":{"id":"resp_codex_claude_stream","model":"gpt-5.4","created_at":1706000000,"status":"in_progress","output":[]}}\n\n',
|
||||||
|
'event: response.output_item.added\n',
|
||||||
|
'data: {"type":"response.output_item.added","output_index":0,"item":{"id":"msg_codex_claude_stream","type":"message","role":"assistant","status":"in_progress","content":[]}}\n\n',
|
||||||
|
'event: response.content_part.added\n',
|
||||||
|
'data: {"type":"response.content_part.added","output_index":0,"content_index":0,"item_id":"msg_codex_claude_stream","part":{"type":"output_text","text":""}}\n\n',
|
||||||
|
'event: response.output_text.delta\n',
|
||||||
|
'data: {"type":"response.output_text.delta","output_index":0,"item_id":"msg_codex_claude_stream","content_index":0,"delta":"pong from codex"}\n\n',
|
||||||
|
'event: response.content_part.done\n',
|
||||||
|
'data: {"type":"response.content_part.done","output_index":0,"content_index":0,"item_id":"msg_codex_claude_stream","part":{"type":"output_text","text":"pong from codex"}}\n\n',
|
||||||
|
'event: response.completed\n',
|
||||||
|
'data: {"type":"response.completed","response":{"id":"resp_codex_claude_stream","model":"gpt-5.4","status":"completed","usage":{"input_tokens":9,"output_tokens":3,"total_tokens":12}}}\n\n',
|
||||||
|
'data: [DONE]\n\n',
|
||||||
|
]));
|
||||||
|
|
||||||
|
const response = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/v1/messages',
|
||||||
|
payload: {
|
||||||
|
model: 'gpt-5.4',
|
||||||
|
stream: true,
|
||||||
|
max_tokens: 256,
|
||||||
|
messages: [{ role: 'user', content: 'hello codex' }],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(200);
|
||||||
|
expect(response.headers['content-type']).toContain('text/event-stream');
|
||||||
|
expect(response.body).toContain('event: message_start');
|
||||||
|
expect(response.body).toContain('event: content_block_delta');
|
||||||
|
expect(response.body).toContain('pong from codex');
|
||||||
|
expect(response.body).not.toContain('event: response.created');
|
||||||
|
expect(response.body).not.toContain('"type":"response.output_text.delta"');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('translates codex SSE into OpenAI chat completion chunks instead of leaking raw response events', async () => {
|
||||||
|
fetchMock.mockResolvedValue(createSseResponse([
|
||||||
|
'event: response.created\n',
|
||||||
|
'data: {"type":"response.created","response":{"id":"resp_codex_openai_stream","model":"gpt-5.4","created_at":1706000000,"status":"in_progress","output":[]}}\n\n',
|
||||||
|
'event: response.output_item.added\n',
|
||||||
|
'data: {"type":"response.output_item.added","output_index":0,"item":{"id":"msg_codex_openai_stream","type":"message","role":"assistant","status":"in_progress","content":[]}}\n\n',
|
||||||
|
'event: response.output_text.delta\n',
|
||||||
|
'data: {"type":"response.output_text.delta","output_index":0,"item_id":"msg_codex_openai_stream","delta":"pong from codex"}\n\n',
|
||||||
|
'event: response.completed\n',
|
||||||
|
'data: {"type":"response.completed","response":{"id":"resp_codex_openai_stream","model":"gpt-5.4","status":"completed","usage":{"input_tokens":9,"output_tokens":3,"total_tokens":12}}}\n\n',
|
||||||
|
'data: [DONE]\n\n',
|
||||||
|
]));
|
||||||
|
|
||||||
|
const response = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/v1/chat/completions',
|
||||||
|
payload: {
|
||||||
|
model: 'gpt-5.4',
|
||||||
|
stream: true,
|
||||||
|
messages: [{ role: 'user', content: 'hello codex' }],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(200);
|
||||||
|
expect(response.headers['content-type']).toContain('text/event-stream');
|
||||||
|
expect(response.body).toContain('"object":"chat.completion.chunk"');
|
||||||
|
expect(response.body).toContain('pong from codex');
|
||||||
|
expect(response.body).not.toContain('event: response.created');
|
||||||
|
expect(response.body).not.toContain('"type":"response.output_text.delta"');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -374,6 +374,289 @@ describe('OAuthManagement page', () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('explains oauth usage and disables unavailable providers', async () => {
|
||||||
|
apiMock.getOAuthProviders.mockResolvedValue({
|
||||||
|
providers: [
|
||||||
|
{
|
||||||
|
provider: 'codex',
|
||||||
|
label: 'Codex',
|
||||||
|
platform: 'codex',
|
||||||
|
enabled: false,
|
||||||
|
loginType: 'oauth',
|
||||||
|
requiresProjectId: false,
|
||||||
|
supportsDirectAccountRouting: true,
|
||||||
|
supportsCloudValidation: true,
|
||||||
|
supportsNativeProxy: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
apiMock.getOAuthConnections.mockResolvedValue({
|
||||||
|
items: [],
|
||||||
|
total: 0,
|
||||||
|
limit: 100,
|
||||||
|
offset: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
let root: ReturnType<typeof create> | null = null;
|
||||||
|
try {
|
||||||
|
await act(async () => {
|
||||||
|
root = create(
|
||||||
|
<ToastProvider>
|
||||||
|
<MemoryRouter>
|
||||||
|
<OAuthManagement />
|
||||||
|
</MemoryRouter>
|
||||||
|
</ToastProvider>,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
await vi.waitFor(async () => {
|
||||||
|
await flushMicrotasks();
|
||||||
|
const text = collectText(root!.root);
|
||||||
|
expect(text).toContain('官方上游连接');
|
||||||
|
expect(text).toContain('CLI');
|
||||||
|
expect(text).toContain('下游密钥');
|
||||||
|
});
|
||||||
|
|
||||||
|
const startButton = root!.root.find((node) => (
|
||||||
|
node.type === 'button'
|
||||||
|
&& collectText(node).includes('当前不可用')
|
||||||
|
));
|
||||||
|
expect(startButton.props.disabled).toBe(true);
|
||||||
|
expect(collectText(root!.root)).toContain('当前环境未启用');
|
||||||
|
} finally {
|
||||||
|
root?.unmount();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('prefers renamed account title and still shows oauth email', 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: 7,
|
||||||
|
provider: 'codex',
|
||||||
|
username: 'Juricek Team A',
|
||||||
|
email: 'juricek.chen@gmail.com',
|
||||||
|
accountKey: 'chatgpt-account-123',
|
||||||
|
planType: 'team',
|
||||||
|
modelCount: 11,
|
||||||
|
modelsPreview: ['gpt-5.4'],
|
||||||
|
status: 'healthy',
|
||||||
|
routeChannelCount: 11,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
total: 1,
|
||||||
|
limit: 100,
|
||||||
|
offset: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
let root: ReturnType<typeof create> | null = null;
|
||||||
|
try {
|
||||||
|
await act(async () => {
|
||||||
|
root = create(
|
||||||
|
<ToastProvider>
|
||||||
|
<MemoryRouter>
|
||||||
|
<OAuthManagement />
|
||||||
|
</MemoryRouter>
|
||||||
|
</ToastProvider>,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
await vi.waitFor(async () => {
|
||||||
|
await flushMicrotasks();
|
||||||
|
const text = collectText(root!.root);
|
||||||
|
expect(text).toContain('Juricek Team A');
|
||||||
|
expect(text).toContain('juricek.chen@gmail.com');
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
root?.unmount();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows starting gemini oauth without entering a project id', async () => {
|
||||||
|
promptMock.mockReturnValueOnce('');
|
||||||
|
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: [],
|
||||||
|
total: 0,
|
||||||
|
limit: 100,
|
||||||
|
offset: 0,
|
||||||
|
});
|
||||||
|
apiMock.startOAuthProvider.mockResolvedValue({
|
||||||
|
provider: 'gemini-cli',
|
||||||
|
state: 'gemini-state-123',
|
||||||
|
authorizationUrl: 'https://accounts.google.com/o/oauth2/v2/auth?state=gemini-state-123',
|
||||||
|
instructions: {
|
||||||
|
redirectUri: 'http://localhost:8085/oauth2callback',
|
||||||
|
callbackPort: 8085,
|
||||||
|
callbackPath: '/oauth2callback',
|
||||||
|
manualCallbackDelayMs: 15000,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
apiMock.getOAuthSession.mockResolvedValue({
|
||||||
|
provider: 'gemini-cli',
|
||||||
|
state: 'gemini-state-123',
|
||||||
|
status: 'pending',
|
||||||
|
});
|
||||||
|
|
||||||
|
let root: ReturnType<typeof create> | null = null;
|
||||||
|
try {
|
||||||
|
await act(async () => {
|
||||||
|
root = create(
|
||||||
|
<ToastProvider>
|
||||||
|
<MemoryRouter>
|
||||||
|
<OAuthManagement />
|
||||||
|
</MemoryRouter>
|
||||||
|
</ToastProvider>,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
await vi.waitFor(async () => {
|
||||||
|
await flushMicrotasks();
|
||||||
|
});
|
||||||
|
|
||||||
|
const startButton = root!.root.find((node) => (
|
||||||
|
node.type === 'button'
|
||||||
|
&& typeof node.props.onClick === 'function'
|
||||||
|
&& collectText(node).includes('连接 Gemini CLI')
|
||||||
|
));
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await startButton.props.onClick();
|
||||||
|
});
|
||||||
|
await vi.waitFor(async () => {
|
||||||
|
await flushMicrotasks();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(promptMock).toHaveBeenCalled();
|
||||||
|
expect(apiMock.startOAuthProvider).toHaveBeenCalledWith('gemini-cli', { projectId: undefined });
|
||||||
|
expect(openMock).toHaveBeenCalledWith(
|
||||||
|
'https://accounts.google.com/o/oauth2/v2/auth?state=gemini-state-123',
|
||||||
|
'oauth-gemini-cli',
|
||||||
|
expect.stringContaining('width=540'),
|
||||||
|
);
|
||||||
|
expect(collectText(root!.root)).not.toContain('Gemini CLI 连接需要 Project ID');
|
||||||
|
} finally {
|
||||||
|
root?.unmount();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rebinds gemini oauth without prompting for a project id again', 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-user@example.com',
|
||||||
|
planType: 'cloud',
|
||||||
|
modelCount: 2,
|
||||||
|
modelsPreview: ['gemini-2.5-pro', 'gemini-2.5-flash'],
|
||||||
|
status: 'healthy',
|
||||||
|
projectId: 'project-demo',
|
||||||
|
routeChannelCount: 0,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
total: 1,
|
||||||
|
limit: 100,
|
||||||
|
offset: 0,
|
||||||
|
});
|
||||||
|
apiMock.rebindOAuthConnection.mockResolvedValue({
|
||||||
|
provider: 'gemini-cli',
|
||||||
|
state: 'gemini-rebind-123',
|
||||||
|
authorizationUrl: 'https://accounts.google.com/o/oauth2/v2/auth?state=gemini-rebind-123',
|
||||||
|
instructions: {
|
||||||
|
redirectUri: 'http://localhost:8085/oauth2callback',
|
||||||
|
callbackPort: 8085,
|
||||||
|
callbackPath: '/oauth2callback',
|
||||||
|
manualCallbackDelayMs: 15000,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
apiMock.getOAuthSession.mockResolvedValue({
|
||||||
|
provider: 'gemini-cli',
|
||||||
|
state: 'gemini-rebind-123',
|
||||||
|
status: 'pending',
|
||||||
|
});
|
||||||
|
|
||||||
|
let root: ReturnType<typeof create> | null = null;
|
||||||
|
try {
|
||||||
|
await act(async () => {
|
||||||
|
root = create(
|
||||||
|
<ToastProvider>
|
||||||
|
<MemoryRouter>
|
||||||
|
<OAuthManagement />
|
||||||
|
</MemoryRouter>
|
||||||
|
</ToastProvider>,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
await vi.waitFor(async () => {
|
||||||
|
await flushMicrotasks();
|
||||||
|
});
|
||||||
|
|
||||||
|
const rebindButton = root!.root.find((node) => (
|
||||||
|
node.type === 'button'
|
||||||
|
&& typeof node.props.onClick === 'function'
|
||||||
|
&& collectText(node).includes('重新授权')
|
||||||
|
));
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await rebindButton.props.onClick();
|
||||||
|
});
|
||||||
|
await vi.waitFor(async () => {
|
||||||
|
await flushMicrotasks();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(promptMock).not.toHaveBeenCalled();
|
||||||
|
expect(apiMock.rebindOAuthConnection).toHaveBeenCalledWith(11);
|
||||||
|
expect(openMock).toHaveBeenCalledWith(
|
||||||
|
'https://accounts.google.com/o/oauth2/v2/auth?state=gemini-rebind-123',
|
||||||
|
'oauth-gemini-cli',
|
||||||
|
expect.stringContaining('width=540'),
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
root?.unmount();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it('shows oauth connection status metadata and allows deleting a connection', async () => {
|
it('shows oauth connection status metadata and allows deleting a connection', async () => {
|
||||||
apiMock.getOAuthProviders.mockResolvedValue({
|
apiMock.getOAuthProviders.mockResolvedValue({
|
||||||
providers: [
|
providers: [
|
||||||
|
|||||||
@@ -34,6 +34,29 @@ function resolveConnectionStatusLabel(status?: string): string {
|
|||||||
return status === 'abnormal' ? '异常' : '正常';
|
return status === 'abnormal' ? '异常' : '正常';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function asTrimmedString(value: string | null | undefined): string {
|
||||||
|
return typeof value === 'string' ? value.trim() : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveProviderActionLabel(provider: OAuthProviderInfo, loading: boolean): string {
|
||||||
|
if (loading) return '启动中...';
|
||||||
|
if (!provider.enabled) return '当前不可用';
|
||||||
|
return `连接 ${provider.label}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveConnectionPrimaryTitle(connection: OAuthConnectionInfo): string {
|
||||||
|
return asTrimmedString(connection.username)
|
||||||
|
|| asTrimmedString(connection.email)
|
||||||
|
|| asTrimmedString(connection.provider)
|
||||||
|
|| 'OAuth 连接';
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveConnectionEmailLabel(connection: OAuthConnectionInfo): string {
|
||||||
|
const email = asTrimmedString(connection.email);
|
||||||
|
if (!email) return '';
|
||||||
|
return email;
|
||||||
|
}
|
||||||
|
|
||||||
function renderCodeBlock(value: string) {
|
function renderCodeBlock(value: string) {
|
||||||
return (
|
return (
|
||||||
<code style={{
|
<code style={{
|
||||||
@@ -172,20 +195,21 @@ export default function OAuthManagement() {
|
|||||||
}, [activeSession]);
|
}, [activeSession]);
|
||||||
|
|
||||||
const handleStart = async (provider: OAuthProviderInfo, accountId?: number) => {
|
const handleStart = async (provider: OAuthProviderInfo, accountId?: number) => {
|
||||||
|
if (!provider.enabled) {
|
||||||
|
setSessionMessage(`${provider.label} 当前环境未启用`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
const actionKey = `start:${provider.provider}:${accountId || 0}`;
|
const actionKey = `start:${provider.provider}:${accountId || 0}`;
|
||||||
setActionLoadingKey(actionKey);
|
setActionLoadingKey(actionKey);
|
||||||
try {
|
try {
|
||||||
const projectId = provider.requiresProjectId
|
const shouldPromptForProjectId = provider.requiresProjectId && !accountId;
|
||||||
|
const projectId = shouldPromptForProjectId
|
||||||
? (() => {
|
? (() => {
|
||||||
if (typeof window === 'undefined' || typeof window.prompt !== 'function') return undefined;
|
if (typeof window === 'undefined' || typeof window.prompt !== 'function') return undefined;
|
||||||
const value = window.prompt('输入 Google Cloud Project ID');
|
const value = window.prompt('输入 Google Cloud Project ID(可选,留空则自动解析)');
|
||||||
return typeof value === 'string' && value.trim() ? value.trim() : undefined;
|
return typeof value === 'string' && value.trim() ? value.trim() : undefined;
|
||||||
})()
|
})()
|
||||||
: undefined;
|
: undefined;
|
||||||
if (provider.requiresProjectId && !projectId && !accountId) {
|
|
||||||
setSessionMessage('Gemini CLI 连接需要 Project ID');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const started = accountId
|
const started = accountId
|
||||||
? await api.rebindOAuthConnection(accountId)
|
? await api.rebindOAuthConnection(accountId)
|
||||||
: await api.startOAuthProvider(provider.provider, { projectId });
|
: await api.startOAuthProvider(provider.provider, { projectId });
|
||||||
@@ -245,7 +269,9 @@ export default function OAuthManagement() {
|
|||||||
<div className="page-header">
|
<div className="page-header">
|
||||||
<div>
|
<div>
|
||||||
<div className="page-title">OAuth 管理</div>
|
<div className="page-title">OAuth 管理</div>
|
||||||
<div className="page-subtitle">统一管理需要浏览器授权的上游连接,包括 Codex、Claude 和 Gemini CLI。</div>
|
<div className="page-subtitle">
|
||||||
|
统一管理需要浏览器授权的官方上游连接。授权完成后,可把 Codex、Claude、Gemini CLI 等 CLI / Web 登录态接入 metapi,继续通过统一路由、下游密钥、模型操练场和第三方客户端转发使用。
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -348,26 +374,33 @@ export default function OAuthManagement() {
|
|||||||
|
|
||||||
<div className="card" style={{ padding: 20, marginBottom: 16 }}>
|
<div className="card" style={{ padding: 20, marginBottom: 16 }}>
|
||||||
<div style={{ fontSize: 15, fontWeight: 600, marginBottom: 12 }}>授权入口</div>
|
<div style={{ fontSize: 15, fontWeight: 600, marginBottom: 12 }}>授权入口</div>
|
||||||
|
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 12, lineHeight: 1.6 }}>
|
||||||
|
适合把官方 CLI / Web 登录得到的账号统一接入 metapi,再供各种 CLI、SDK、下游密钥和模型操练场复用。
|
||||||
|
</div>
|
||||||
<div style={{ display: 'grid', gap: 12 }}>
|
<div style={{ display: 'grid', gap: 12 }}>
|
||||||
{providers.map((provider) => {
|
{providers.map((provider) => {
|
||||||
const actionKey = `start:${provider.provider}:0`;
|
const actionKey = `start:${provider.provider}:0`;
|
||||||
|
const disabled = !provider.enabled || actionLoadingKey === actionKey;
|
||||||
return (
|
return (
|
||||||
<div key={provider.provider} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12 }}>
|
<div key={provider.provider} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12 }}>
|
||||||
<div>
|
<div>
|
||||||
<div style={{ fontSize: 14, fontWeight: 600 }}>{provider.label}</div>
|
<div style={{ fontSize: 14, fontWeight: 600 }}>{provider.label}</div>
|
||||||
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>
|
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>
|
||||||
{provider.platform}
|
{provider.platform}
|
||||||
{provider.requiresProjectId ? ' · 需要 Project ID' : ''}
|
{provider.requiresProjectId ? ' · 可选 Project ID' : ''}
|
||||||
{provider.supportsNativeProxy ? ' · 原生代理' : ''}
|
{provider.supportsNativeProxy ? ' · 原生代理' : ''}
|
||||||
|
{!provider.enabled ? ' · 当前环境未启用' : ''}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="btn btn-primary"
|
className="btn btn-primary"
|
||||||
onClick={() => handleStart(provider)}
|
onClick={() => handleStart(provider)}
|
||||||
disabled={actionLoadingKey === actionKey}
|
disabled={disabled}
|
||||||
|
title={!provider.enabled ? `${provider.label} 当前环境未启用` : undefined}
|
||||||
|
style={!provider.enabled ? { opacity: 0.55, cursor: 'not-allowed' } : undefined}
|
||||||
>
|
>
|
||||||
{actionLoadingKey === actionKey ? '启动中...' : `连接 ${provider.label}`}
|
{resolveProviderActionLabel(provider, actionLoadingKey === actionKey)}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -384,13 +417,20 @@ export default function OAuthManagement() {
|
|||||||
{connections.map((connection) => {
|
{connections.map((connection) => {
|
||||||
const rebindActionKey = `start:${connection.provider}:${connection.accountId}`;
|
const rebindActionKey = `start:${connection.provider}:${connection.accountId}`;
|
||||||
const deleteActionKey = `delete:${connection.accountId}`;
|
const deleteActionKey = `delete:${connection.accountId}`;
|
||||||
|
const primaryTitle = resolveConnectionPrimaryTitle(connection);
|
||||||
|
const emailLabel = resolveConnectionEmailLabel(connection);
|
||||||
return (
|
return (
|
||||||
<div key={connection.accountId} style={{ border: '1px solid var(--color-border)', borderRadius: 'var(--radius-md)', padding: 14 }}>
|
<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 }}>
|
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 12 }}>
|
||||||
<div style={{ minWidth: 0 }}>
|
<div style={{ minWidth: 0 }}>
|
||||||
<div style={{ fontSize: 14, fontWeight: 600 }}>{connection.email || connection.username || connection.provider}</div>
|
<div style={{ fontSize: 14, fontWeight: 600 }}>{primaryTitle}</div>
|
||||||
|
{emailLabel && emailLabel !== primaryTitle && (
|
||||||
|
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginTop: 4 }}>
|
||||||
|
授权邮箱: {emailLabel}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginTop: 4 }}>
|
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginTop: 4 }}>
|
||||||
{connection.planType || 'unknown'} · {connection.modelCount} 个模型
|
{connection.provider} · {connection.planType || 'unknown'} · {connection.modelCount} 个模型
|
||||||
</div>
|
</div>
|
||||||
{connection.accountKey && (
|
{connection.accountKey && (
|
||||||
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginTop: 4 }}>
|
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginTop: 4 }}>
|
||||||
|
|||||||
Reference in New Issue
Block a user