Files
BoosAPI/src/web/pages/OAuthManagement.tsx
T

3210 lines
122 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
);
}