forked from boos/BoosAPI
3210 lines
122 KiB
TypeScript
3210 lines
122 KiB
TypeScript
import {
|
||
useCallback,
|
||
useEffect,
|
||
useId,
|
||
useMemo,
|
||
useRef,
|
||
useState,
|
||
type ChangeEvent,
|
||
type DragEvent,
|
||
type ReactNode,
|
||
} from 'react';
|
||
import { createPortal } from 'react-dom';
|
||
import { useLocation } from 'react-router-dom';
|
||
import CenteredModal from '../components/CenteredModal.js';
|
||
import ResponsiveBatchActionBar from '../components/ResponsiveBatchActionBar.js';
|
||
import ResponsiveFilterPanel from '../components/ResponsiveFilterPanel.js';
|
||
import { MobileCard, MobileField } from '../components/MobileCard.js';
|
||
import ModernSelect from '../components/ModernSelect.js';
|
||
import { useToast } from '../components/Toast.js';
|
||
import { trapFocusInContainer } from '../components/focusTrap.js';
|
||
import { useAnimatedVisibility } from '../components/useAnimatedVisibility.js';
|
||
import { useIsMobile } from '../components/useIsMobile.js';
|
||
import OAuthModelsModal, { type OAuthModelItem } from './oauth/OAuthModelsModal.js';
|
||
import {
|
||
api,
|
||
type OAuthConnectionInfo,
|
||
type OAuthImportResponse,
|
||
type OAuthProviderInfo,
|
||
type OAuthRouteParticipation,
|
||
type OAuthRouteUnitStrategy,
|
||
type OAuthQuotaInfo,
|
||
type OAuthQuotaWindowInfo,
|
||
type OAuthStartInstructions,
|
||
} from '../api.js';
|
||
|
||
const POLL_INTERVAL_MS = 1500;
|
||
const CONNECTION_PAGE_LIMIT = 200;
|
||
const AUTO_REFRESH_OPTIONS = [0, 5, 10, 15, 30] as const;
|
||
type TokenFilter = '' | 'valid' | 'expiring' | 'expired' | 'unknown';
|
||
type ColumnMenuFocusEdge = 'first' | 'last';
|
||
|
||
type ActiveSession = {
|
||
provider: string;
|
||
state: string;
|
||
authorizationUrl: string;
|
||
instructions: OAuthStartInstructions;
|
||
};
|
||
|
||
type DrawerIntent =
|
||
| { mode: 'create'; provider?: string }
|
||
| { mode: 'rebind'; account: OAuthConnectionInfo }
|
||
| { mode: 'proxy'; account: OAuthConnectionInfo };
|
||
|
||
type ColumnKey = 'identity' | 'site' | 'status' | 'quota' | 'proxy';
|
||
|
||
type OAuthProviderWireInfo = Partial<OAuthProviderInfo> & {
|
||
id?: string | null;
|
||
key?: string | null;
|
||
value?: string | null;
|
||
name?: string | null;
|
||
displayName?: string | null;
|
||
display_name?: string | null;
|
||
type?: string | null;
|
||
login_type?: string | null;
|
||
requires_project_id?: boolean | null;
|
||
supports_direct_account_routing?: boolean | null;
|
||
supports_cloud_validation?: boolean | null;
|
||
supports_native_proxy?: boolean | null;
|
||
};
|
||
|
||
type OAuthImportFileLike = {
|
||
name?: string;
|
||
text?: () => Promise<string>;
|
||
};
|
||
|
||
type OAuthImportDraft = {
|
||
sourceName: string;
|
||
rawText: string;
|
||
error?: string;
|
||
};
|
||
|
||
type OAuthImportSource = {
|
||
sourceName: string;
|
||
rawText: string;
|
||
error?: string;
|
||
};
|
||
|
||
type OAuthImportPreview = {
|
||
sourceName: string;
|
||
valid: boolean;
|
||
providerLabel?: string;
|
||
email?: string;
|
||
accountKey?: string;
|
||
expiresLabel?: string;
|
||
disabled?: boolean;
|
||
error?: string;
|
||
parsedData?: Record<string, unknown>;
|
||
};
|
||
|
||
type OAuthImportPreviewSummary = {
|
||
totalCount: number;
|
||
validCount: number;
|
||
invalidCount: number;
|
||
hasInvalidItems: boolean;
|
||
canImport: boolean;
|
||
items: OAuthImportPreview[];
|
||
};
|
||
|
||
type OAuthModelsModalState = {
|
||
open: boolean;
|
||
loading: boolean;
|
||
refreshing: boolean;
|
||
connection: OAuthConnectionInfo | null;
|
||
models: OAuthModelItem[];
|
||
totalCount: number;
|
||
disabledCount: number;
|
||
siteName: string;
|
||
};
|
||
|
||
type OAuthRouteUnitModalState = {
|
||
open: boolean;
|
||
name: string;
|
||
strategy: OAuthRouteUnitStrategy;
|
||
};
|
||
|
||
type SessionRouteUnitFeedback = {
|
||
action: 'created' | 'deleted';
|
||
name: string;
|
||
memberCount: number;
|
||
strategy: OAuthRouteUnitStrategy;
|
||
};
|
||
|
||
type SessionFeedback = {
|
||
message: string;
|
||
tone: 'info' | 'success' | 'error';
|
||
routeUnit?: SessionRouteUnitFeedback | null;
|
||
};
|
||
|
||
const COLUMN_OPTIONS: Array<{ key: ColumnKey; label: string }> = [
|
||
{ key: 'identity', label: '账号 / Provider' },
|
||
{ key: 'site', label: '站点' },
|
||
{ key: 'status', label: '运行状态' },
|
||
{ key: 'quota', label: 'Usage / Quota' },
|
||
{ key: 'proxy', label: '代理 / 项目' },
|
||
];
|
||
|
||
const TOKEN_FILTER_OPTIONS: Array<{ value: TokenFilter; label: string }> = [
|
||
{ value: '', label: '全部 Token' },
|
||
{ value: 'valid', label: 'Token 正常' },
|
||
{ value: 'expiring', label: 'Token 即将到期' },
|
||
{ value: 'expired', label: 'Token 已过期' },
|
||
{ value: 'unknown', label: 'Token 未知' },
|
||
];
|
||
|
||
function formatCountedLabel(label: string, count: number): string {
|
||
return count > 0 ? `${label} · ${count}` : label;
|
||
}
|
||
|
||
function openOAuthPopup(provider: string, authorizationUrl: string) {
|
||
if (typeof window === 'undefined' || typeof window.open !== 'function') return;
|
||
const popup = window.open(
|
||
authorizationUrl,
|
||
`oauth-${provider}`,
|
||
'popup=yes,width=540,height=760,resizable=yes,scrollbars=yes,noopener,noreferrer',
|
||
);
|
||
if (popup) {
|
||
try {
|
||
popup.opener = null;
|
||
} catch {
|
||
// Ignore cross-window opener hardening failures.
|
||
}
|
||
}
|
||
if (popup && typeof popup.focus === 'function') {
|
||
popup.focus();
|
||
}
|
||
}
|
||
|
||
function asTrimmedString(value: string | null | undefined): string {
|
||
if (typeof value !== 'string') return '';
|
||
const text = value.trim();
|
||
const lowerText = text.toLowerCase();
|
||
if (lowerText === 'undefined' || lowerText === 'null') return '';
|
||
return text;
|
||
}
|
||
|
||
function resolveOAuthProviderFallbackLabel(providerKey: string): string {
|
||
const normalized = providerKey.trim().toLowerCase();
|
||
if (normalized === 'codex') return 'Codex';
|
||
if (normalized === 'openai') return 'OpenAI';
|
||
if (normalized === 'anthropic') return 'Anthropic';
|
||
if (normalized === 'claude') return 'Claude';
|
||
if (normalized === 'google') return 'Google AI';
|
||
if (normalized === 'gemini-cli' || normalized === 'gemini') return 'Gemini CLI';
|
||
if (normalized === 'antigravity') return 'Antigravity';
|
||
return providerKey;
|
||
}
|
||
|
||
function normalizeOAuthProvider(provider: OAuthProviderWireInfo): OAuthProviderInfo | null {
|
||
const providerKey = asTrimmedString(provider.provider)
|
||
|| asTrimmedString(provider.id)
|
||
|| asTrimmedString(provider.value)
|
||
|| asTrimmedString(provider.key)
|
||
|| asTrimmedString(provider.type);
|
||
if (!providerKey) return null;
|
||
|
||
const platform = asTrimmedString(provider.platform)
|
||
|| asTrimmedString(provider.type)
|
||
|| providerKey;
|
||
|
||
const label = asTrimmedString(provider.label)
|
||
|| asTrimmedString(provider.name)
|
||
|| asTrimmedString(provider.displayName)
|
||
|| asTrimmedString(provider.display_name)
|
||
|| resolveOAuthProviderFallbackLabel(providerKey);
|
||
|
||
return {
|
||
provider: providerKey,
|
||
label,
|
||
platform,
|
||
enabled: provider.enabled !== false,
|
||
loginType: 'oauth',
|
||
requiresProjectId: provider.requiresProjectId === true || provider.requires_project_id === true,
|
||
supportsDirectAccountRouting: provider.supportsDirectAccountRouting ?? provider.supports_direct_account_routing ?? true,
|
||
supportsCloudValidation: provider.supportsCloudValidation ?? provider.supports_cloud_validation ?? true,
|
||
supportsNativeProxy: provider.supportsNativeProxy ?? provider.supports_native_proxy ?? true,
|
||
};
|
||
}
|
||
|
||
function normalizeOauthMessage(value: string | null | undefined): string {
|
||
const text = asTrimmedString(value);
|
||
if (!text) return '';
|
||
|
||
return text
|
||
.replace(/codex usage windows inferred from rate limit response headers/ig, '额度窗口已从响应头推断')
|
||
.replace(/official 5h quota window is not exposed by current codex oauth artifacts/ig, '当前 Codex OAuth 未暴露官方 5h 窗口')
|
||
.replace(/official 7d quota window is not exposed by current codex oauth artifacts/ig, '当前 Codex OAuth 未暴露官方 7d 窗口')
|
||
.replace(/official 5h quota window is unavailable for this provider/ig, '当前 Provider 不提供官方 5h 窗口')
|
||
.replace(/official 7d quota window is unavailable for this provider/ig, '当前 Provider 不提供官方 7d 窗口')
|
||
.replace(/\bfetch failed\b/ig, '网络请求失败');
|
||
}
|
||
|
||
function isOAuthDirectFlowUnavailable(error: unknown): boolean {
|
||
const message = error instanceof Error
|
||
? error.message
|
||
: typeof error === 'string'
|
||
? error
|
||
: '';
|
||
const normalized = message.trim().toLowerCase();
|
||
return normalized.includes('not implemented')
|
||
|| normalized.includes('not configured')
|
||
|| normalized.includes('未实现')
|
||
|| normalized.includes('未配置');
|
||
}
|
||
|
||
function isModelRefreshResultSuccessful(value: unknown): boolean {
|
||
if (!value || typeof value !== 'object') return true;
|
||
const refresh = (value as { refresh?: { status?: unknown } }).refresh;
|
||
return refresh?.status !== 'error';
|
||
}
|
||
|
||
function formatOAuthImportMessage(result: OAuthImportResponse): string {
|
||
const imported = Number(result.imported || 0);
|
||
const updated = Number(result.updated || 0);
|
||
const failed = Number(result.failed || 0);
|
||
const skipped = Number(result.skipped || 0);
|
||
const changedParts = [
|
||
imported > 0 ? `添加 ${imported} 个` : '',
|
||
updated > 0 ? `更新 ${updated} 个` : '',
|
||
skipped > 0 ? `跳过 ${skipped} 个` : '',
|
||
].filter(Boolean);
|
||
|
||
if (failed > 0) {
|
||
return [
|
||
'批量导入完成',
|
||
...changedParts,
|
||
`失败 ${failed} 个`,
|
||
].join(',');
|
||
}
|
||
|
||
if (changedParts.length > 0) {
|
||
return `已${changedParts.join('、')} OAuth 连接`;
|
||
}
|
||
|
||
return '没有可导入的 OAuth 连接';
|
||
}
|
||
|
||
function listImportFiles(files: ArrayLike<OAuthImportFileLike> | null | undefined): OAuthImportFileLike[] {
|
||
return files ? Array.from(files) : [];
|
||
}
|
||
|
||
async function readOauthImportDrafts(
|
||
files: ArrayLike<OAuthImportFileLike> | null | undefined,
|
||
): Promise<OAuthImportDraft[]> {
|
||
const nextFiles = listImportFiles(files);
|
||
return Promise.all(nextFiles.map(async (file, index) => {
|
||
const sourceName = asTrimmedString(file.name) || `oauth-import-${index + 1}.json`;
|
||
if (typeof file.text !== 'function') {
|
||
return {
|
||
sourceName,
|
||
rawText: '',
|
||
error: '当前浏览器不支持读取该文件',
|
||
};
|
||
}
|
||
try {
|
||
return {
|
||
sourceName,
|
||
rawText: await file.text(),
|
||
};
|
||
} catch (error: any) {
|
||
return {
|
||
sourceName,
|
||
rawText: '',
|
||
error: error?.message || '读取文件失败',
|
||
};
|
||
}
|
||
}));
|
||
}
|
||
|
||
function decodeJwtPayload(token?: string): Record<string, unknown> | null {
|
||
if (typeof token !== 'string' || !token.trim()) return null;
|
||
const parts = token.split('.');
|
||
if (parts.length !== 3) return null;
|
||
const raw = (parts[1] || '').replace(/-/g, '+').replace(/_/g, '/');
|
||
const padded = raw + '='.repeat((4 - (raw.length % 4 || 4)) % 4);
|
||
try {
|
||
const decoded = typeof window !== 'undefined' && typeof window.atob === 'function'
|
||
? window.atob(padded)
|
||
: '';
|
||
const parsed = JSON.parse(decoded) as unknown;
|
||
return parsed && typeof parsed === 'object' && !Array.isArray(parsed)
|
||
? parsed as Record<string, unknown>
|
||
: null;
|
||
} catch {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
function resolveImportProviderLabel(value: string): string | null {
|
||
const normalized = value.trim().toLowerCase();
|
||
if (normalized === 'codex' || normalized === 'openai') return 'Codex';
|
||
if (normalized === 'claude' || normalized === 'anthropic') return 'Claude';
|
||
if (normalized === 'gemini-cli' || normalized === 'gemini') return 'Gemini CLI';
|
||
if (normalized === 'antigravity') return 'Antigravity';
|
||
return null;
|
||
}
|
||
|
||
function resolveImportPreviewExpiryLabel(value: unknown): string | undefined {
|
||
if (value === undefined || value === null || value === '') return undefined;
|
||
const rawNumber = typeof value === 'number'
|
||
? value
|
||
: (typeof value === 'string' && /^\d+$/.test(value.trim()))
|
||
? Number.parseInt(value.trim(), 10)
|
||
: Date.parse(typeof value === 'string' ? value : '');
|
||
if (!Number.isFinite(rawNumber) || rawNumber <= 0) {
|
||
throw new Error('expired 时间格式无效');
|
||
}
|
||
const parsed = rawNumber < 10_000_000_000 ? rawNumber * 1000 : rawNumber;
|
||
return new Date(parsed).toLocaleString();
|
||
}
|
||
|
||
function parseOauthImportPreview(source: OAuthImportSource): OAuthImportPreview {
|
||
if (source.error) {
|
||
return {
|
||
sourceName: source.sourceName,
|
||
valid: false,
|
||
error: source.error,
|
||
};
|
||
}
|
||
|
||
const raw = source.rawText.trim();
|
||
if (!raw) {
|
||
return {
|
||
sourceName: source.sourceName,
|
||
valid: false,
|
||
error: 'JSON 内容为空',
|
||
};
|
||
}
|
||
|
||
let parsed: unknown;
|
||
try {
|
||
parsed = JSON.parse(raw);
|
||
} catch {
|
||
return {
|
||
sourceName: source.sourceName,
|
||
valid: false,
|
||
error: 'JSON 解析失败',
|
||
};
|
||
}
|
||
|
||
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
||
return {
|
||
sourceName: source.sourceName,
|
||
valid: false,
|
||
error: '需要单个 OAuth JSON 对象',
|
||
};
|
||
}
|
||
|
||
const payload = parsed as Record<string, unknown>;
|
||
const type = asTrimmedString(typeof payload.type === 'string' ? payload.type : '')
|
||
|| asTrimmedString(typeof payload.provider === 'string' ? payload.provider : '')
|
||
|| asTrimmedString(typeof payload.oauthProvider === 'string' ? payload.oauthProvider : '');
|
||
if (
|
||
type === 'sub2api-data'
|
||
|| type === 'sub2api-bundle'
|
||
|| 'accounts' in payload
|
||
|| 'proxies' in payload
|
||
|| 'version' in payload
|
||
|| 'exported_at' in payload
|
||
) {
|
||
return {
|
||
sourceName: source.sourceName,
|
||
valid: false,
|
||
error: '这是旧的 sub2api 导出格式',
|
||
};
|
||
}
|
||
|
||
const providerLabel = type ? resolveImportProviderLabel(type) : null;
|
||
if (!providerLabel) {
|
||
return {
|
||
sourceName: source.sourceName,
|
||
valid: false,
|
||
error: `不支持的 OAuth 类型:${type || '未知'}`,
|
||
};
|
||
}
|
||
|
||
const accessToken = asTrimmedString(typeof payload.access_token === 'string' ? payload.access_token : '')
|
||
|| asTrimmedString(typeof payload.accessToken === 'string' ? payload.accessToken : '');
|
||
if (!accessToken) {
|
||
return {
|
||
sourceName: source.sourceName,
|
||
valid: false,
|
||
error: '缺少 access_token',
|
||
};
|
||
}
|
||
|
||
try {
|
||
const idToken = typeof payload.id_token === 'string'
|
||
? payload.id_token
|
||
: typeof payload.idToken === 'string'
|
||
? payload.idToken
|
||
: undefined;
|
||
const claims = decodeJwtPayload(idToken);
|
||
const authClaims = claims?.['https://api.openai.com/auth'];
|
||
const authRecord = authClaims && typeof authClaims === 'object' && !Array.isArray(authClaims)
|
||
? authClaims as Record<string, unknown>
|
||
: null;
|
||
return {
|
||
sourceName: source.sourceName,
|
||
valid: true,
|
||
providerLabel,
|
||
email: asTrimmedString(typeof payload.email === 'string' ? payload.email : '')
|
||
|| asTrimmedString(typeof claims?.email === 'string' ? claims.email : '')
|
||
|| asTrimmedString(typeof claims?.preferred_username === 'string' ? claims.preferred_username : ''),
|
||
accountKey: asTrimmedString(typeof payload.account_key === 'string' ? payload.account_key : '')
|
||
|| asTrimmedString(typeof payload.account_id === 'string' ? payload.account_id : '')
|
||
|| asTrimmedString(typeof payload.accountKey === 'string' ? payload.accountKey : '')
|
||
|| asTrimmedString(typeof payload.accountId === 'string' ? payload.accountId : '')
|
||
|| asTrimmedString(typeof authRecord?.chatgpt_account_id === 'string' ? authRecord.chatgpt_account_id : ''),
|
||
expiresLabel: resolveImportPreviewExpiryLabel(
|
||
payload.expired
|
||
?? payload.expires_at
|
||
?? payload.expiresAt
|
||
?? payload.tokenExpiresAt
|
||
?? payload.token_expires_at,
|
||
),
|
||
disabled: payload.disabled === true,
|
||
parsedData: payload,
|
||
};
|
||
} catch (error: any) {
|
||
return {
|
||
sourceName: source.sourceName,
|
||
valid: false,
|
||
error: error?.message || 'JSON 结构无效',
|
||
};
|
||
}
|
||
}
|
||
|
||
function resolveConnectionPrimaryTitle(connection: OAuthConnectionInfo): string {
|
||
return asTrimmedString(connection.username)
|
||
|| asTrimmedString(connection.email)
|
||
|| asTrimmedString(connection.accountKey)
|
||
|| asTrimmedString(connection.provider)
|
||
|| 'OAuth 连接';
|
||
}
|
||
|
||
function resolveConnectionEmailLabel(connection: OAuthConnectionInfo): string {
|
||
return asTrimmedString(connection.email);
|
||
}
|
||
|
||
function resolveConnectionStatusLabel(status?: string): string {
|
||
return status === 'abnormal' ? '异常' : '正常';
|
||
}
|
||
|
||
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 resolveModelSyncStatusText(connection: OAuthConnectionInfo): string {
|
||
const failureText = normalizeOauthMessage(connection.lastModelSyncError || '');
|
||
if (failureText) return '获取失败';
|
||
return connection.lastModelSyncAt ? '同步正常' : '未同步';
|
||
}
|
||
|
||
function resolveQuotaSyncStatusText(quota?: OAuthQuotaInfo | null): string {
|
||
if (!quota) return '未刷新额度';
|
||
if (quota.status === 'error') {
|
||
return '获取失败';
|
||
}
|
||
if (quota.status === 'unsupported') {
|
||
return '不支持';
|
||
}
|
||
return quota.lastSyncAt ? '同步正常' : '未刷新';
|
||
}
|
||
|
||
function resolveModelSyncDetail(connection: OAuthConnectionInfo): string {
|
||
return normalizeOauthMessage(connection.lastModelSyncError || '');
|
||
}
|
||
|
||
function resolveQuotaSyncDetail(quota?: OAuthQuotaInfo | null): string {
|
||
if (!quota) return '';
|
||
if (quota.status === 'error') {
|
||
return normalizeOauthMessage(quota.lastError || quota.providerMessage || '额度刷新失败');
|
||
}
|
||
if (quota.status === 'unsupported') {
|
||
return normalizeOauthMessage(quota.providerMessage || '当前连接暂不支持额度窗口');
|
||
}
|
||
return '';
|
||
}
|
||
|
||
function resolveTokenExpiryMillis(connection: OAuthConnectionInfo): number | null {
|
||
const rawMillis = Number(connection.tokenExpiresAt);
|
||
if (Number.isFinite(rawMillis) && rawMillis > 0) return rawMillis;
|
||
const parsed = Date.parse(asTrimmedString(connection.expiresAt));
|
||
return Number.isFinite(parsed) && parsed > 0 ? parsed : null;
|
||
}
|
||
|
||
function resolveTokenFilterValue(connection: OAuthConnectionInfo): Exclude<TokenFilter, ''> {
|
||
const expiresAt = resolveTokenExpiryMillis(connection);
|
||
if (!expiresAt) return 'unknown';
|
||
const remainingMs = expiresAt - Date.now();
|
||
if (remainingMs <= 0) return 'expired';
|
||
if (remainingMs <= 5 * 60 * 1000) return 'expiring';
|
||
return 'valid';
|
||
}
|
||
|
||
function resolveTokenExpiryStatusText(connection: OAuthConnectionInfo): string {
|
||
const tokenStatus = resolveTokenFilterValue(connection);
|
||
if (tokenStatus === 'unknown') return 'Token 未知';
|
||
if (tokenStatus === 'expired') return 'Token 已过期';
|
||
if (tokenStatus === 'expiring') return 'Token 即将到期';
|
||
const expiresAt = resolveTokenExpiryMillis(connection);
|
||
if (!expiresAt) return '';
|
||
return `Token 到期:${new Date(expiresAt).toLocaleString()}`;
|
||
}
|
||
|
||
function resolveTokenStatusClassName(connection: OAuthConnectionInfo): string {
|
||
return `oauth-token-status oauth-token-status-${resolveTokenFilterValue(connection)}`;
|
||
}
|
||
|
||
function redactProxyUrl(value: string | null | undefined): string {
|
||
const text = asTrimmedString(value);
|
||
if (!text) return '';
|
||
try {
|
||
const parsed = new URL(text);
|
||
if (parsed.username || parsed.password) {
|
||
parsed.username = '***';
|
||
parsed.password = '';
|
||
}
|
||
const serialized = parsed.toString();
|
||
return parsed.pathname === '/' && !parsed.search && !parsed.hash
|
||
? serialized.replace(/\/$/, '')
|
||
: serialized;
|
||
} catch {
|
||
return text.replace(/\/\/[^/@:\s]+(?::[^/@\s]*)?@/, '//***@');
|
||
}
|
||
}
|
||
|
||
function compactAccountKey(value?: string | null): string {
|
||
const text = asTrimmedString(value || '');
|
||
if (!text || text.length <= 24) return text;
|
||
return `${text.slice(0, 8)}...${text.slice(-6)}`;
|
||
}
|
||
|
||
function formatResetLabel(value?: string | null): string {
|
||
const text = asTrimmedString(value || '');
|
||
if (!text) return '';
|
||
const date = new Date(text);
|
||
if (Number.isNaN(date.getTime())) return text;
|
||
const diffMs = date.getTime() - Date.now();
|
||
if (diffMs <= 0) return '现在';
|
||
const diffHours = Math.floor(diffMs / (1000 * 60 * 60));
|
||
const diffMinutes = Math.floor((diffMs % (1000 * 60 * 60)) / (1000 * 60));
|
||
if (diffHours >= 24) {
|
||
const days = Math.floor(diffHours / 24);
|
||
return `${days}d ${diffHours % 24}h`;
|
||
}
|
||
if (diffHours > 0) return `${diffHours}h ${diffMinutes}m`;
|
||
return `${Math.max(1, diffMinutes)}m`;
|
||
}
|
||
|
||
function resolveQuotaWindowPercent(window?: OAuthQuotaWindowInfo | null): number | null {
|
||
if (!window?.supported) return null;
|
||
if (typeof window.used === 'number' && typeof window.limit === 'number' && window.limit > 0) {
|
||
return Math.max(0, Math.min(100, Math.round((window.used / window.limit) * 100)));
|
||
}
|
||
if (typeof window.remaining === 'number' && typeof window.limit === 'number' && window.limit > 0) {
|
||
return Math.max(0, Math.min(100, Math.round(((window.limit - window.remaining) / window.limit) * 100)));
|
||
}
|
||
return null;
|
||
}
|
||
|
||
function resolveQuotaWindowSummary(window?: OAuthQuotaWindowInfo | null): string {
|
||
if (!window || !window.supported) return '';
|
||
if (typeof window.used === 'number' && typeof window.limit === 'number') {
|
||
return `${window.used} / ${window.limit}`;
|
||
}
|
||
if (typeof window.remaining === 'number' && typeof window.limit === 'number') {
|
||
return `剩余 ${window.remaining} / ${window.limit}`;
|
||
}
|
||
if (typeof window.limit === 'number') return `总量 ${window.limit}`;
|
||
return window.message || '官方未提供';
|
||
}
|
||
|
||
function resolveProxyProjectSummary(connection: OAuthConnectionInfo): string {
|
||
const parts = [
|
||
asTrimmedString(connection.planType || ''),
|
||
connection.projectId ? `Project ${connection.projectId}` : '',
|
||
].filter(Boolean);
|
||
return parts.join(' · ') || '--';
|
||
}
|
||
|
||
function resolveProxyDisplayText(connection: OAuthConnectionInfo): string {
|
||
if (connection.useSystemProxy) return '系统级代理';
|
||
if (connection.proxyUrl) return redactProxyUrl(connection.proxyUrl);
|
||
return '未设置代理';
|
||
}
|
||
|
||
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>
|
||
);
|
||
}
|
||
|
||
function renderGuideCard(title: string, description: string, children?: ReactNode) {
|
||
return (
|
||
<div className="oauth-guide-card">
|
||
<div>
|
||
<div className="oauth-guide-title">{title}</div>
|
||
<div className="oauth-guide-copy">{description}</div>
|
||
</div>
|
||
{children}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function QuotaWindowRow({
|
||
label,
|
||
window,
|
||
}: {
|
||
label: string;
|
||
window?: OAuthQuotaWindowInfo | null;
|
||
}) {
|
||
const percent = resolveQuotaWindowPercent(window);
|
||
const summary = resolveQuotaWindowSummary(window);
|
||
const tone = percent != null && percent >= 90
|
||
? 'var(--color-danger)'
|
||
: percent != null && percent >= 70
|
||
? 'var(--color-warning)'
|
||
: 'var(--color-primary)';
|
||
|
||
return (
|
||
<div className="oauth-window-row">
|
||
<div className="oauth-window-row-header">
|
||
<span className="oauth-window-pill">{label}</span>
|
||
<div className="oauth-window-meter">
|
||
<div
|
||
className="oauth-window-meter-fill"
|
||
style={{
|
||
width: `${percent ?? 0}%`,
|
||
background: percent == null ? 'var(--color-border)' : tone,
|
||
}}
|
||
/>
|
||
</div>
|
||
<span className="oauth-window-value">{percent == null ? 'N/A' : `${percent}%`}</span>
|
||
{summary && percent == null ? <span className="oauth-window-summary">{summary}</span> : null}
|
||
{window?.resetAt ? (
|
||
<span className="oauth-window-reset">重置 {formatResetLabel(window.resetAt)}</span>
|
||
) : null}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function SideDrawer({
|
||
open,
|
||
onClose,
|
||
title,
|
||
children,
|
||
}: {
|
||
open: boolean;
|
||
onClose: () => void;
|
||
title: ReactNode;
|
||
children: ReactNode;
|
||
}) {
|
||
const presence = useAnimatedVisibility(open, 220);
|
||
const titleId = useId();
|
||
const panelRef = useRef<HTMLDivElement | null>(null);
|
||
const lastActiveElementRef = useRef<HTMLElement | null>(null);
|
||
|
||
useEffect(() => {
|
||
if (!open || typeof document === 'undefined') return;
|
||
const previousOverflow = document.body.style.overflow;
|
||
document.body.style.overflow = 'hidden';
|
||
return () => {
|
||
document.body.style.overflow = previousOverflow;
|
||
};
|
||
}, [open]);
|
||
|
||
useEffect(() => {
|
||
if (open) {
|
||
const activeElement = typeof document !== 'undefined' ? document.activeElement : null;
|
||
lastActiveElementRef.current = typeof HTMLElement !== 'undefined' && activeElement instanceof HTMLElement
|
||
? activeElement
|
||
: null;
|
||
const focusPanel = () => {
|
||
const panel = panelRef.current;
|
||
if (!panel) return;
|
||
const currentActiveElement = typeof document !== 'undefined' ? document.activeElement : null;
|
||
if (typeof Node !== 'undefined' && currentActiveElement instanceof Node && panel.contains(currentActiveElement)) return;
|
||
panel.focus({ preventScroll: true });
|
||
};
|
||
if (typeof window !== 'undefined' && typeof window.requestAnimationFrame === 'function') {
|
||
const animationFrame = window.requestAnimationFrame(focusPanel);
|
||
return () => {
|
||
if (typeof window.cancelAnimationFrame === 'function') {
|
||
window.cancelAnimationFrame(animationFrame);
|
||
}
|
||
};
|
||
}
|
||
focusPanel();
|
||
return undefined;
|
||
}
|
||
|
||
if (lastActiveElementRef.current?.isConnected !== false) {
|
||
lastActiveElementRef.current?.focus({ preventScroll: true });
|
||
}
|
||
lastActiveElementRef.current = null;
|
||
return undefined;
|
||
}, [open]);
|
||
|
||
useEffect(() => () => {
|
||
if (lastActiveElementRef.current?.isConnected !== false) {
|
||
lastActiveElementRef.current?.focus({ preventScroll: true });
|
||
}
|
||
lastActiveElementRef.current = null;
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
if (!open || typeof document === 'undefined') return;
|
||
const handleKeydown = (event: globalThis.KeyboardEvent) => {
|
||
if (event.key === 'Escape') {
|
||
onClose();
|
||
return;
|
||
}
|
||
trapFocusInContainer(event, panelRef.current);
|
||
};
|
||
document.addEventListener('keydown', handleKeydown);
|
||
return () => {
|
||
document.removeEventListener('keydown', handleKeydown);
|
||
};
|
||
}, [onClose, open]);
|
||
|
||
if (!presence.shouldRender) return null;
|
||
|
||
const interactive = open && presence.isVisible;
|
||
const panel = (
|
||
<div
|
||
className={`modal-backdrop oauth-drawer-backdrop ${presence.isVisible ? '' : 'is-closing'}`.trim()}
|
||
onClick={onClose}
|
||
>
|
||
<div
|
||
ref={panelRef}
|
||
className={`modal-content oauth-drawer-content ${presence.isVisible ? '' : 'is-closing'}`.trim()}
|
||
onClick={(event) => event.stopPropagation()}
|
||
role="dialog"
|
||
aria-modal="true"
|
||
aria-labelledby={titleId}
|
||
aria-hidden={!interactive}
|
||
tabIndex={-1}
|
||
>
|
||
<div className="modal-header oauth-drawer-header">
|
||
<div className="modal-title" id={titleId}>{title}</div>
|
||
<button
|
||
type="button"
|
||
className="modal-close-button oauth-drawer-close"
|
||
onClick={onClose}
|
||
aria-label="关闭 OAuth 抽屉"
|
||
tabIndex={interactive ? 0 : -1}
|
||
>
|
||
×
|
||
</button>
|
||
</div>
|
||
<div className="modal-body oauth-drawer-body">
|
||
{children}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
|
||
const portalTarget = typeof document !== 'undefined' ? document.body : null;
|
||
return portalTarget ? createPortal(panel, portalTarget) : panel;
|
||
}
|
||
|
||
export default function OAuthManagement() {
|
||
const location = useLocation();
|
||
const isMobile = useIsMobile();
|
||
const toast = useToast();
|
||
const createIntentHandledRef = useRef(false);
|
||
const mountedRef = useRef(true);
|
||
const connectionsLoadSeqRef = useRef(0);
|
||
const modelsModalRequestSeqRef = useRef(0);
|
||
const [providers, setProviders] = useState<OAuthProviderInfo[]>([]);
|
||
const [connections, setConnections] = useState<OAuthConnectionInfo[]>([]);
|
||
const [loaded, setLoaded] = useState(false);
|
||
const [sessionFeedback, setSessionFeedback] = useState<SessionFeedback | null>(null);
|
||
const [actionLoadingKey, setActionLoadingKey] = useState('');
|
||
const [selectedConnectionIds, setSelectedConnectionIds] = useState<number[]>([]);
|
||
const [showMobileFilters, setShowMobileFilters] = useState(false);
|
||
const [searchQuery, setSearchQuery] = useState('');
|
||
const [providerFilter, setProviderFilter] = useState('');
|
||
const [statusFilter, setStatusFilter] = useState('');
|
||
const [siteFilter, setSiteFilter] = useState('');
|
||
const [tokenFilter, setTokenFilter] = useState<TokenFilter>('');
|
||
const [visibleColumns, setVisibleColumns] = useState<Record<ColumnKey, boolean>>({
|
||
identity: true,
|
||
site: true,
|
||
status: true,
|
||
quota: true,
|
||
proxy: true,
|
||
});
|
||
const [showColumnMenu, setShowColumnMenu] = useState(false);
|
||
const columnMenuAnchorRef = useRef<HTMLDivElement | null>(null);
|
||
const columnMenuButtonRef = useRef<HTMLButtonElement | null>(null);
|
||
const columnMenuRef = useRef<HTMLDivElement | null>(null);
|
||
const columnMenuFocusEdgeRef = useRef<ColumnMenuFocusEdge>('first');
|
||
const columnMenuId = useId();
|
||
const columnMenuPresence = useAnimatedVisibility(showColumnMenu, 140);
|
||
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('');
|
||
const [drawerProjectId, setDrawerProjectId] = useState('');
|
||
const [activeSession, setActiveSession] = useState<ActiveSession | null>(null);
|
||
const [manualCallbackVisible, setManualCallbackVisible] = useState(false);
|
||
const [manualCallbackUrl, setManualCallbackUrl] = useState('');
|
||
const [manualCallbackSubmitting, setManualCallbackSubmitting] = useState(false);
|
||
const [oauthCustomProxyEnabled, setOauthCustomProxyEnabled] = useState(false);
|
||
const [oauthSystemProxyEnabled, setOauthSystemProxyEnabled] = useState(false);
|
||
const [oauthProxyUrl, setOauthProxyUrl] = useState('');
|
||
const [modelsModal, setModelsModal] = useState<OAuthModelsModalState>({
|
||
open: false,
|
||
loading: false,
|
||
refreshing: false,
|
||
connection: null,
|
||
models: [],
|
||
totalCount: 0,
|
||
disabledCount: 0,
|
||
siteName: '',
|
||
});
|
||
const [routeUnitModal, setRouteUnitModal] = useState<OAuthRouteUnitModalState>({
|
||
open: false,
|
||
name: '',
|
||
strategy: 'round_robin',
|
||
});
|
||
|
||
const setSessionMessage = useCallback((
|
||
message: string,
|
||
tone: SessionFeedback['tone'],
|
||
options?: {
|
||
routeUnit?: SessionRouteUnitFeedback | null;
|
||
},
|
||
) => {
|
||
setSessionFeedback({
|
||
message,
|
||
tone,
|
||
routeUnit: options?.routeUnit ?? null,
|
||
});
|
||
}, []);
|
||
|
||
const setSessionInfo = useCallback((
|
||
message: string,
|
||
options?: {
|
||
routeUnit?: SessionRouteUnitFeedback | null;
|
||
},
|
||
) => {
|
||
setSessionMessage(message, 'info', options);
|
||
}, [setSessionMessage]);
|
||
|
||
const setSessionSuccess = useCallback((
|
||
message: string,
|
||
options?: {
|
||
routeUnit?: SessionRouteUnitFeedback | null;
|
||
},
|
||
) => {
|
||
setSessionMessage(message, 'success', options);
|
||
}, [setSessionMessage]);
|
||
|
||
const setSessionError = useCallback((
|
||
message: string,
|
||
options?: {
|
||
routeUnit?: SessionRouteUnitFeedback | null;
|
||
},
|
||
) => {
|
||
setSessionMessage(message, 'error', options);
|
||
}, [setSessionMessage]);
|
||
|
||
const focusColumnMenuItem = useCallback((edge: ColumnMenuFocusEdge = 'first') => {
|
||
const focusItem = () => {
|
||
const items = columnMenuRef.current?.querySelectorAll<HTMLInputElement>('input[type="checkbox"]');
|
||
if (!items?.length) return;
|
||
const target = edge === 'last' ? items[items.length - 1] : items[0];
|
||
target?.focus({ preventScroll: true });
|
||
};
|
||
|
||
if (typeof window !== 'undefined' && typeof window.requestAnimationFrame === 'function') {
|
||
window.requestAnimationFrame(focusItem);
|
||
return;
|
||
}
|
||
|
||
focusItem();
|
||
}, []);
|
||
|
||
const openColumnMenu = useCallback((edge: ColumnMenuFocusEdge = 'first') => {
|
||
columnMenuFocusEdgeRef.current = edge;
|
||
setShowColumnMenu(true);
|
||
}, []);
|
||
|
||
const closeColumnMenu = useCallback((options?: { restoreFocus?: boolean }) => {
|
||
setShowColumnMenu(false);
|
||
if (options?.restoreFocus) {
|
||
columnMenuButtonRef.current?.focus({ preventScroll: true });
|
||
}
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
if (!showColumnMenu) return undefined;
|
||
|
||
const handlePointerDown = (event: MouseEvent) => {
|
||
const target = event.target;
|
||
if (
|
||
typeof Node !== 'undefined'
|
||
&& target instanceof Node
|
||
&& columnMenuAnchorRef.current?.contains(target)
|
||
) {
|
||
return;
|
||
}
|
||
closeColumnMenu();
|
||
};
|
||
|
||
const handleKeyDown = (event: KeyboardEvent) => {
|
||
if (event.key !== 'Escape') return;
|
||
event.preventDefault();
|
||
closeColumnMenu({ restoreFocus: true });
|
||
};
|
||
|
||
document.addEventListener('mousedown', handlePointerDown);
|
||
document.addEventListener('keydown', handleKeyDown);
|
||
return () => {
|
||
document.removeEventListener('mousedown', handlePointerDown);
|
||
document.removeEventListener('keydown', handleKeyDown);
|
||
};
|
||
}, [closeColumnMenu, showColumnMenu]);
|
||
|
||
useEffect(() => {
|
||
if (!showColumnMenu || !columnMenuPresence.shouldRender || !columnMenuPresence.isVisible) return;
|
||
focusColumnMenuItem(columnMenuFocusEdgeRef.current);
|
||
}, [
|
||
columnMenuPresence.isVisible,
|
||
columnMenuPresence.shouldRender,
|
||
focusColumnMenuItem,
|
||
showColumnMenu,
|
||
]);
|
||
|
||
const resetOauthProxySettings = useCallback(() => {
|
||
setOauthCustomProxyEnabled(false);
|
||
setOauthSystemProxyEnabled(false);
|
||
setOauthProxyUrl('');
|
||
}, []);
|
||
|
||
const resetImportProxySettings = useCallback((defaultToSystem = false) => {
|
||
setImportCustomProxyEnabled(false);
|
||
setImportSystemProxyEnabled(defaultToSystem);
|
||
setImportProxyUrl('');
|
||
}, []);
|
||
|
||
const resetImportState = useCallback(() => {
|
||
setImportJsonText('');
|
||
setImportDrafts([]);
|
||
setImportDragOver(false);
|
||
}, []);
|
||
|
||
const closeImportModal = useCallback(() => {
|
||
setImportOpen(false);
|
||
resetImportState();
|
||
resetImportProxySettings(false);
|
||
}, [resetImportProxySettings, resetImportState]);
|
||
|
||
const openImportModal = useCallback(() => {
|
||
resetImportState();
|
||
resetImportProxySettings(runtimeSystemProxyConfigured);
|
||
setImportOpen(true);
|
||
}, [resetImportProxySettings, resetImportState, runtimeSystemProxyConfigured]);
|
||
|
||
useEffect(() => () => {
|
||
mountedRef.current = false;
|
||
connectionsLoadSeqRef.current += 1;
|
||
modelsModalRequestSeqRef.current += 1;
|
||
}, []);
|
||
|
||
const loadConnections = useCallback(async () => {
|
||
const requestSeq = ++connectionsLoadSeqRef.current;
|
||
const response = await api.getOAuthConnections({
|
||
limit: CONNECTION_PAGE_LIMIT,
|
||
offset: 0,
|
||
});
|
||
const nextItems = Array.isArray(response?.items) ? response.items : [];
|
||
if (!mountedRef.current || connectionsLoadSeqRef.current !== requestSeq) return nextItems;
|
||
setConnections(nextItems);
|
||
setSelectedConnectionIds((current) => current.filter((id) => nextItems.some((item) => item.accountId === id)));
|
||
return nextItems;
|
||
}, []);
|
||
|
||
const load = useCallback(async () => {
|
||
try {
|
||
const [providersResponse] = await Promise.all([
|
||
api.getOAuthProviders(),
|
||
loadConnections(),
|
||
]);
|
||
const nextProviders = Array.from(
|
||
new Map(
|
||
(Array.isArray(providersResponse?.providers) ? providersResponse.providers : [])
|
||
.map((provider) => normalizeOAuthProvider(provider as OAuthProviderWireInfo))
|
||
.filter((provider): provider is OAuthProviderInfo => provider != null)
|
||
.map((provider) => [provider.provider, provider]),
|
||
).values(),
|
||
);
|
||
if (!mountedRef.current) return;
|
||
setRuntimeSystemProxyConfigured(providersResponse?.defaults?.systemProxyConfigured === true);
|
||
setProviders(nextProviders);
|
||
setSelectedProviderKey((current) => current || nextProviders[0]?.provider || '');
|
||
} catch (error: any) {
|
||
if (!mountedRef.current) return;
|
||
console.error('failed to load oauth management data', error);
|
||
setSessionError(error?.message || 'OAuth 管理数据加载失败');
|
||
} finally {
|
||
if (!mountedRef.current) return;
|
||
setLoaded(true);
|
||
}
|
||
}, [loadConnections]);
|
||
|
||
useEffect(() => {
|
||
void load();
|
||
}, [load]);
|
||
|
||
useEffect(() => {
|
||
if (autoRefreshSeconds <= 0) {
|
||
setAutoRefreshCountdown(0);
|
||
return undefined;
|
||
}
|
||
|
||
setAutoRefreshCountdown(autoRefreshSeconds);
|
||
const timer = setInterval(() => {
|
||
setAutoRefreshCountdown((current) => {
|
||
if (current <= 1) {
|
||
void loadConnections().catch((error: any) => {
|
||
if (!mountedRef.current) return;
|
||
setSessionError(error?.message || 'OAuth 连接列表刷新失败');
|
||
});
|
||
return autoRefreshSeconds;
|
||
}
|
||
return current - 1;
|
||
});
|
||
}, 1000);
|
||
|
||
return () => clearInterval(timer);
|
||
}, [autoRefreshSeconds, loadConnections]);
|
||
|
||
useEffect(() => {
|
||
if (!loaded || providers.length === 0 || createIntentHandledRef.current) return;
|
||
const params = new URLSearchParams(location.search);
|
||
if (params.get('create') !== '1') return;
|
||
|
||
createIntentHandledRef.current = true;
|
||
const provider = asTrimmedString(params.get('provider')) || providers[0]?.provider || '';
|
||
setDrawerIntent({ mode: 'create', provider });
|
||
setSelectedProviderKey(provider);
|
||
setDrawerProjectId('');
|
||
setDrawerOpen(true);
|
||
setSessionInfo('从建站流程跳转到 OAuth 管理,请在这里完成授权。');
|
||
}, [loaded, location.search, providers]);
|
||
|
||
useEffect(() => {
|
||
if (!activeSession) return undefined;
|
||
|
||
let cancelled = false;
|
||
let timer: ReturnType<typeof setTimeout> | null = null;
|
||
|
||
const poll = async () => {
|
||
try {
|
||
const session = await api.getOAuthSession(activeSession.state);
|
||
if (cancelled || !mountedRef.current) return;
|
||
|
||
if (session.status === 'pending') {
|
||
setSessionInfo('等待授权完成');
|
||
timer = setTimeout(poll, POLL_INTERVAL_MS);
|
||
return;
|
||
}
|
||
|
||
if (session.status === 'success') {
|
||
setSessionSuccess('授权成功');
|
||
await loadConnections();
|
||
if (cancelled || !mountedRef.current) return;
|
||
setActiveSession(null);
|
||
return;
|
||
}
|
||
|
||
setSessionError(`授权失败:${session.error || '未知错误'}`);
|
||
setActiveSession(null);
|
||
} catch (error: any) {
|
||
if (cancelled || !mountedRef.current) return;
|
||
setSessionError(error?.message || 'OAuth 会话状态查询失败');
|
||
setActiveSession(null);
|
||
}
|
||
};
|
||
|
||
void poll();
|
||
return () => {
|
||
cancelled = true;
|
||
if (timer) clearTimeout(timer);
|
||
};
|
||
}, [activeSession, loadConnections]);
|
||
|
||
useEffect(() => {
|
||
if (!activeSession) {
|
||
setManualCallbackVisible(false);
|
||
setManualCallbackUrl('');
|
||
setManualCallbackSubmitting(false);
|
||
return undefined;
|
||
}
|
||
|
||
setManualCallbackVisible(false);
|
||
setManualCallbackUrl('');
|
||
setManualCallbackSubmitting(false);
|
||
|
||
const timer = setTimeout(() => {
|
||
setManualCallbackVisible(true);
|
||
}, Math.max(0, activeSession.instructions.manualCallbackDelayMs || 0));
|
||
|
||
return () => clearTimeout(timer);
|
||
}, [activeSession]);
|
||
|
||
const providerOptions = useMemo(
|
||
() => providers.flatMap((provider) => {
|
||
const value = asTrimmedString(provider.provider);
|
||
if (!value) return [];
|
||
const label = asTrimmedString(provider.label) || resolveOAuthProviderFallbackLabel(value);
|
||
const platform = asTrimmedString(provider.platform) || value;
|
||
return [{
|
||
value,
|
||
label,
|
||
description: `${platform}${provider.requiresProjectId ? ' · 可选 Project ID' : ''}${!provider.enabled ? ' · 当前环境未启用' : ''}`,
|
||
}];
|
||
}),
|
||
[providers],
|
||
);
|
||
|
||
const selectedProvider = useMemo(
|
||
() => providers.find((provider) => provider.provider === selectedProviderKey) || null,
|
||
[providers, selectedProviderKey],
|
||
);
|
||
const hasProviderOptions = providerOptions.length > 0;
|
||
|
||
const siteOptions = useMemo(() => {
|
||
const seen = new Map<string, string>();
|
||
connections.forEach((connection) => {
|
||
const id = String(connection.site?.id || '');
|
||
const label = asTrimmedString(connection.site?.name) || asTrimmedString(connection.site?.url);
|
||
if (id && label && !seen.has(id)) {
|
||
seen.set(id, label);
|
||
}
|
||
});
|
||
return Array.from(seen.entries()).map(([value, label]) => ({ value, label }));
|
||
}, [connections]);
|
||
|
||
const tokenStatusCounts = useMemo(() => {
|
||
const counts: Record<Exclude<TokenFilter, ''>, number> = {
|
||
valid: 0,
|
||
expiring: 0,
|
||
expired: 0,
|
||
unknown: 0,
|
||
};
|
||
connections.forEach((connection) => {
|
||
counts[resolveTokenFilterValue(connection)] += 1;
|
||
});
|
||
return counts;
|
||
}, [connections]);
|
||
|
||
const tokenFilterOptions = useMemo(
|
||
() => TOKEN_FILTER_OPTIONS.map((option) => ({
|
||
...option,
|
||
label: option.value
|
||
? formatCountedLabel(option.label, tokenStatusCounts[option.value])
|
||
: formatCountedLabel(option.label, connections.length),
|
||
})),
|
||
[connections.length, tokenStatusCounts],
|
||
);
|
||
|
||
const tokenRiskSummaryItems = useMemo(() => {
|
||
const items: Array<{ value: Exclude<TokenFilter, ''>; label: string; count: number; badgeClass: string }> = [];
|
||
if (tokenStatusCounts.expired > 0) {
|
||
items.push({ value: 'expired', label: 'Token 已过期', count: tokenStatusCounts.expired, badgeClass: 'badge-danger' });
|
||
}
|
||
if (tokenStatusCounts.expiring > 0) {
|
||
items.push({ value: 'expiring', label: '即将到期', count: tokenStatusCounts.expiring, badgeClass: 'badge-warning' });
|
||
}
|
||
if (tokenStatusCounts.unknown > 0) {
|
||
items.push({ value: 'unknown', label: '未知', count: tokenStatusCounts.unknown, badgeClass: 'badge-muted' });
|
||
}
|
||
return items;
|
||
}, [tokenStatusCounts]);
|
||
|
||
const applyTokenRiskFilter = useCallback((value: Exclude<TokenFilter, ''>) => {
|
||
setSearchQuery('');
|
||
setTokenFilter((current) => (current === value ? '' : value));
|
||
}, []);
|
||
|
||
const hasActiveFilters = Boolean(
|
||
searchQuery.trim()
|
||
|| providerFilter
|
||
|| statusFilter
|
||
|| siteFilter
|
||
|| tokenFilter,
|
||
);
|
||
|
||
const clearFilters = useCallback(() => {
|
||
setSearchQuery('');
|
||
setProviderFilter('');
|
||
setStatusFilter('');
|
||
setSiteFilter('');
|
||
setTokenFilter('');
|
||
}, []);
|
||
|
||
const filteredConnections = useMemo(() => {
|
||
const search = searchQuery.trim().toLowerCase();
|
||
return connections.filter((connection) => {
|
||
if (providerFilter && connection.provider !== providerFilter) return false;
|
||
if (statusFilter && connection.status !== statusFilter) return false;
|
||
if (siteFilter && String(connection.site?.id || '') !== siteFilter) return false;
|
||
if (tokenFilter && resolveTokenFilterValue(connection) !== tokenFilter) return false;
|
||
if (!search) return true;
|
||
const haystack = [
|
||
resolveConnectionPrimaryTitle(connection),
|
||
resolveConnectionEmailLabel(connection),
|
||
connection.provider,
|
||
connection.site?.name,
|
||
connection.site?.url,
|
||
connection.accountKey,
|
||
connection.projectId,
|
||
connection.modelsPreview.join(' '),
|
||
resolveTokenExpiryStatusText(connection),
|
||
].join(' ').toLowerCase();
|
||
return haystack.includes(search);
|
||
});
|
||
}, [connections, providerFilter, searchQuery, siteFilter, statusFilter, tokenFilter]);
|
||
|
||
const allVisibleSelected = filteredConnections.length > 0
|
||
&& filteredConnections.every((connection) => selectedConnectionIds.includes(connection.accountId));
|
||
|
||
const visibleSelectedCount = useMemo(
|
||
() => filteredConnections.filter((connection) => selectedConnectionIds.includes(connection.accountId)).length,
|
||
[filteredConnections, selectedConnectionIds],
|
||
);
|
||
const hiddenSelectedCount = Math.max(0, selectedConnectionIds.length - visibleSelectedCount);
|
||
|
||
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 || '');
|
||
setDrawerProjectId('');
|
||
resetOauthProxySettings();
|
||
setDrawerOpen(true);
|
||
setShowColumnMenu(false);
|
||
};
|
||
|
||
const openRebindDrawer = (connection: OAuthConnectionInfo) => {
|
||
setDrawerIntent({ mode: 'rebind', account: connection });
|
||
setSelectedProviderKey(connection.provider);
|
||
setDrawerProjectId(connection.projectId || '');
|
||
resetOauthProxySettings();
|
||
setDrawerOpen(true);
|
||
setShowColumnMenu(false);
|
||
};
|
||
|
||
const openProxySettingsDrawer = (connection: OAuthConnectionInfo) => {
|
||
setDrawerIntent({ mode: 'proxy', account: connection });
|
||
setSelectedProviderKey(connection.provider);
|
||
setDrawerProjectId(connection.projectId || '');
|
||
setOauthSystemProxyEnabled(connection.useSystemProxy === true);
|
||
setOauthCustomProxyEnabled(connection.useSystemProxy !== true && !!asTrimmedString(connection.proxyUrl));
|
||
setOauthProxyUrl(connection.useSystemProxy ? '' : asTrimmedString(connection.proxyUrl));
|
||
setDrawerOpen(true);
|
||
setShowColumnMenu(false);
|
||
setSessionInfo('已打开 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) {
|
||
setSessionError('已开启代理,请先输入完整代理地址');
|
||
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();
|
||
setSessionSuccess('代理已保存');
|
||
} catch (error: any) {
|
||
setSessionError(error?.message || '保存代理失败');
|
||
} finally {
|
||
setActionLoadingKey('');
|
||
}
|
||
};
|
||
|
||
const handleStart = async () => {
|
||
const provider = selectedProvider
|
||
|| (drawerIntent.mode === 'create'
|
||
? providers.find((item) => item.provider === drawerIntent.provider)
|
||
: null)
|
||
|| providers[0]
|
||
|| null;
|
||
if (!provider) {
|
||
setSessionError('当前没有可用的 OAuth Provider。');
|
||
return;
|
||
}
|
||
if (!provider.enabled) {
|
||
setSessionError(`${provider.label} 当前环境未启用`);
|
||
return;
|
||
}
|
||
|
||
const rebindAccount = drawerIntent.mode === 'create' ? null : drawerIntent.account;
|
||
const accountId = rebindAccount?.accountId;
|
||
const customProxyUrl = asTrimmedString(oauthProxyUrl);
|
||
if (oauthCustomProxyEnabled && !customProxyUrl) {
|
||
setSessionError('已开启代理,请先输入完整代理地址');
|
||
return;
|
||
}
|
||
|
||
const proxySettings = resolveProxySettingsPayload({
|
||
customEnabled: oauthCustomProxyEnabled,
|
||
systemEnabled: oauthSystemProxyEnabled,
|
||
proxyValue: oauthProxyUrl,
|
||
fallbackAccount: rebindAccount,
|
||
clearToSiteFallback: drawerIntent.mode === 'proxy',
|
||
});
|
||
|
||
const actionKey = `start:${provider.provider}:${accountId || 0}`;
|
||
setActionLoadingKey(actionKey);
|
||
try {
|
||
const projectId = drawerIntent.mode === 'create' && provider.requiresProjectId
|
||
? (asTrimmedString(drawerProjectId) || undefined)
|
||
: undefined;
|
||
const started = accountId
|
||
? await api.rebindOAuthConnection(
|
||
accountId,
|
||
{
|
||
...proxySettings,
|
||
},
|
||
)
|
||
: await api.startOAuthProvider(provider.provider, {
|
||
projectId,
|
||
...proxySettings,
|
||
});
|
||
|
||
setSessionInfo('等待授权完成');
|
||
setActiveSession({
|
||
provider: started.provider,
|
||
state: started.state,
|
||
authorizationUrl: started.authorizationUrl,
|
||
instructions: started.instructions,
|
||
});
|
||
resetOauthProxySettings();
|
||
openOAuthPopup(provider.provider, started.authorizationUrl);
|
||
} catch (error: any) {
|
||
if (isOAuthDirectFlowUnavailable(error)) {
|
||
if (drawerIntent.mode === 'proxy' && accountId) {
|
||
await api.updateOAuthConnectionProxy(accountId, proxySettings);
|
||
await loadConnections();
|
||
}
|
||
setDrawerOpen(false);
|
||
setActiveSession(null);
|
||
resetOauthProxySettings();
|
||
resetImportState();
|
||
resetImportProxySettings(oauthSystemProxyEnabled);
|
||
setImportCustomProxyEnabled(oauthCustomProxyEnabled);
|
||
setImportProxyUrl(oauthCustomProxyEnabled ? customProxyUrl : '');
|
||
setImportOpen(true);
|
||
setSessionInfo(drawerIntent.mode === 'proxy'
|
||
? '代理设置已保存。当前环境未配置官方浏览器授权,请改用 JSON 导入更新 OAuth 连接。'
|
||
: '当前环境未配置官方浏览器授权,请改用 JSON 导入添加 OAuth 连接。');
|
||
} else {
|
||
setSessionError(error?.message || '无法启动 OAuth 授权');
|
||
}
|
||
} finally {
|
||
setActionLoadingKey('');
|
||
}
|
||
};
|
||
|
||
const handleSubmitManualCallback = async () => {
|
||
if (!activeSession) return;
|
||
const callbackUrl = manualCallbackUrl.trim();
|
||
if (!callbackUrl) {
|
||
setSessionError('请输入完整的回调 URL');
|
||
return;
|
||
}
|
||
setManualCallbackSubmitting(true);
|
||
try {
|
||
await api.submitOAuthManualCallback(activeSession.state, callbackUrl);
|
||
setSessionInfo('回调已提交,等待授权完成');
|
||
} catch (error: any) {
|
||
setSessionError(error?.message || '提交回调 URL 失败');
|
||
} finally {
|
||
setManualCallbackSubmitting(false);
|
||
}
|
||
};
|
||
|
||
const handleDelete = async (accountId: number) => {
|
||
if (typeof window !== 'undefined' && typeof window.confirm === 'function') {
|
||
const confirmed = window.confirm('确定要删除这个 OAuth 连接吗?');
|
||
if (!confirmed) return;
|
||
}
|
||
const actionKey = `delete:${accountId}`;
|
||
setActionLoadingKey(actionKey);
|
||
try {
|
||
await api.deleteOAuthConnection(accountId);
|
||
setSessionSuccess('连接已删除');
|
||
await loadConnections();
|
||
setSelectedConnectionIds((current) => current.filter((id) => id !== accountId));
|
||
} catch (error: any) {
|
||
setSessionError(error?.message || '删除 OAuth 连接失败');
|
||
} finally {
|
||
setActionLoadingKey('');
|
||
}
|
||
};
|
||
|
||
const handleDeleteSelected = async () => {
|
||
if (selectedConnectionIds.length === 0) return;
|
||
if (typeof window !== 'undefined' && typeof window.confirm === 'function') {
|
||
const hiddenNote = hiddenSelectedCount > 0
|
||
? ` 其中 ${hiddenSelectedCount} 个当前被筛选隐藏。`
|
||
: '';
|
||
const confirmed = window.confirm(`确定要删除选中的 ${selectedConnectionIds.length} 个 OAuth 连接吗?${hiddenNote}`);
|
||
if (!confirmed) return;
|
||
}
|
||
setActionLoadingKey('delete:selected');
|
||
try {
|
||
const results = await Promise.allSettled(selectedConnectionIds.map((accountId) => api.deleteOAuthConnection(accountId)));
|
||
const failed = results.filter((item) => item.status === 'rejected').length;
|
||
await loadConnections();
|
||
setSelectedConnectionIds([]);
|
||
if (failed > 0) {
|
||
setSessionInfo(`批量删除完成,${failed} 个连接删除失败`);
|
||
} else {
|
||
setSessionSuccess(`已删除 ${results.length} 个 OAuth 连接`);
|
||
}
|
||
} finally {
|
||
setActionLoadingKey('');
|
||
}
|
||
};
|
||
|
||
const handleRefreshQuota = async (accountId: number) => {
|
||
const actionKey = `quota:${accountId}`;
|
||
setActionLoadingKey(actionKey);
|
||
try {
|
||
await api.refreshOAuthConnectionQuota(accountId);
|
||
setSessionSuccess('额度信息已刷新');
|
||
await loadConnections();
|
||
} catch (error: any) {
|
||
setSessionError(error?.message || '刷新额度失败');
|
||
} finally {
|
||
setActionLoadingKey('');
|
||
}
|
||
};
|
||
|
||
const handleRefreshSelected = async () => {
|
||
if (selectedConnectionIds.length === 0) return;
|
||
setActionLoadingKey('quota:selected');
|
||
try {
|
||
const result = await api.refreshOAuthConnectionQuotaBatch(selectedConnectionIds);
|
||
await loadConnections();
|
||
if (result.failed > 0) {
|
||
setSessionInfo(`批量刷新完成,成功 ${result.refreshed} 个,失败 ${result.failed} 个`);
|
||
} else {
|
||
setSessionSuccess(`已批量刷新 ${result.refreshed} 个 OAuth 连接`);
|
||
}
|
||
} catch (error: any) {
|
||
setSessionError(error?.message || '批量刷新额度失败');
|
||
} finally {
|
||
setActionLoadingKey('');
|
||
}
|
||
};
|
||
|
||
const applyLoadedModelsModal = useCallback((connection: OAuthConnectionInfo, result: any) => {
|
||
const models = Array.isArray(result?.models) ? result.models as OAuthModelItem[] : [];
|
||
setModelsModal((current) => ({
|
||
...current,
|
||
open: true,
|
||
loading: false,
|
||
refreshing: false,
|
||
connection,
|
||
models,
|
||
totalCount: Number.isFinite(result?.totalCount) ? Number(result.totalCount) : models.length,
|
||
disabledCount: Number.isFinite(result?.disabledCount)
|
||
? Number(result.disabledCount)
|
||
: models.filter((item) => item.disabled).length,
|
||
siteName: asTrimmedString(result?.siteName) || connection.site?.name || '',
|
||
}));
|
||
}, []);
|
||
|
||
const loadModelsModal = useCallback(async (
|
||
connection: OAuthConnectionInfo,
|
||
options: {
|
||
refreshUpstream?: boolean;
|
||
resetBeforeLoad?: boolean;
|
||
closeOnError?: boolean;
|
||
} = {},
|
||
) => {
|
||
const requestId = ++modelsModalRequestSeqRef.current;
|
||
if (!mountedRef.current) return;
|
||
setModelsModal((current) => ({
|
||
...current,
|
||
open: true,
|
||
connection,
|
||
loading: options.resetBeforeLoad ? true : current.loading,
|
||
refreshing: options.refreshUpstream ? true : current.refreshing,
|
||
...(options.resetBeforeLoad
|
||
? { models: [], totalCount: 0, disabledCount: 0, siteName: connection.site?.name || '' }
|
||
: {}),
|
||
}));
|
||
|
||
try {
|
||
if (options.refreshUpstream) {
|
||
await api.checkModels(connection.accountId);
|
||
}
|
||
const result = await api.getAccountModels(connection.accountId);
|
||
if (!mountedRef.current || modelsModalRequestSeqRef.current !== requestId) return;
|
||
applyLoadedModelsModal(connection, result);
|
||
if (options.refreshUpstream) {
|
||
await loadConnections();
|
||
if (!mountedRef.current || modelsModalRequestSeqRef.current !== requestId) return;
|
||
setSessionSuccess('模型列表已刷新');
|
||
}
|
||
} catch (error: any) {
|
||
if (!mountedRef.current || modelsModalRequestSeqRef.current !== requestId) return;
|
||
setSessionError(error?.message || '加载模型列表失败');
|
||
setModelsModal((current) => (
|
||
options.closeOnError
|
||
? {
|
||
...current,
|
||
open: false,
|
||
loading: false,
|
||
refreshing: false,
|
||
connection: null,
|
||
}
|
||
: {
|
||
...current,
|
||
loading: false,
|
||
refreshing: false,
|
||
}
|
||
));
|
||
}
|
||
}, [applyLoadedModelsModal, loadConnections]);
|
||
|
||
const openModelsModal = useCallback(async (connection: OAuthConnectionInfo) => {
|
||
await loadModelsModal(connection, {
|
||
resetBeforeLoad: true,
|
||
closeOnError: true,
|
||
});
|
||
}, [loadModelsModal]);
|
||
|
||
const closeModelsModal = useCallback(() => {
|
||
modelsModalRequestSeqRef.current += 1;
|
||
setModelsModal((current) => ({
|
||
...current,
|
||
open: false,
|
||
loading: false,
|
||
refreshing: false,
|
||
connection: null,
|
||
}));
|
||
}, []);
|
||
|
||
const handleImportFilesSelected = useCallback(async (files: ArrayLike<OAuthImportFileLike> | null | undefined) => {
|
||
const drafts = await readOauthImportDrafts(files);
|
||
setImportDrafts(drafts);
|
||
}, []);
|
||
|
||
const handleImportFileChange = useCallback(async (event: ChangeEvent<HTMLInputElement>) => {
|
||
await handleImportFilesSelected(event.target.files);
|
||
event.target.value = '';
|
||
}, [handleImportFilesSelected]);
|
||
|
||
const handleImportDragOver = useCallback((event: DragEvent<HTMLDivElement>) => {
|
||
event.preventDefault();
|
||
setImportDragOver(true);
|
||
}, []);
|
||
|
||
const handleImportDragLeave = useCallback((event: DragEvent<HTMLDivElement>) => {
|
||
event.preventDefault();
|
||
setImportDragOver(false);
|
||
}, []);
|
||
|
||
const handleImportDrop = useCallback(async (event: DragEvent<HTMLDivElement>) => {
|
||
event.preventDefault();
|
||
setImportDragOver(false);
|
||
await handleImportFilesSelected(event.dataTransfer?.files);
|
||
}, [handleImportFilesSelected]);
|
||
|
||
const importSources = useMemo<OAuthImportSource[]>(() => {
|
||
const manualRaw = importJsonText.trim();
|
||
return [
|
||
...importDrafts,
|
||
...(manualRaw ? [{ sourceName: '手动粘贴 JSON', rawText: manualRaw }] : []),
|
||
];
|
||
}, [importDrafts, importJsonText]);
|
||
|
||
const importPreviewSummary = useMemo<OAuthImportPreviewSummary | null>(() => {
|
||
if (importSources.length <= 0) return null;
|
||
const items = importSources.map((source) => parseOauthImportPreview(source));
|
||
const validCount = items.filter((item) => item.valid).length;
|
||
const invalidCount = items.length - validCount;
|
||
return {
|
||
totalCount: items.length,
|
||
validCount,
|
||
invalidCount,
|
||
hasInvalidItems: invalidCount > 0,
|
||
canImport: validCount > 0,
|
||
items,
|
||
};
|
||
}, [importSources]);
|
||
|
||
const handleImport = async () => {
|
||
if (importSources.length <= 0) {
|
||
setSessionError('请先选择 JSON 文件或粘贴 OAuth 连接 JSON 内容');
|
||
return;
|
||
}
|
||
if (!importPreviewSummary?.canImport) {
|
||
setSessionError('未识别到可导入的 OAuth JSON');
|
||
return;
|
||
}
|
||
if (importCustomProxyEnabled && !asTrimmedString(importProxyUrl)) {
|
||
setSessionError('已开启代理,请先输入完整代理地址');
|
||
return;
|
||
}
|
||
|
||
setImporting(true);
|
||
try {
|
||
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,
|
||
});
|
||
const localSkippedItems: OAuthImportResponse['items'] = importPreviewSummary.items
|
||
.filter((item) => !item.valid)
|
||
.map((item) => ({
|
||
name: item.sourceName,
|
||
status: 'skipped' as const,
|
||
message: item.error || 'JSON 结构无效',
|
||
}));
|
||
const mergedResult = localSkippedItems.length > 0
|
||
? {
|
||
...result,
|
||
skipped: Number(result.skipped || 0) + localSkippedItems.length,
|
||
items: [
|
||
...localSkippedItems,
|
||
...result.items,
|
||
],
|
||
}
|
||
: result;
|
||
|
||
const changedAccountIds = mergedResult.items.flatMap((item) => {
|
||
if (item.status !== 'imported' && item.status !== 'updated') return [];
|
||
if (!('accountId' in item)) return [];
|
||
const accountId = Number(item.accountId);
|
||
return Number.isFinite(accountId) ? [accountId] : [];
|
||
});
|
||
const modelRefreshResults = changedAccountIds.length > 0
|
||
? await Promise.allSettled(changedAccountIds.map((accountId) => api.checkModels(accountId)))
|
||
: [];
|
||
const modelRefreshFailed = modelRefreshResults.filter((item) => (
|
||
item.status === 'rejected' || !isModelRefreshResultSuccessful(item.value)
|
||
)).length;
|
||
|
||
await loadConnections();
|
||
const updatedCount = Number(mergedResult.updated || 0);
|
||
const changedCount = mergedResult.imported + updatedCount;
|
||
const importMessage = formatOAuthImportMessage(mergedResult);
|
||
if (mergedResult.failed > 0) {
|
||
toast.info(importMessage);
|
||
} else {
|
||
toast.success(importMessage);
|
||
}
|
||
if (mergedResult.failed > 0) {
|
||
setSessionInfo(importMessage);
|
||
} else if (modelRefreshFailed > 0) {
|
||
setSessionInfo(`${importMessage},但 ${modelRefreshFailed} 个账号模型刷新失败,可稍后手动刷新模型`);
|
||
} else {
|
||
setSessionSuccess(changedCount > 0
|
||
? `${importMessage},模型列表已刷新`
|
||
: importMessage);
|
||
}
|
||
closeImportModal();
|
||
} catch (error: any) {
|
||
const message = error?.message || '导入 OAuth JSON 失败';
|
||
toast.error(message);
|
||
setSessionError(message);
|
||
} finally {
|
||
setImporting(false);
|
||
}
|
||
};
|
||
|
||
const handleCreateRouteUnit = async () => {
|
||
const name = asTrimmedString(routeUnitModal.name);
|
||
if (!canMergeSelectedIntoRouteUnit || selectedConnections.length < 2) return;
|
||
if (!name) {
|
||
setSessionError('请先输入路由池名称');
|
||
return;
|
||
}
|
||
|
||
setActionLoadingKey('route-unit:create');
|
||
try {
|
||
const result = await api.createOAuthRouteUnit({
|
||
accountIds: selectedConnections.map((connection) => connection.accountId),
|
||
name,
|
||
strategy: routeUnitModal.strategy,
|
||
});
|
||
const routeUnitDefaults: SessionRouteUnitFeedback = {
|
||
action: 'created' as const,
|
||
name: asTrimmedString(name) || name,
|
||
memberCount: selectedConnections.length,
|
||
strategy: routeUnitModal.strategy,
|
||
};
|
||
const routeUnit: SessionRouteUnitFeedback = result.routeUnit
|
||
? {
|
||
...routeUnitDefaults,
|
||
name: asTrimmedString(result.routeUnit.name) || routeUnitDefaults.name,
|
||
memberCount: result.routeUnit.memberCount || routeUnitDefaults.memberCount,
|
||
strategy: result.routeUnit.strategy || routeUnitDefaults.strategy,
|
||
}
|
||
: routeUnitDefaults;
|
||
toast.success(`已创建路由池:${routeUnit.name}`);
|
||
setSessionSuccess('已创建路由池', {
|
||
routeUnit,
|
||
});
|
||
setSelectedConnectionIds([]);
|
||
closeRouteUnitModal();
|
||
try {
|
||
await loadConnections();
|
||
} catch {
|
||
toast.error('OAuth 连接列表刷新失败');
|
||
setSessionError('已创建路由池,但连接列表刷新失败', {
|
||
routeUnit,
|
||
});
|
||
}
|
||
} catch (error: any) {
|
||
const message = error?.message || '创建路由池失败';
|
||
toast.error(message);
|
||
setSessionError(message);
|
||
} finally {
|
||
setActionLoadingKey('');
|
||
}
|
||
};
|
||
|
||
const handleDeleteSelectedRouteUnit = async () => {
|
||
if (!selectedRouteUnitParticipation) return;
|
||
const routeUnitId = selectedRouteUnitParticipation.routeUnitId ?? selectedRouteUnitParticipation.id;
|
||
if (!routeUnitId) return;
|
||
|
||
setActionLoadingKey('route-unit:delete');
|
||
try {
|
||
const routeUnitFeedback = {
|
||
action: 'deleted' as const,
|
||
name: selectedRouteUnitParticipation.name,
|
||
memberCount: selectedRouteUnitParticipation.memberCount,
|
||
strategy: selectedRouteUnitParticipation.strategy,
|
||
};
|
||
await api.deleteOAuthRouteUnit(routeUnitId);
|
||
toast.success(`已拆回单体:${routeUnitFeedback.name}`);
|
||
setSessionSuccess('已拆回单体', {
|
||
routeUnit: routeUnitFeedback,
|
||
});
|
||
setSelectedConnectionIds([]);
|
||
try {
|
||
await loadConnections();
|
||
} catch {
|
||
toast.error('OAuth 连接列表刷新失败');
|
||
setSessionError('已拆回单体,但连接列表刷新失败', {
|
||
routeUnit: routeUnitFeedback,
|
||
});
|
||
}
|
||
} catch (error: any) {
|
||
const message = error?.message || '拆回单体失败';
|
||
toast.error(message);
|
||
setSessionError(message);
|
||
} finally {
|
||
setActionLoadingKey('');
|
||
}
|
||
};
|
||
|
||
const toggleColumn = (key: ColumnKey) => {
|
||
setVisibleColumns((current) => ({
|
||
...current,
|
||
[key]: !current[key],
|
||
}));
|
||
};
|
||
|
||
const toggleSelectAllVisible = (checked: boolean) => {
|
||
if (!checked) {
|
||
setSelectedConnectionIds((current) => current.filter((id) => !filteredConnections.some((connection) => connection.accountId === id)));
|
||
return;
|
||
}
|
||
setSelectedConnectionIds((current) => Array.from(new Set([
|
||
...current,
|
||
...filteredConnections.map((connection) => connection.accountId),
|
||
])));
|
||
};
|
||
|
||
const keepOnlyVisibleSelected = () => {
|
||
setSelectedConnectionIds((current) => current.filter((id) => filteredConnections.some((connection) => connection.accountId === id)));
|
||
};
|
||
|
||
const filterBar = (
|
||
<div className="card oauth-toolbar-card">
|
||
<div className="toolbar oauth-toolbar">
|
||
<div className="oauth-toolbar-row">
|
||
<div className="oauth-toolbar-filters">
|
||
<div className="toolbar-search oauth-filter-slot-search">
|
||
<svg width="16" height="16" viewBox="0 0 20 20" fill="none" aria-hidden="true">
|
||
<path d="M13.5 13.5L17 17" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" />
|
||
<circle cx="8.5" cy="8.5" r="4.75" stroke="currentColor" strokeWidth="1.6" />
|
||
</svg>
|
||
<input
|
||
type="text"
|
||
value={searchQuery}
|
||
onChange={(event) => setSearchQuery(event.target.value)}
|
||
placeholder="搜索账号 / 邮箱 / 站点 / 项目 / Token"
|
||
/>
|
||
</div>
|
||
<div className="oauth-filter-slot">
|
||
<ModernSelect
|
||
size="sm"
|
||
value={providerFilter}
|
||
onChange={(value) => setProviderFilter(String(value || ''))}
|
||
options={[
|
||
{ value: '', label: '全部 Provider' },
|
||
...providerOptions,
|
||
]}
|
||
placeholder="全部 Provider"
|
||
/>
|
||
</div>
|
||
<div className="oauth-filter-slot">
|
||
<ModernSelect
|
||
size="sm"
|
||
value={statusFilter}
|
||
onChange={(value) => setStatusFilter(String(value || ''))}
|
||
options={[
|
||
{ value: '', label: '全部状态' },
|
||
{ value: 'healthy', label: '正常' },
|
||
{ value: 'abnormal', label: '异常' },
|
||
]}
|
||
placeholder="全部状态"
|
||
/>
|
||
</div>
|
||
<div className="oauth-filter-slot-wide">
|
||
<ModernSelect
|
||
size="sm"
|
||
value={siteFilter}
|
||
onChange={(value) => setSiteFilter(String(value || ''))}
|
||
options={[
|
||
{ value: '', label: '全部站点' },
|
||
...siteOptions,
|
||
]}
|
||
placeholder="全部站点"
|
||
/>
|
||
</div>
|
||
<div className="oauth-filter-slot">
|
||
<ModernSelect
|
||
size="sm"
|
||
value={tokenFilter}
|
||
onChange={(value) => setTokenFilter((value || '') as TokenFilter)}
|
||
options={tokenFilterOptions}
|
||
placeholder="全部 Token"
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="oauth-toolbar-actions">
|
||
{hasActiveFilters ? (
|
||
<button
|
||
type="button"
|
||
className="btn btn-ghost oauth-outline-button"
|
||
onClick={clearFilters}
|
||
>
|
||
清空筛选
|
||
</button>
|
||
) : null}
|
||
<div className="oauth-filter-slot">
|
||
<ModernSelect
|
||
size="sm"
|
||
value={String(autoRefreshSeconds)}
|
||
onChange={(value) => setAutoRefreshSeconds(Number(value || 0))}
|
||
options={AUTO_REFRESH_OPTIONS.map((seconds) => ({
|
||
value: String(seconds),
|
||
label: seconds === 0 ? '自动刷新:关闭' : `自动刷新:${seconds}s`,
|
||
}))}
|
||
placeholder="自动刷新"
|
||
/>
|
||
</div>
|
||
{autoRefreshSeconds > 0 ? (
|
||
<div className="oauth-toolbar-meta">下次刷新 {autoRefreshCountdown}s</div>
|
||
) : null}
|
||
<div ref={columnMenuAnchorRef} className="oauth-column-menu-anchor">
|
||
<button
|
||
ref={columnMenuButtonRef}
|
||
type="button"
|
||
className="btn btn-ghost oauth-outline-button"
|
||
aria-haspopup="menu"
|
||
aria-expanded={showColumnMenu}
|
||
aria-controls={showColumnMenu ? columnMenuId : undefined}
|
||
onClick={() => {
|
||
if (showColumnMenu) {
|
||
closeColumnMenu();
|
||
return;
|
||
}
|
||
openColumnMenu('first');
|
||
}}
|
||
onKeyDown={(event) => {
|
||
if (!['ArrowDown', 'ArrowUp', 'Enter', ' '].includes(event.key)) return;
|
||
event.preventDefault();
|
||
openColumnMenu(event.key === 'ArrowUp' ? 'last' : 'first');
|
||
}}
|
||
>
|
||
列设置
|
||
</button>
|
||
{columnMenuPresence.shouldRender ? (
|
||
<div
|
||
ref={columnMenuRef}
|
||
id={columnMenuId}
|
||
className={`oauth-column-menu ${columnMenuPresence.isVisible ? '' : 'is-closing'}`.trim()}
|
||
role="menu"
|
||
aria-label="OAuth 列设置"
|
||
aria-hidden={!columnMenuPresence.isVisible}
|
||
>
|
||
{COLUMN_OPTIONS.map((column) => (
|
||
<label key={column.key} className="oauth-column-item" role="menuitemcheckbox" aria-checked={visibleColumns[column.key]}>
|
||
<input
|
||
type="checkbox"
|
||
checked={visibleColumns[column.key]}
|
||
onChange={() => toggleColumn(column.key)}
|
||
/>
|
||
<span>{column.label}</span>
|
||
</label>
|
||
))}
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div className="oauth-summary-note">
|
||
OAuth 账号以后只在这里维护。连接管理页默认只保留普通 Session / API Key / Token 连接。
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
|
||
const mobileFilterContent = (
|
||
<div className="mobile-filter-panel oauth-mobile-filter-panel">
|
||
<input
|
||
type="text"
|
||
className="oauth-input"
|
||
value={searchQuery}
|
||
onChange={(event) => setSearchQuery(event.target.value)}
|
||
placeholder="搜索账号 / 邮箱 / 站点 / 项目 / Token"
|
||
/>
|
||
<ModernSelect
|
||
size="sm"
|
||
value={providerFilter}
|
||
onChange={(value) => setProviderFilter(String(value || ''))}
|
||
options={[
|
||
{ value: '', label: '全部 Provider' },
|
||
...providerOptions,
|
||
]}
|
||
placeholder="全部 Provider"
|
||
/>
|
||
<ModernSelect
|
||
size="sm"
|
||
value={statusFilter}
|
||
onChange={(value) => setStatusFilter(String(value || ''))}
|
||
options={[
|
||
{ value: '', label: '全部状态' },
|
||
{ value: 'healthy', label: '正常' },
|
||
{ value: 'abnormal', label: '异常' },
|
||
]}
|
||
placeholder="全部状态"
|
||
/>
|
||
<ModernSelect
|
||
size="sm"
|
||
value={siteFilter}
|
||
onChange={(value) => setSiteFilter(String(value || ''))}
|
||
options={[
|
||
{ value: '', label: '全部站点' },
|
||
...siteOptions,
|
||
]}
|
||
placeholder="全部站点"
|
||
/>
|
||
<ModernSelect
|
||
size="sm"
|
||
value={tokenFilter}
|
||
onChange={(value) => setTokenFilter((value || '') as TokenFilter)}
|
||
options={tokenFilterOptions}
|
||
placeholder="全部 Token"
|
||
/>
|
||
<ModernSelect
|
||
size="sm"
|
||
value={String(autoRefreshSeconds)}
|
||
onChange={(value) => setAutoRefreshSeconds(Number(value || 0))}
|
||
options={AUTO_REFRESH_OPTIONS.map((seconds) => ({
|
||
value: String(seconds),
|
||
label: seconds === 0 ? '自动刷新:关闭' : `自动刷新:${seconds}s`,
|
||
}))}
|
||
placeholder="自动刷新"
|
||
/>
|
||
{hasActiveFilters ? (
|
||
<button
|
||
type="button"
|
||
className="btn btn-ghost oauth-outline-button"
|
||
onClick={clearFilters}
|
||
>
|
||
清空筛选
|
||
</button>
|
||
) : null}
|
||
</div>
|
||
);
|
||
|
||
const desktopTable = (
|
||
<table className="data-table oauth-table">
|
||
<thead>
|
||
<tr>
|
||
<th className="oauth-table-checkbox-col">
|
||
<input
|
||
data-testid="oauth-select-all"
|
||
type="checkbox"
|
||
checked={allVisibleSelected}
|
||
onChange={(event) => toggleSelectAllVisible(event.target.checked)}
|
||
/>
|
||
</th>
|
||
{visibleColumns.identity ? <th className="oauth-col-identity">账号</th> : null}
|
||
{visibleColumns.site ? <th className="oauth-col-site">站点</th> : null}
|
||
{visibleColumns.status ? <th className="oauth-col-status">状态</th> : null}
|
||
{visibleColumns.quota ? <th className="oauth-col-quota">额度</th> : null}
|
||
{visibleColumns.proxy ? <th className="oauth-col-proxy">计划 / 代理</th> : null}
|
||
<th className="oauth-table-actions-col">操作</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{filteredConnections.map((connection) => {
|
||
const quota = connection.quota;
|
||
const emailLabel = resolveConnectionEmailLabel(connection);
|
||
const primaryTitle = resolveConnectionPrimaryTitle(connection);
|
||
const sitePlatform = asTrimmedString(connection.site?.platform);
|
||
const modelSyncDetail = resolveModelSyncDetail(connection);
|
||
const quotaSyncDetail = resolveQuotaSyncDetail(quota);
|
||
const tokenExpiryStatus = resolveTokenExpiryStatusText(connection);
|
||
return (
|
||
<tr key={connection.accountId}>
|
||
<td>
|
||
<input
|
||
type="checkbox"
|
||
checked={selectedConnectionIds.includes(connection.accountId)}
|
||
onChange={(event) => {
|
||
const checked = event.target.checked;
|
||
setSelectedConnectionIds((current) => checked
|
||
? Array.from(new Set([...current, connection.accountId]))
|
||
: current.filter((id) => id !== connection.accountId));
|
||
}}
|
||
/>
|
||
</td>
|
||
{visibleColumns.identity ? (
|
||
<td className="oauth-col-identity">
|
||
<div className="oauth-cell-stack">
|
||
<div className="oauth-cell-inline">
|
||
<button
|
||
type="button"
|
||
className="btn btn-link btn-link-info oauth-account-trigger oauth-identity-primary"
|
||
title={primaryTitle}
|
||
onClick={() => void openModelsModal(connection)}
|
||
>
|
||
{primaryTitle}
|
||
</button>
|
||
<span className={`badge oauth-badge ${connection.provider === 'codex' ? 'badge-info' : 'badge-primary'}`}>
|
||
{connection.provider}
|
||
</span>
|
||
<span className={`badge oauth-badge ${connection.status === 'abnormal' ? 'badge-warning' : 'badge-success'}`}>
|
||
{resolveConnectionStatusLabel(connection.status)}
|
||
</span>
|
||
</div>
|
||
{emailLabel && emailLabel !== primaryTitle ? (
|
||
<div className="oauth-cell-secondary oauth-identity-secondary" title={emailLabel}>{emailLabel}</div>
|
||
) : null}
|
||
{connection.accountKey ? (
|
||
<div className="oauth-cell-tertiary oauth-identity-key" title={connection.accountKey}>
|
||
连接: {compactAccountKey(connection.accountKey)}
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
</td>
|
||
) : null}
|
||
{visibleColumns.site ? (
|
||
<td className="oauth-col-site">
|
||
<div className="oauth-cell-stack">
|
||
<div className="oauth-cell-primary oauth-site-name" title={connection.site?.name || '--'}>
|
||
{connection.site?.name || '--'}
|
||
</div>
|
||
{sitePlatform && sitePlatform !== connection.provider ? (
|
||
<div className="oauth-cell-secondary">{sitePlatform}</div>
|
||
) : null}
|
||
</div>
|
||
</td>
|
||
) : null}
|
||
{visibleColumns.status ? (
|
||
<td className="oauth-col-status">
|
||
<div className="oauth-status-stack">
|
||
<div className="oauth-status-item">
|
||
<div className="oauth-status-line">
|
||
<div className="oauth-status-label">模型</div>
|
||
<div className="oauth-cell-secondary oauth-status-value" title={modelSyncDetail || resolveModelSyncStatusText(connection)}>
|
||
{resolveModelSyncStatusText(connection)}
|
||
</div>
|
||
</div>
|
||
{modelSyncDetail ? (
|
||
<div className="oauth-status-detail" title={modelSyncDetail}>{modelSyncDetail}</div>
|
||
) : null}
|
||
</div>
|
||
<div className="oauth-status-item">
|
||
<div className="oauth-status-line">
|
||
<div className="oauth-status-label">额度</div>
|
||
<div className="oauth-cell-tertiary oauth-status-value" title={quotaSyncDetail || resolveQuotaSyncStatusText(quota)}>
|
||
{resolveQuotaSyncStatusText(quota)}
|
||
</div>
|
||
</div>
|
||
{quotaSyncDetail ? (
|
||
<div className="oauth-status-detail" title={quotaSyncDetail}>{quotaSyncDetail}</div>
|
||
) : null}
|
||
</div>
|
||
{tokenExpiryStatus ? (
|
||
<div className="oauth-status-item">
|
||
<div className="oauth-status-line">
|
||
<div className="oauth-status-label">Token</div>
|
||
<div className={`oauth-cell-tertiary oauth-status-value ${resolveTokenStatusClassName(connection)}`} title={tokenExpiryStatus}>
|
||
{tokenExpiryStatus}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
</td>
|
||
) : null}
|
||
{visibleColumns.quota ? (
|
||
<td className="oauth-col-quota">
|
||
{quota ? (
|
||
<div className="oauth-quota-stack">
|
||
<div className="oauth-quota-meta">
|
||
<span className={`badge oauth-badge ${quota.status === 'error' ? 'badge-warning' : quota.status === 'unsupported' ? 'badge-muted' : 'badge-info'}`}>
|
||
{resolveQuotaStatusLabel(quota.status)}
|
||
</span>
|
||
<span className="oauth-cell-tertiary">{resolveQuotaSourceLabel(quota.source)}</span>
|
||
</div>
|
||
<QuotaWindowRow label="5h" window={quota.windows?.fiveHour} />
|
||
<QuotaWindowRow label="7d" window={quota.windows?.sevenDay} />
|
||
</div>
|
||
) : (
|
||
<span className="oauth-cell-secondary">--</span>
|
||
)}
|
||
</td>
|
||
) : null}
|
||
{visibleColumns.proxy ? (
|
||
<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"
|
||
className="btn btn-link btn-link-info oauth-inline-trigger"
|
||
onClick={() => openProxySettingsDrawer(connection)}
|
||
>
|
||
{hasOauthProxySelection(connection) ? '代理设置' : '设置代理'}
|
||
</button>
|
||
</div>
|
||
</td>
|
||
) : null}
|
||
<td className="oauth-actions-cell">
|
||
<div className="oauth-row-actions">
|
||
<button
|
||
type="button"
|
||
className="btn btn-link btn-link-info"
|
||
onClick={() => void openModelsModal(connection)}
|
||
>
|
||
{connection.modelCount} 个模型
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className="btn btn-link btn-link-primary"
|
||
onClick={() => handleRefreshQuota(connection.accountId)}
|
||
disabled={actionLoadingKey === `quota:${connection.accountId}`}
|
||
>
|
||
{actionLoadingKey === `quota:${connection.accountId}` ? '刷新中...' : '刷新额度'}
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className="btn btn-link btn-link-info"
|
||
onClick={() => openRebindDrawer(connection)}
|
||
>
|
||
重新授权
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className="btn btn-link btn-link-danger"
|
||
onClick={() => handleDelete(connection.accountId)}
|
||
disabled={actionLoadingKey === `delete:${connection.accountId}`}
|
||
>
|
||
{actionLoadingKey === `delete:${connection.accountId}` ? '删除中...' : '删除连接'}
|
||
</button>
|
||
</div>
|
||
</td>
|
||
</tr>
|
||
);
|
||
})}
|
||
</tbody>
|
||
</table>
|
||
);
|
||
|
||
const mobileList = (
|
||
<div className="mobile-card-list oauth-mobile-list">
|
||
{filteredConnections.map((connection) => {
|
||
const quota = connection.quota;
|
||
const tokenExpiryStatus = resolveTokenExpiryStatusText(connection);
|
||
return (
|
||
<MobileCard
|
||
key={connection.accountId}
|
||
title={resolveConnectionPrimaryTitle(connection)}
|
||
subtitle={`${connection.provider} · ${resolveConnectionStatusLabel(connection.status)}`}
|
||
headerActions={(
|
||
<input
|
||
type="checkbox"
|
||
checked={selectedConnectionIds.includes(connection.accountId)}
|
||
onChange={(event) => {
|
||
const checked = event.target.checked;
|
||
setSelectedConnectionIds((current) => checked
|
||
? Array.from(new Set([...current, connection.accountId]))
|
||
: current.filter((id) => id !== connection.accountId));
|
||
}}
|
||
/>
|
||
)}
|
||
>
|
||
<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={(
|
||
<div className="oauth-cell-stack">
|
||
<div className="oauth-cell-tertiary">{resolveProxyDisplayText(connection)}</div>
|
||
<button
|
||
type="button"
|
||
className="btn btn-link btn-link-info oauth-inline-trigger"
|
||
onClick={() => openProxySettingsDrawer(connection)}
|
||
>
|
||
{hasOauthProxySelection(connection) ? '代理设置' : '设置代理'}
|
||
</button>
|
||
</div>
|
||
)}
|
||
stacked
|
||
/>
|
||
<MobileField
|
||
label="运行状态"
|
||
value={(
|
||
<div className="oauth-status-stack">
|
||
<div className="oauth-status-item">
|
||
<div className="oauth-status-line">
|
||
<div className="oauth-status-label">模型</div>
|
||
<div className="oauth-cell-secondary">{resolveModelSyncStatusText(connection)}</div>
|
||
</div>
|
||
{resolveModelSyncDetail(connection) ? (
|
||
<div className="oauth-status-detail">{resolveModelSyncDetail(connection)}</div>
|
||
) : null}
|
||
</div>
|
||
<div className="oauth-status-item">
|
||
<div className="oauth-status-line">
|
||
<div className="oauth-status-label">额度</div>
|
||
<div className="oauth-cell-tertiary">{resolveQuotaSyncStatusText(quota)}</div>
|
||
</div>
|
||
{resolveQuotaSyncDetail(quota) ? (
|
||
<div className="oauth-status-detail">{resolveQuotaSyncDetail(quota)}</div>
|
||
) : null}
|
||
</div>
|
||
{tokenExpiryStatus ? (
|
||
<div className="oauth-status-item">
|
||
<div className="oauth-status-line">
|
||
<div className="oauth-status-label">Token</div>
|
||
<div className={`oauth-cell-tertiary ${resolveTokenStatusClassName(connection)}`}>{tokenExpiryStatus}</div>
|
||
</div>
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
)}
|
||
stacked
|
||
/>
|
||
<div className="oauth-mobile-section">
|
||
<div className="oauth-mobile-section-label">Usage / Quota</div>
|
||
{quota ? (
|
||
<>
|
||
<div className="oauth-quota-meta">
|
||
<span className={`badge oauth-badge ${quota.status === 'error' ? 'badge-warning' : quota.status === 'unsupported' ? 'badge-muted' : 'badge-info'}`}>
|
||
{resolveQuotaStatusLabel(quota.status)}
|
||
</span>
|
||
<span className="oauth-cell-tertiary">{resolveQuotaSourceLabel(quota.source)}</span>
|
||
</div>
|
||
<QuotaWindowRow label="5h" window={quota.windows?.fiveHour} />
|
||
<QuotaWindowRow label="7d" window={quota.windows?.sevenDay} />
|
||
</>
|
||
) : (
|
||
<div className="oauth-cell-secondary">--</div>
|
||
)}
|
||
</div>
|
||
<div className="mobile-card-actions oauth-mobile-actions">
|
||
<button type="button" className="btn btn-link btn-link-info" onClick={() => void openModelsModal(connection)}>
|
||
{connection.modelCount} 个模型
|
||
</button>
|
||
<button type="button" className="btn btn-link btn-link-primary" onClick={() => handleRefreshQuota(connection.accountId)}>
|
||
刷新额度
|
||
</button>
|
||
<button type="button" className="btn btn-link btn-link-info" onClick={() => openProxySettingsDrawer(connection)}>
|
||
代理设置
|
||
</button>
|
||
<button type="button" className="btn btn-link btn-link-info" onClick={() => openRebindDrawer(connection)}>
|
||
重新授权
|
||
</button>
|
||
<button type="button" className="btn btn-link btn-link-danger" onClick={() => handleDelete(connection.accountId)}>
|
||
删除连接
|
||
</button>
|
||
</div>
|
||
</MobileCard>
|
||
);
|
||
})}
|
||
</div>
|
||
);
|
||
|
||
return (
|
||
<div className="page-container animate-fade-in admin-ops-page oauth-ops-page">
|
||
<div className="page-header">
|
||
<div>
|
||
<h2 className="page-title">OAuth 管理</h2>
|
||
<div className="page-subtitle">
|
||
统一管理需要浏览器授权的官方上游连接。OAuth 账号以后只在这里维护,不再和普通连接管理页重复显示。
|
||
</div>
|
||
</div>
|
||
{!isMobile ? (
|
||
<div className="page-actions">
|
||
<button type="button" className="btn btn-ghost oauth-outline-button" onClick={openImportModal}>
|
||
导入 JSON
|
||
</button>
|
||
<button type="button" className="btn btn-primary" onClick={() => openCreateDrawer()}>
|
||
新建 OAuth 连接
|
||
</button>
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
|
||
{sessionFeedback ? (
|
||
<div className={`card oauth-page-message oauth-page-message-${sessionFeedback.tone}`.trim()}>
|
||
<div className="oauth-page-message-head">
|
||
<div className="oauth-page-message-text">{sessionFeedback.message}</div>
|
||
<span className={`badge ${sessionFeedback.tone === 'success' ? 'badge-success' : sessionFeedback.tone === 'error' ? 'badge-danger' : 'badge-info'}`}>
|
||
{sessionFeedback.tone === 'success' ? '成功' : sessionFeedback.tone === 'error' ? '失败' : '提示'}
|
||
</span>
|
||
</div>
|
||
{sessionFeedback.routeUnit ? (
|
||
<div className="oauth-page-message-meta">
|
||
<span className="badge badge-info">{sessionFeedback.routeUnit.name}</span>
|
||
<span className="badge badge-muted">{sessionFeedback.routeUnit.memberCount} 个成员</span>
|
||
<span className="badge badge-muted">{resolveRouteUnitStrategyLabel(sessionFeedback.routeUnit.strategy)}</span>
|
||
<div className="oauth-page-message-detail">
|
||
{sessionFeedback.routeUnit.action === 'created'
|
||
? '已将选中的 OAuth 账号合并为一个路由池,后续会以单个路由单元参与路由。'
|
||
: '该路由池已拆分回单体账号,后续会分别参与路由。'}
|
||
</div>
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
) : null}
|
||
|
||
<ResponsiveFilterPanel
|
||
isMobile={isMobile}
|
||
mobileOpen={showMobileFilters}
|
||
onMobileOpen={() => setShowMobileFilters(true)}
|
||
onMobileClose={() => setShowMobileFilters(false)}
|
||
mobileTitle="OAuth 筛选与操作"
|
||
mobileContent={mobileFilterContent}
|
||
desktopContent={filterBar}
|
||
mobileTrigger={
|
||
<div className="mobile-filter-row oauth-mobile-trigger-row">
|
||
<button
|
||
type="button"
|
||
className="btn btn-ghost oauth-outline-button"
|
||
onClick={() => setShowMobileFilters(true)}
|
||
>
|
||
筛选与操作
|
||
</button>
|
||
<button type="button" className="btn btn-primary" onClick={() => openCreateDrawer()}>
|
||
新建 OAuth 连接
|
||
</button>
|
||
</div>
|
||
}
|
||
/>
|
||
|
||
<div className="card oauth-workbench-card">
|
||
<div className="oauth-workbench-head">
|
||
<div>
|
||
<div className="oauth-workbench-title">OAuth 连接列表</div>
|
||
<div className="oauth-workbench-meta">
|
||
已连接 {connections.length} 个 OAuth 账号,当前筛选后显示 {filteredConnections.length} 个。
|
||
{tokenRiskSummaryItems.length > 0 ? (
|
||
<span className="oauth-token-risk-actions">
|
||
{tokenRiskSummaryItems.map((item) => (
|
||
<button
|
||
key={item.value}
|
||
type="button"
|
||
className={`badge-link oauth-token-risk-button ${tokenFilter === item.value ? 'is-active' : ''}`.trim()}
|
||
aria-pressed={tokenFilter === item.value}
|
||
onClick={() => applyTokenRiskFilter(item.value)}
|
||
>
|
||
<span className={`badge ${item.badgeClass}`}>{item.label} {item.count}</span>
|
||
</button>
|
||
))}
|
||
</span>
|
||
) : null}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{selectedConnectionIds.length > 0 ? (
|
||
<ResponsiveBatchActionBar
|
||
isMobile={isMobile}
|
||
info={`已选 ${selectedConnectionIds.length} 项${hiddenSelectedCount > 0 ? `,${hiddenSelectedCount} 项被筛选隐藏` : ''}`}
|
||
desktopStyle={{ marginBottom: 12 }}
|
||
>
|
||
<button
|
||
type="button"
|
||
className="btn btn-ghost oauth-outline-button"
|
||
onClick={handleRefreshSelected}
|
||
disabled={actionLoadingKey === 'quota:selected'}
|
||
>
|
||
{actionLoadingKey === 'quota:selected' ? '刷新中...' : '批量刷新额度'}
|
||
</button>
|
||
{hiddenSelectedCount > 0 ? (
|
||
<button
|
||
type="button"
|
||
className="btn btn-ghost oauth-outline-button"
|
||
onClick={keepOnlyVisibleSelected}
|
||
>
|
||
只保留当前可见
|
||
</button>
|
||
) : null}
|
||
{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"
|
||
onClick={handleDeleteSelected}
|
||
disabled={actionLoadingKey === 'delete:selected'}
|
||
>
|
||
{actionLoadingKey === 'delete:selected' ? '删除中...' : '批量删除'}
|
||
</button>
|
||
</ResponsiveBatchActionBar>
|
||
) : null}
|
||
|
||
{!loaded ? (
|
||
<div className="empty-state oauth-empty-state">
|
||
<svg className="empty-state-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1} d="M12 6v6l4 2m6-2a10 10 0 11-20 0 10 10 0 0120 0z" />
|
||
</svg>
|
||
<div className="empty-state-title">加载中...</div>
|
||
<div className="empty-state-desc">正在加载 OAuth 连接与额度信息。</div>
|
||
</div>
|
||
) : filteredConnections.length === 0 && connections.length > 0 ? (
|
||
<div className="empty-state oauth-empty-state">
|
||
<svg className="empty-state-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1} d="M3 4a1 1 0 011-1h16a1 1 0 011 1v2.5a1 1 0 01-.293.707L14 13.914V19a1 1 0 01-.553.894l-4 2A1 1 0 018 21v-7.086L1.293 7.207A1 1 0 011 6.5V4z" />
|
||
</svg>
|
||
<div className="empty-state-title">没有匹配的 OAuth 连接</div>
|
||
<div className="empty-state-desc">
|
||
当前筛选条件没有命中账号,可清空筛选后重新查看全部 OAuth 连接。
|
||
</div>
|
||
{hasActiveFilters ? (
|
||
<button
|
||
type="button"
|
||
className="btn btn-ghost oauth-outline-button oauth-empty-action"
|
||
onClick={clearFilters}
|
||
>
|
||
清空筛选
|
||
</button>
|
||
) : null}
|
||
</div>
|
||
) : filteredConnections.length === 0 ? (
|
||
<div className="empty-state oauth-empty-state">
|
||
<svg className="empty-state-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1} d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0z" />
|
||
</svg>
|
||
<div className="empty-state-title">暂无 OAuth 连接</div>
|
||
<div className="empty-state-desc">
|
||
使用右上角“新建 OAuth 连接”接入 Codex、Claude、Gemini CLI 或 Antigravity。
|
||
</div>
|
||
</div>
|
||
) : (
|
||
isMobile ? mobileList : desktopTable
|
||
)}
|
||
</div>
|
||
|
||
<SideDrawer
|
||
open={drawerOpen}
|
||
onClose={() => setDrawerOpen(false)}
|
||
title={drawerIntent.mode === 'create'
|
||
? '新建 OAuth 连接'
|
||
: drawerIntent.mode === 'proxy'
|
||
? `代理设置 · ${resolveConnectionPrimaryTitle(drawerIntent.account)}`
|
||
: `重新授权 · ${resolveConnectionPrimaryTitle(drawerIntent.account)}`}
|
||
>
|
||
<div className="oauth-drawer-layout">
|
||
<div className="card oauth-drawer-panel">
|
||
<div className="oauth-drawer-section">
|
||
{drawerIntent.mode === 'create' ? (
|
||
<div className="oauth-form-field">
|
||
<div className="oauth-field-label">Provider</div>
|
||
<ModernSelect
|
||
value={selectedProviderKey}
|
||
onChange={(value) => setSelectedProviderKey(String(value || ''))}
|
||
options={providerOptions}
|
||
placeholder="选择 OAuth Provider"
|
||
/>
|
||
{!hasProviderOptions ? (
|
||
<div className="oauth-form-helper" role="status">
|
||
当前没有可用的 OAuth Provider。请检查后端 OAuth 配置或清理无效 Provider 数据。
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
) : (
|
||
<div className="oauth-cell-stack">
|
||
<div className="oauth-field-label">当前连接</div>
|
||
<div className="oauth-cell-primary">
|
||
{resolveConnectionPrimaryTitle(drawerIntent.account)}
|
||
</div>
|
||
<div className="oauth-cell-secondary">
|
||
{drawerIntent.account.provider}
|
||
{drawerIntent.account.projectId ? ` · Project ${drawerIntent.account.projectId}` : ''}
|
||
{drawerIntent.mode === 'proxy'
|
||
? ` · ${resolveProxyDisplayText(drawerIntent.account)}`
|
||
: ''}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{selectedProvider?.requiresProjectId && drawerIntent.mode === 'create' ? (
|
||
<div className="oauth-form-field">
|
||
<div className="oauth-field-label">Google Cloud Project ID(可选)</div>
|
||
<input
|
||
type="text"
|
||
className="oauth-input"
|
||
value={drawerProjectId}
|
||
onChange={(event) => setDrawerProjectId(event.target.value)}
|
||
placeholder="留空则交给上游自动解析"
|
||
/>
|
||
</div>
|
||
) : null}
|
||
|
||
<div className="oauth-form-note">
|
||
{drawerIntent.mode === 'proxy'
|
||
? '这里修改的是账号级 OAuth 代理。点击“保存代理”会立即落库并刷新列表;只有“保存并重新授权”才会重新走授权流程。若两项都不勾选,则回退到站点代理配置。'
|
||
: '这里的设置会作用于下一次“连接”或“重新授权”。填写代理地址后,本次 OAuth 换 token 和后续生成的账号都会直接带上这份账号级代理配置;若不勾选,则回退到站点代理配置。'}
|
||
</div>
|
||
|
||
<div className="oauth-toggle-group">
|
||
<label className="oauth-toggle">
|
||
<input
|
||
type="checkbox"
|
||
checked={oauthSystemProxyEnabled}
|
||
data-oauth-setting="use-system-proxy"
|
||
onChange={(event) => {
|
||
const checked = !!event.target.checked;
|
||
setOauthSystemProxyEnabled(checked);
|
||
if (checked) {
|
||
setOauthCustomProxyEnabled(false);
|
||
setOauthProxyUrl('');
|
||
}
|
||
}}
|
||
/>
|
||
<span>使用系统级代理</span>
|
||
</label>
|
||
<label className="oauth-toggle">
|
||
<input
|
||
type="checkbox"
|
||
checked={oauthCustomProxyEnabled}
|
||
data-oauth-setting="use-custom-proxy"
|
||
onChange={(event) => {
|
||
const checked = !!event.target.checked;
|
||
setOauthCustomProxyEnabled(checked);
|
||
if (checked) setOauthSystemProxyEnabled(false);
|
||
}}
|
||
/>
|
||
<span>使用自定义代理</span>
|
||
</label>
|
||
</div>
|
||
|
||
<div className="oauth-form-field">
|
||
<div className="oauth-field-label">代理地址</div>
|
||
<input
|
||
type="text"
|
||
className="oauth-input"
|
||
value={oauthProxyUrl}
|
||
data-oauth-setting="proxy-url"
|
||
onChange={(event) => setOauthProxyUrl(event.target.value)}
|
||
placeholder="如 http://127.0.0.1:7890 或 socks5://127.0.0.1:1080"
|
||
disabled={!oauthCustomProxyEnabled}
|
||
/>
|
||
</div>
|
||
|
||
{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={!hasProviderOptions || !selectedProvider || !selectedProvider.enabled || actionLoadingKey.startsWith('start:')}
|
||
>
|
||
{!hasProviderOptions
|
||
? '暂无可用 Provider'
|
||
: actionLoadingKey.startsWith('start:')
|
||
? '启动中...'
|
||
: drawerIntent.mode === 'rebind'
|
||
? `重新授权 ${selectedProvider?.label || ''}`.trim()
|
||
: `连接 ${selectedProvider?.label || ''}`.trim()}
|
||
</button>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{activeSession ? (
|
||
<div className="card oauth-drawer-panel">
|
||
<div className="oauth-panel-title">授权指引</div>
|
||
<div className="oauth-guide-grid">
|
||
<div className="oauth-guide-highlight">
|
||
<div className="oauth-guide-block-label oauth-guide-block-label-spaced">固定回调地址</div>
|
||
{renderCodeBlock(activeSession.instructions.redirectUri)}
|
||
</div>
|
||
|
||
{renderGuideCard(
|
||
'本地部署',
|
||
'BoosAPI 和浏览器在同一台机器时,不需要 SSH 隧道。直接点击“连接”,在弹窗里完成授权即可。',
|
||
<div className="oauth-guide-copy">
|
||
如果浏览器能直接访问上面的 localhost 回调地址,授权完成后会自动回到 BoosAPI。
|
||
</div>,
|
||
)}
|
||
|
||
{activeSession.instructions.sshTunnelCommand
|
||
? renderGuideCard(
|
||
'云端部署',
|
||
'BoosAPI 部署在 VPS、容器或远程主机时,浏览器访问到的是你自己电脑的 localhost。先在本地开 SSH 隧道,再继续登录。',
|
||
<div className="oauth-guide-block-list">
|
||
<div className="oauth-guide-block-label">常规 SSH 隧道</div>
|
||
{renderCodeBlock(activeSession.instructions.sshTunnelCommand)}
|
||
{activeSession.instructions.sshTunnelKeyCommand ? (
|
||
<>
|
||
<div className="oauth-guide-block-label">SSH Key 隧道</div>
|
||
{renderCodeBlock(activeSession.instructions.sshTunnelKeyCommand)}
|
||
</>
|
||
) : null}
|
||
</div>,
|
||
)
|
||
: renderGuideCard(
|
||
'云端部署',
|
||
'当前没有检测到远程主机地址。如果你实际是云端部署,请用能访问服务器 127.0.0.1 回调端口的 SSH 隧道方式完成授权。',
|
||
)}
|
||
|
||
{renderGuideCard(
|
||
'手动回调',
|
||
`如果浏览器停在 localhost 错误页,复制浏览器地址栏里的完整 URL,等待 ${Math.max(1, Math.round(activeSession.instructions.manualCallbackDelayMs / 1000))} 秒后粘贴回来。`,
|
||
manualCallbackVisible ? (
|
||
<div className="oauth-drawer-section">
|
||
<textarea
|
||
className="oauth-textarea oauth-mono"
|
||
value={manualCallbackUrl}
|
||
onChange={(event) => setManualCallbackUrl(event.target.value)}
|
||
placeholder="粘贴完整的 callback URL,例如 http://localhost:1455/auth/callback?code=..."
|
||
rows={3}
|
||
/>
|
||
<div className="oauth-inline-actions">
|
||
<button
|
||
type="button"
|
||
className="btn btn-primary"
|
||
onClick={handleSubmitManualCallback}
|
||
disabled={manualCallbackSubmitting}
|
||
>
|
||
{manualCallbackSubmitting ? '提交中...' : '提交回调 URL'}
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className="btn btn-ghost"
|
||
onClick={() => openOAuthPopup(activeSession.provider, activeSession.authorizationUrl)}
|
||
>
|
||
重新打开授权页
|
||
</button>
|
||
</div>
|
||
</div>
|
||
) : (
|
||
<div className="oauth-form-note">手动回调入口将在几秒后可用。</div>
|
||
),
|
||
)}
|
||
</div>
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
</SideDrawer>
|
||
|
||
<OAuthModelsModal
|
||
open={modelsModal.open}
|
||
title={modelsModal.connection ? `模型列表 · ${resolveConnectionPrimaryTitle(modelsModal.connection)}` : '模型列表'}
|
||
siteName={modelsModal.siteName}
|
||
loading={modelsModal.loading}
|
||
refreshing={modelsModal.refreshing}
|
||
models={modelsModal.models}
|
||
totalCount={modelsModal.totalCount}
|
||
disabledCount={modelsModal.disabledCount}
|
||
onClose={closeModelsModal}
|
||
onRefresh={async () => {
|
||
if (!modelsModal.connection) return;
|
||
await loadModelsModal(modelsModal.connection, { refreshUpstream: true });
|
||
}}
|
||
/>
|
||
|
||
<CenteredModal
|
||
open={importOpen}
|
||
onClose={closeImportModal}
|
||
title="导入 OAuth 连接 JSON"
|
||
maxWidth={760}
|
||
closeOnEscape
|
||
bodyStyle={{ display: 'grid', gap: 16 }}
|
||
footer={(
|
||
<>
|
||
<button type="button" className="btn btn-ghost" onClick={closeImportModal}>
|
||
关闭
|
||
</button>
|
||
<button type="button" className="btn btn-primary" onClick={handleImport} disabled={importing || !importPreviewSummary?.canImport}>
|
||
{importing ? '添加中...' : '添加'}
|
||
</button>
|
||
</>
|
||
)}
|
||
>
|
||
<div className="oauth-import-copy">
|
||
选择 JSON 后会先识别是否有效,再决定是否添加。每个 JSON 文件只对应一个 OAuth 连接。
|
||
</div>
|
||
<div
|
||
className={`oauth-import-picker ${importDragOver ? 'is-dragover' : ''}`.trim()}
|
||
onDrop={(event) => { void handleImportDrop(event); }}
|
||
onDragOver={handleImportDragOver}
|
||
onDragLeave={handleImportDragLeave}
|
||
onClick={() => importFileInputRef.current?.click()}
|
||
>
|
||
<input
|
||
ref={importFileInputRef}
|
||
data-testid="oauth-import-file-input"
|
||
type="file"
|
||
accept=".json,application/json"
|
||
multiple
|
||
onChange={(event) => { void handleImportFileChange(event); }}
|
||
style={{ display: 'none' }}
|
||
/>
|
||
{importDrafts.length > 0 ? (
|
||
<>
|
||
<div className="oauth-import-picker-copy">已选择 {importDrafts.length} 份 JSON,点击可重新选择</div>
|
||
<div className="oauth-import-picker-hint">支持多选,只会导入其中的 OAuth 账号。</div>
|
||
<div className="oauth-import-file-list">
|
||
{importDrafts.map((draft) => (
|
||
<div key={draft.sourceName} className="oauth-import-file-item">
|
||
<span className="oauth-import-file-name">
|
||
{draft.sourceName}
|
||
{draft.error ? ` · ${draft.error}` : ''}
|
||
</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</>
|
||
) : (
|
||
<>
|
||
<div className="oauth-import-picker-copy" style={{ color: importDragOver ? 'var(--color-primary)' : undefined }}>
|
||
{importDragOver ? '松开即可导入这些 JSON 文件' : '拖拽 OAuth 连接 JSON 到此处'}
|
||
</div>
|
||
<div className="oauth-import-picker-hint">或点击选择文件,支持一次多选多个 `.json` 文件</div>
|
||
</>
|
||
)}
|
||
</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"
|
||
value={importJsonText}
|
||
onChange={(event) => setImportJsonText(event.target.value)}
|
||
placeholder='粘贴单个 OAuth 连接 JSON,例如 {"type":"codex","access_token":"...","refresh_token":"...","email":"user@example.com"}'
|
||
rows={8}
|
||
/>
|
||
{importPreviewSummary ? (
|
||
<div className={`oauth-import-preview ${importPreviewSummary.canImport ? 'is-valid' : 'is-invalid'}`.trim()}>
|
||
<div className="oauth-import-preview-title">识别结果</div>
|
||
<div className="oauth-import-preview-summary">
|
||
{!importPreviewSummary.hasInvalidItems
|
||
? `已识别 ${importPreviewSummary.totalCount} 份 JSON,均可添加。`
|
||
: importPreviewSummary.canImport
|
||
? `已识别 ${importPreviewSummary.totalCount} 份 JSON,其中 ${importPreviewSummary.validCount} 份可添加,${importPreviewSummary.invalidCount} 份会跳过。`
|
||
: `已识别 ${importPreviewSummary.totalCount} 份 JSON,其中 ${importPreviewSummary.invalidCount} 份无效。`}
|
||
</div>
|
||
<div className="oauth-import-preview-list">
|
||
{importPreviewSummary.items.map((item) => (
|
||
<div key={item.sourceName} className="oauth-import-preview-item">
|
||
<div className="oauth-import-preview-item-head">
|
||
<div className="oauth-import-preview-item-name">{item.sourceName}</div>
|
||
<span className={`badge oauth-import-preview-badge ${item.valid ? 'badge-success' : 'badge-danger'}`}>
|
||
{item.valid ? '结构有效' : '结构无效'}
|
||
</span>
|
||
</div>
|
||
{item.valid ? (
|
||
<div className="oauth-import-preview-meta">
|
||
<span>Provider:{item.providerLabel}</span>
|
||
{item.email ? <span>邮箱:{item.email}</span> : null}
|
||
{item.accountKey ? <span>账号:{item.accountKey}</span> : null}
|
||
{item.expiresLabel ? <span>到期:{item.expiresLabel}</span> : null}
|
||
<span>{item.disabled ? '状态:导入后禁用' : '状态:导入后启用'}</span>
|
||
</div>
|
||
) : (
|
||
<div className="oauth-import-preview-error">{item.error || 'JSON 结构无效'}</div>
|
||
)}
|
||
</div>
|
||
))}
|
||
</div>
|
||
{importPreviewSummary.hasInvalidItems ? (
|
||
<div className="oauth-import-preview-note">
|
||
{importPreviewSummary.canImport
|
||
? '点击“添加”后只会提交结构有效的 OAuth JSON,无效项会计入跳过。'
|
||
: '未识别到可导入的 OAuth JSON。'}
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
) : null}
|
||
</CenteredModal>
|
||
|
||
<CenteredModal
|
||
open={routeUnitModal.open}
|
||
onClose={closeRouteUnitModal}
|
||
title="创建路由池"
|
||
maxWidth={520}
|
||
closeOnEscape
|
||
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>
|
||
);
|
||
}
|