mirror of
https://github.com/router-for-me/Cli-Proxy-API-Management-Center.git
synced 2026-02-20 11:40:50 +08:00
feat: add OAuth model alias editing page and routing
This commit is contained in:
450
src/pages/AuthFilesOAuthExcludedEditPage.tsx
Normal file
450
src/pages/AuthFilesOAuthExcludedEditPage.tsx
Normal file
@@ -0,0 +1,450 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useLocation, useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { LoadingSpinner } from '@/components/ui/LoadingSpinner';
|
||||
import { AutocompleteInput } from '@/components/ui/AutocompleteInput';
|
||||
import { EmptyState } from '@/components/ui/EmptyState';
|
||||
import { IconChevronLeft, IconInfo } from '@/components/ui/icons';
|
||||
import { useEdgeSwipeBack } from '@/hooks/useEdgeSwipeBack';
|
||||
import { useAuthStore, useNotificationStore } from '@/stores';
|
||||
import { authFilesApi } from '@/services/api';
|
||||
import type { AuthFileItem, OAuthModelAliasEntry } from '@/types';
|
||||
import styles from './AuthFilesOAuthExcludedEditPage.module.scss';
|
||||
|
||||
type AuthFileModelItem = { id: string; display_name?: string; type?: string; owned_by?: string };
|
||||
|
||||
type LocationState = { fromAuthFiles?: boolean } | null;
|
||||
|
||||
const OAUTH_PROVIDER_PRESETS = [
|
||||
'gemini-cli',
|
||||
'vertex',
|
||||
'aistudio',
|
||||
'antigravity',
|
||||
'claude',
|
||||
'codex',
|
||||
'qwen',
|
||||
'iflow',
|
||||
];
|
||||
|
||||
const OAUTH_PROVIDER_EXCLUDES = new Set(['all', 'unknown', 'empty']);
|
||||
|
||||
const normalizeProviderKey = (value: string) => value.trim().toLowerCase();
|
||||
|
||||
export function AuthFilesOAuthExcludedEditPage() {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const { showNotification } = useNotificationStore();
|
||||
const connectionStatus = useAuthStore((state) => state.connectionStatus);
|
||||
const disableControls = connectionStatus !== 'connected';
|
||||
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const providerFromParams = searchParams.get('provider') ?? '';
|
||||
|
||||
const [provider, setProvider] = useState(providerFromParams);
|
||||
const [files, setFiles] = useState<AuthFileItem[]>([]);
|
||||
const [excluded, setExcluded] = useState<Record<string, string[]>>({});
|
||||
const [modelAlias, setModelAlias] = useState<Record<string, OAuthModelAliasEntry[]>>({});
|
||||
const [initialLoading, setInitialLoading] = useState(true);
|
||||
const [excludedUnsupported, setExcludedUnsupported] = useState(false);
|
||||
|
||||
const [selectedModels, setSelectedModels] = useState<Set<string>>(new Set());
|
||||
const [modelsList, setModelsList] = useState<AuthFileModelItem[]>([]);
|
||||
const [modelsLoading, setModelsLoading] = useState(false);
|
||||
const [modelsError, setModelsError] = useState<'unsupported' | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setProvider(providerFromParams);
|
||||
}, [providerFromParams]);
|
||||
|
||||
const providerOptions = useMemo(() => {
|
||||
const extraProviders = new Set<string>();
|
||||
Object.keys(excluded).forEach((value) => extraProviders.add(value));
|
||||
Object.keys(modelAlias).forEach((value) => extraProviders.add(value));
|
||||
files.forEach((file) => {
|
||||
if (typeof file.type === 'string') {
|
||||
extraProviders.add(file.type);
|
||||
}
|
||||
if (typeof file.provider === 'string') {
|
||||
extraProviders.add(file.provider);
|
||||
}
|
||||
});
|
||||
|
||||
const normalizedExtras = Array.from(extraProviders)
|
||||
.map((value) => value.trim())
|
||||
.filter((value) => value && !OAUTH_PROVIDER_EXCLUDES.has(value.toLowerCase()));
|
||||
|
||||
const baseSet = new Set(OAUTH_PROVIDER_PRESETS.map((value) => value.toLowerCase()));
|
||||
const extraList = normalizedExtras
|
||||
.filter((value) => !baseSet.has(value.toLowerCase()))
|
||||
.sort((a, b) => a.localeCompare(b));
|
||||
|
||||
return [...OAUTH_PROVIDER_PRESETS, ...extraList];
|
||||
}, [excluded, files, modelAlias]);
|
||||
|
||||
const getTypeLabel = useCallback(
|
||||
(type: string): string => {
|
||||
const key = `auth_files.filter_${type}`;
|
||||
const translated = t(key);
|
||||
if (translated !== key) return translated;
|
||||
if (type.toLowerCase() === 'iflow') return 'iFlow';
|
||||
return type.charAt(0).toUpperCase() + type.slice(1);
|
||||
},
|
||||
[t]
|
||||
);
|
||||
|
||||
const resolvedProviderKey = useMemo(() => normalizeProviderKey(provider), [provider]);
|
||||
const isEditing = useMemo(() => {
|
||||
if (!resolvedProviderKey) return false;
|
||||
return Object.prototype.hasOwnProperty.call(excluded, resolvedProviderKey);
|
||||
}, [excluded, resolvedProviderKey]);
|
||||
|
||||
const title = useMemo(() => {
|
||||
if (isEditing) {
|
||||
return t('oauth_excluded.edit_title', { provider: provider.trim() || resolvedProviderKey });
|
||||
}
|
||||
return t('oauth_excluded.add_title');
|
||||
}, [isEditing, provider, resolvedProviderKey, t]);
|
||||
|
||||
const handleBack = useCallback(() => {
|
||||
const state = location.state as LocationState;
|
||||
if (state?.fromAuthFiles) {
|
||||
navigate(-1);
|
||||
return;
|
||||
}
|
||||
navigate('/auth-files', { replace: true });
|
||||
}, [location.state, navigate]);
|
||||
|
||||
const swipeRef = useEdgeSwipeBack({ onBack: handleBack });
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') {
|
||||
handleBack();
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, [handleBack]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
const load = async () => {
|
||||
setInitialLoading(true);
|
||||
setExcludedUnsupported(false);
|
||||
try {
|
||||
const [filesResult, excludedResult, aliasResult] = await Promise.allSettled([
|
||||
authFilesApi.list(),
|
||||
authFilesApi.getOauthExcludedModels(),
|
||||
authFilesApi.getOauthModelAlias(),
|
||||
]);
|
||||
|
||||
if (cancelled) return;
|
||||
|
||||
if (filesResult.status === 'fulfilled') {
|
||||
setFiles(filesResult.value?.files ?? []);
|
||||
}
|
||||
|
||||
if (aliasResult.status === 'fulfilled') {
|
||||
setModelAlias(aliasResult.value ?? {});
|
||||
}
|
||||
|
||||
if (excludedResult.status === 'fulfilled') {
|
||||
setExcluded(excludedResult.value ?? {});
|
||||
return;
|
||||
}
|
||||
|
||||
const err = excludedResult.status === 'rejected' ? excludedResult.reason : null;
|
||||
const status =
|
||||
typeof err === 'object' && err !== null && 'status' in err
|
||||
? (err as { status?: unknown }).status
|
||||
: undefined;
|
||||
|
||||
if (status === 404) {
|
||||
setExcludedUnsupported(true);
|
||||
return;
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) {
|
||||
setInitialLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
load().catch(() => {
|
||||
if (!cancelled) {
|
||||
setInitialLoading(false);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!resolvedProviderKey) {
|
||||
setSelectedModels(new Set());
|
||||
return;
|
||||
}
|
||||
const existing = excluded[resolvedProviderKey] ?? [];
|
||||
setSelectedModels(new Set(existing));
|
||||
}, [excluded, resolvedProviderKey]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!resolvedProviderKey || excludedUnsupported) {
|
||||
setModelsList([]);
|
||||
setModelsError(null);
|
||||
setModelsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
setModelsLoading(true);
|
||||
setModelsError(null);
|
||||
|
||||
authFilesApi
|
||||
.getModelDefinitions(resolvedProviderKey)
|
||||
.then((models) => {
|
||||
if (cancelled) return;
|
||||
setModelsList(models);
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
if (cancelled) return;
|
||||
const status =
|
||||
typeof err === 'object' && err !== null && 'status' in err
|
||||
? (err as { status?: unknown }).status
|
||||
: undefined;
|
||||
|
||||
if (status === 404) {
|
||||
setModelsList([]);
|
||||
setModelsError('unsupported');
|
||||
return;
|
||||
}
|
||||
|
||||
const errorMessage = err instanceof Error ? err.message : '';
|
||||
showNotification(`${t('notification.load_failed')}: ${errorMessage}`, 'error');
|
||||
})
|
||||
.finally(() => {
|
||||
if (cancelled) return;
|
||||
setModelsLoading(false);
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [excludedUnsupported, resolvedProviderKey, showNotification, t]);
|
||||
|
||||
const updateProvider = useCallback(
|
||||
(value: string) => {
|
||||
setProvider(value);
|
||||
const next = new URLSearchParams(searchParams);
|
||||
const trimmed = value.trim();
|
||||
if (trimmed) {
|
||||
next.set('provider', trimmed);
|
||||
} else {
|
||||
next.delete('provider');
|
||||
}
|
||||
setSearchParams(next, { replace: true });
|
||||
},
|
||||
[searchParams, setSearchParams]
|
||||
);
|
||||
|
||||
const toggleModel = useCallback((modelId: string, checked: boolean) => {
|
||||
setSelectedModels((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (checked) {
|
||||
next.add(modelId);
|
||||
} else {
|
||||
next.delete(modelId);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleSave = useCallback(async () => {
|
||||
const normalizedProvider = normalizeProviderKey(provider);
|
||||
if (!normalizedProvider) {
|
||||
showNotification(t('oauth_excluded.provider_required'), 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
const models = [...selectedModels];
|
||||
setSaving(true);
|
||||
try {
|
||||
if (models.length) {
|
||||
await authFilesApi.saveOauthExcludedModels(normalizedProvider, models);
|
||||
} else {
|
||||
await authFilesApi.deleteOauthExcludedEntry(normalizedProvider);
|
||||
}
|
||||
showNotification(t('oauth_excluded.save_success'), 'success');
|
||||
handleBack();
|
||||
} catch (err: unknown) {
|
||||
const errorMessage = err instanceof Error ? err.message : '';
|
||||
showNotification(`${t('oauth_excluded.save_failed')}: ${errorMessage}`, 'error');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}, [handleBack, provider, selectedModels, showNotification, t]);
|
||||
|
||||
const canSave = !disableControls && !saving && !excludedUnsupported;
|
||||
|
||||
return (
|
||||
<div className={styles.container} ref={swipeRef}>
|
||||
<div className={styles.topBar}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleBack}
|
||||
className={styles.backButton}
|
||||
aria-label={t('common.back')}
|
||||
>
|
||||
<span className={styles.backIcon}>
|
||||
<IconChevronLeft size={18} />
|
||||
</span>
|
||||
<span className={styles.backText}>{t('common.back')}</span>
|
||||
</Button>
|
||||
<div className={styles.topBarTitle} title={title}>
|
||||
{title}
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleSave}
|
||||
loading={saving}
|
||||
disabled={!canSave}
|
||||
className={styles.saveButton}
|
||||
>
|
||||
{t('oauth_excluded.save')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{initialLoading ? (
|
||||
<div className={styles.loadingState}>
|
||||
<LoadingSpinner size={16} />
|
||||
<span>{t('common.loading')}</span>
|
||||
</div>
|
||||
) : excludedUnsupported ? (
|
||||
<Card>
|
||||
<EmptyState
|
||||
title={t('oauth_excluded.upgrade_required_title')}
|
||||
description={t('oauth_excluded.upgrade_required_desc')}
|
||||
/>
|
||||
</Card>
|
||||
) : (
|
||||
<div className={styles.content}>
|
||||
<Card className={styles.settingsCard}>
|
||||
<div className={styles.settingsHeader}>
|
||||
<div className={styles.settingsHeaderTitle}>
|
||||
<IconInfo size={16} />
|
||||
<span>{t('oauth_excluded.title')}</span>
|
||||
</div>
|
||||
<div className={styles.settingsHeaderHint}>{t('oauth_excluded.description')}</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.settingsSection}>
|
||||
<div className={styles.settingsRow}>
|
||||
<div className={styles.settingsInfo}>
|
||||
<div className={styles.settingsLabel}>{t('oauth_excluded.provider_label')}</div>
|
||||
<div className={styles.settingsDesc}>{t('oauth_excluded.provider_hint')}</div>
|
||||
</div>
|
||||
<div className={styles.settingsControl}>
|
||||
<AutocompleteInput
|
||||
id="oauth-excluded-provider"
|
||||
placeholder={t('oauth_excluded.provider_placeholder')}
|
||||
value={provider}
|
||||
onChange={updateProvider}
|
||||
options={providerOptions}
|
||||
disabled={disableControls || saving}
|
||||
wrapperStyle={{ marginBottom: 0 }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{providerOptions.length > 0 && (
|
||||
<div className={styles.tagList}>
|
||||
{providerOptions.map((option) => {
|
||||
const isActive = normalizeProviderKey(provider) === option.toLowerCase();
|
||||
return (
|
||||
<button
|
||||
key={option}
|
||||
type="button"
|
||||
className={`${styles.tag} ${isActive ? styles.tagActive : ''}`}
|
||||
onClick={() => updateProvider(option)}
|
||||
disabled={disableControls || saving}
|
||||
>
|
||||
{getTypeLabel(option)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className={styles.settingsCard}>
|
||||
<div className={styles.settingsHeader}>
|
||||
<div className={styles.settingsHeaderTitle}>{t('oauth_excluded.models_label')}</div>
|
||||
{resolvedProviderKey && (
|
||||
<div className={styles.modelsHint}>
|
||||
{modelsLoading ? (
|
||||
<>
|
||||
<LoadingSpinner size={14} />
|
||||
<span>{t('oauth_excluded.models_loading')}</span>
|
||||
</>
|
||||
) : modelsError === 'unsupported' ? (
|
||||
<span>{t('oauth_excluded.models_unsupported')}</span>
|
||||
) : modelsList.length > 0 ? (
|
||||
<span>{t('oauth_excluded.models_loaded', { count: modelsList.length })}</span>
|
||||
) : (
|
||||
<span>{t('oauth_excluded.no_models_available')}</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{modelsLoading ? (
|
||||
<div className={styles.loadingModels}>
|
||||
<LoadingSpinner size={16} />
|
||||
<span>{t('common.loading')}</span>
|
||||
</div>
|
||||
) : modelsList.length > 0 ? (
|
||||
<div className={styles.modelList}>
|
||||
{modelsList.map((model) => {
|
||||
const checked = selectedModels.has(model.id);
|
||||
return (
|
||||
<label key={model.id} className={styles.modelItem}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
disabled={disableControls || saving}
|
||||
onChange={(event) => toggleModel(model.id, event.target.checked)}
|
||||
/>
|
||||
<span className={styles.modelText}>
|
||||
<span className={styles.modelId}>{model.id}</span>
|
||||
{model.display_name && model.display_name !== model.id && (
|
||||
<span className={styles.modelDisplayName}>{model.display_name}</span>
|
||||
)}
|
||||
</span>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : resolvedProviderKey ? (
|
||||
<div className={styles.emptyModels}>
|
||||
{modelsError === 'unsupported'
|
||||
? t('oauth_excluded.models_unsupported')
|
||||
: t('oauth_excluded.no_models_available')}
|
||||
</div>
|
||||
) : (
|
||||
<div className={styles.emptyModels}>{t('oauth_excluded.provider_required')}</div>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user