feat: add OAuth model alias editing page and routing

This commit is contained in:
LTbinglingfeng
2026-01-29 02:21:04 +08:00
parent 8b3c4189f1
commit 8148851a06
13 changed files with 1886 additions and 619 deletions

View File

@@ -26,6 +26,10 @@
pointer-events: none;
will-change: transform, opacity;
}
&--stacked {
display: none;
}
}
&--animating &__layer {

View File

@@ -6,13 +6,20 @@ import './PageTransition.scss';
interface PageTransitionProps {
render: (location: Location) => ReactNode;
getRouteOrder?: (pathname: string) => number | null;
getTransitionVariant?: (fromPathname: string, toPathname: string) => TransitionVariant;
scrollContainerRef?: React.RefObject<HTMLElement | null>;
}
const TRANSITION_DURATION = 0.35;
const TRAVEL_DISTANCE = 60;
const VERTICAL_TRANSITION_DURATION = 0.35;
const VERTICAL_TRAVEL_DISTANCE = 60;
const IOS_TRANSITION_DURATION = 0.42;
const IOS_ENTER_FROM_X_PERCENT = 100;
const IOS_EXIT_TO_X_PERCENT_FORWARD = -30;
const IOS_EXIT_TO_X_PERCENT_BACKWARD = 100;
const IOS_ENTER_FROM_X_PERCENT_BACKWARD = -30;
const IOS_EXIT_DIM_OPACITY = 0.72;
type LayerStatus = 'current' | 'exiting';
type LayerStatus = 'current' | 'exiting' | 'stacked';
type Layer = {
key: string;
@@ -22,12 +29,21 @@ type Layer = {
type TransitionDirection = 'forward' | 'backward';
export function PageTransition({ render, getRouteOrder, scrollContainerRef }: PageTransitionProps) {
type TransitionVariant = 'vertical' | 'ios';
export function PageTransition({
render,
getRouteOrder,
getTransitionVariant,
scrollContainerRef,
}: PageTransitionProps) {
const location = useLocation();
const currentLayerRef = useRef<HTMLDivElement>(null);
const exitingLayerRef = useRef<HTMLDivElement>(null);
const transitionDirectionRef = useRef<TransitionDirection>('forward');
const transitionVariantRef = useRef<TransitionVariant>('vertical');
const exitScrollOffsetRef = useRef(0);
const nextLayersRef = useRef<Layer[] | null>(null);
const [isAnimating, setIsAnimating] = useState(false);
const [layers, setLayers] = useState<Layer[]>(() => [
@@ -37,8 +53,10 @@ export function PageTransition({ render, getRouteOrder, scrollContainerRef }: Pa
status: 'current',
},
]);
const currentLayerKey = layers[layers.length - 1]?.key ?? location.key;
const currentLayerPathname = layers[layers.length - 1]?.location.pathname;
const currentLayer =
layers.find((layer) => layer.status === 'current') ?? layers[layers.length - 1];
const currentLayerKey = currentLayer?.key ?? location.key;
const currentLayerPathname = currentLayer?.location.pathname;
const resolveScrollContainer = useCallback(() => {
if (scrollContainerRef?.current) return scrollContainerRef.current;
@@ -67,18 +85,62 @@ export function PageTransition({ render, getRouteOrder, scrollContainerRef }: Pa
: 'backward';
transitionDirectionRef.current = nextDirection;
transitionVariantRef.current = getTransitionVariant
? getTransitionVariant(currentLayerPathname ?? '', location.pathname)
: 'vertical';
let cancelled = false;
queueMicrotask(() => {
if (cancelled) return;
setLayers((prev) => {
const prevCurrent = prev[prev.length - 1];
return [
prevCurrent
? { ...prevCurrent, status: 'exiting' }
: { key: location.key, location, status: 'exiting' },
{ key: location.key, location, status: 'current' },
];
const variant = transitionVariantRef.current;
const direction = transitionDirectionRef.current;
const previousCurrentIndex = prev.findIndex((layer) => layer.status === 'current');
const resolvedCurrentIndex =
previousCurrentIndex >= 0 ? previousCurrentIndex : prev.length - 1;
const previousCurrent = prev[resolvedCurrentIndex];
const previousStack: Layer[] = prev
.filter((_, idx) => idx !== resolvedCurrentIndex)
.map((layer): Layer => ({ ...layer, status: 'stacked' }));
const nextCurrent: Layer = { key: location.key, location, status: 'current' };
if (!previousCurrent) {
nextLayersRef.current = [nextCurrent];
return [nextCurrent];
}
if (variant === 'ios') {
if (direction === 'forward') {
const exitingLayer: Layer = { ...previousCurrent, status: 'exiting' };
const stackedLayer: Layer = { ...previousCurrent, status: 'stacked' };
nextLayersRef.current = [...previousStack, stackedLayer, nextCurrent];
return [...previousStack, exitingLayer, nextCurrent];
}
const targetIndex = prev.findIndex((layer) => layer.key === location.key);
if (targetIndex !== -1) {
const targetStack: Layer[] = prev.slice(0, targetIndex + 1).map((layer, idx): Layer => {
const isTarget = idx === targetIndex;
return {
...layer,
location: isTarget ? location : layer.location,
status: isTarget ? 'current' : 'stacked',
};
});
const exitingLayer: Layer = { ...previousCurrent, status: 'exiting' };
nextLayersRef.current = targetStack;
return [...targetStack, exitingLayer];
}
}
const exitingLayer: Layer = { ...previousCurrent, status: 'exiting' };
nextLayersRef.current = [nextCurrent];
return [exitingLayer, nextCurrent];
});
setIsAnimating(true);
});
@@ -92,6 +154,7 @@ export function PageTransition({ render, getRouteOrder, scrollContainerRef }: Pa
currentLayerKey,
currentLayerPathname,
getRouteOrder,
getTransitionVariant,
resolveScrollContainer,
]);
@@ -103,6 +166,7 @@ export function PageTransition({ render, getRouteOrder, scrollContainerRef }: Pa
const currentLayerEl = currentLayerRef.current;
const exitingLayerEl = exitingLayerRef.current;
const transitionVariant = transitionVariantRef.current;
const scrollContainer = resolveScrollContainer();
const scrollOffset = exitScrollOffsetRef.current;
@@ -111,17 +175,84 @@ export function PageTransition({ render, getRouteOrder, scrollContainerRef }: Pa
}
const transitionDirection = transitionDirectionRef.current;
const enterFromY = transitionDirection === 'forward' ? TRAVEL_DISTANCE : -TRAVEL_DISTANCE;
const exitToY = transitionDirection === 'forward' ? -TRAVEL_DISTANCE : TRAVEL_DISTANCE;
const isForward = transitionDirection === 'forward';
const enterFromY = isForward ? VERTICAL_TRAVEL_DISTANCE : -VERTICAL_TRAVEL_DISTANCE;
const exitToY = isForward ? -VERTICAL_TRAVEL_DISTANCE : VERTICAL_TRAVEL_DISTANCE;
const exitBaseY = scrollOffset ? -scrollOffset : 0;
const tl = gsap.timeline({
onComplete: () => {
setLayers((prev) => prev.filter((layer) => layer.status !== 'exiting'));
const nextLayers = nextLayersRef.current;
nextLayersRef.current = null;
setLayers((prev) => nextLayers ?? prev.filter((layer) => layer.status !== 'exiting'));
setIsAnimating(false);
},
});
if (transitionVariant === 'ios') {
const exitToXPercent = isForward
? IOS_EXIT_TO_X_PERCENT_FORWARD
: IOS_EXIT_TO_X_PERCENT_BACKWARD;
const enterFromXPercent = isForward
? IOS_ENTER_FROM_X_PERCENT
: IOS_ENTER_FROM_X_PERCENT_BACKWARD;
if (exitingLayerEl) {
gsap.set(exitingLayerEl, {
y: exitBaseY,
xPercent: 0,
opacity: 1,
zIndex: isForward ? 0 : 1,
});
}
gsap.set(currentLayerEl, {
xPercent: enterFromXPercent,
opacity: 1,
zIndex: isForward ? 1 : 0,
});
const shadowValue = '-14px 0 24px rgba(0, 0, 0, 0.16)';
const topLayerEl = isForward ? currentLayerEl : exitingLayerEl;
if (topLayerEl) {
gsap.set(topLayerEl, { boxShadow: shadowValue });
}
if (exitingLayerEl) {
tl.to(
exitingLayerEl,
{
xPercent: exitToXPercent,
opacity: isForward ? IOS_EXIT_DIM_OPACITY : 1,
duration: IOS_TRANSITION_DURATION,
ease: 'power2.out',
force3D: true,
},
0
);
}
tl.to(
currentLayerEl,
{
xPercent: 0,
opacity: 1,
duration: IOS_TRANSITION_DURATION,
ease: 'power2.out',
force3D: true,
onComplete: () => {
if (currentLayerEl) {
gsap.set(currentLayerEl, { clearProps: 'transform,opacity,boxShadow,zIndex' });
}
if (exitingLayerEl) {
gsap.set(exitingLayerEl, { clearProps: 'transform,opacity,boxShadow,zIndex' });
}
},
},
0
);
} else {
// Exit animation: fade out with slight movement (runs simultaneously)
if (exitingLayerEl) {
gsap.set(exitingLayerEl, { y: exitBaseY });
@@ -130,7 +261,7 @@ export function PageTransition({ render, getRouteOrder, scrollContainerRef }: Pa
{
y: exitBaseY + exitToY,
opacity: 0,
duration: TRANSITION_DURATION,
duration: VERTICAL_TRANSITION_DURATION,
ease: 'circ.out',
force3D: true,
},
@@ -145,7 +276,7 @@ export function PageTransition({ render, getRouteOrder, scrollContainerRef }: Pa
{
y: 0,
opacity: 1,
duration: TRANSITION_DURATION,
duration: VERTICAL_TRANSITION_DURATION,
ease: 'circ.out',
force3D: true,
onComplete: () => {
@@ -156,6 +287,7 @@ export function PageTransition({ render, getRouteOrder, scrollContainerRef }: Pa
},
0
);
}
return () => {
tl.kill();
@@ -170,8 +302,15 @@ export function PageTransition({ render, getRouteOrder, scrollContainerRef }: Pa
key={layer.key}
className={`page-transition__layer${
layer.status === 'exiting' ? ' page-transition__layer--exit' : ''
}${layer.status === 'stacked' ? ' page-transition__layer--stacked' : ''
}`}
ref={layer.status === 'exiting' ? exitingLayerRef : currentLayerRef}
ref={
layer.status === 'exiting'
? exitingLayerRef
: layer.status === 'current'
? currentLayerRef
: undefined
}
>
{render(layer.location)}
</div>

View File

@@ -375,6 +375,17 @@ export function MainLayout() {
const trimmedPath =
pathname.length > 1 && pathname.endsWith('/') ? pathname.slice(0, -1) : pathname;
const normalizedPath = trimmedPath === '/dashboard' ? '/' : trimmedPath;
const authFilesIndex = navOrder.indexOf('/auth-files');
if (authFilesIndex !== -1) {
if (normalizedPath === '/auth-files') return authFilesIndex;
if (normalizedPath.startsWith('/auth-files/')) {
if (normalizedPath.startsWith('/auth-files/oauth-excluded')) return authFilesIndex + 0.1;
if (normalizedPath.startsWith('/auth-files/oauth-model-alias')) return authFilesIndex + 0.2;
return authFilesIndex + 0.05;
}
}
const exactIndex = navOrder.indexOf(normalizedPath);
if (exactIndex !== -1) return exactIndex;
const nestedIndex = navOrder.findIndex(
@@ -383,6 +394,20 @@ export function MainLayout() {
return nestedIndex === -1 ? null : nestedIndex;
};
const getTransitionVariant = useCallback((fromPathname: string, toPathname: string) => {
const normalize = (pathname: string) => {
const trimmed =
pathname.length > 1 && pathname.endsWith('/') ? pathname.slice(0, -1) : pathname;
return trimmed === '/dashboard' ? '/' : trimmed;
};
const from = normalize(fromPathname);
const to = normalize(toPathname);
const isAuthFiles = (pathname: string) =>
pathname === '/auth-files' || pathname.startsWith('/auth-files/');
return isAuthFiles(from) && isAuthFiles(to) ? 'ios' : 'vertical';
}, []);
const handleRefreshAll = async () => {
clearCache();
const results = await Promise.allSettled([
@@ -540,6 +565,7 @@ export function MainLayout() {
<PageTransition
render={(location) => <MainRoutes location={location} />}
getRouteOrder={getRouteOrder}
getTransitionVariant={getTransitionVariant}
scrollContainerRef={contentRef}
/>
</main>

View File

@@ -164,6 +164,14 @@ export function IconChevronDown({ size = 20, ...props }: IconProps) {
);
}
export function IconChevronLeft({ size = 20, ...props }: IconProps) {
return (
<svg {...baseSvgProps} width={size} height={size} {...props}>
<path d="m15 18-6-6 6-6" />
</svg>
);
}
export function IconSearch({ size = 20, ...props }: IconProps) {
return (
<svg {...baseSvgProps} width={size} height={size} {...props}>

View File

@@ -0,0 +1,103 @@
import { useEffect, useRef } from 'react';
type SwipeBackOptions = {
enabled?: boolean;
edgeSize?: number;
threshold?: number;
onBack: () => void;
};
type ActiveGesture = {
pointerId: number;
startX: number;
startY: number;
active: boolean;
};
const DEFAULT_EDGE_SIZE = 28;
const DEFAULT_THRESHOLD = 90;
const VERTICAL_TOLERANCE_RATIO = 1.2;
export function useEdgeSwipeBack({
enabled = true,
edgeSize = DEFAULT_EDGE_SIZE,
threshold = DEFAULT_THRESHOLD,
onBack,
}: SwipeBackOptions) {
const containerRef = useRef<HTMLDivElement | null>(null);
const gestureRef = useRef<ActiveGesture | null>(null);
useEffect(() => {
if (!enabled) return;
const el = containerRef.current;
if (!el) return;
const reset = () => {
gestureRef.current = null;
};
const handlePointerMove = (event: PointerEvent) => {
const gesture = gestureRef.current;
if (!gesture?.active) return;
if (event.pointerId !== gesture.pointerId) return;
const dx = event.clientX - gesture.startX;
const dy = event.clientY - gesture.startY;
if (Math.abs(dy) > Math.abs(dx) * VERTICAL_TOLERANCE_RATIO) {
reset();
}
};
const handlePointerUp = (event: PointerEvent) => {
const gesture = gestureRef.current;
if (!gesture?.active) return;
if (event.pointerId !== gesture.pointerId) return;
const dx = event.clientX - gesture.startX;
const dy = event.clientY - gesture.startY;
const isHorizontal = Math.abs(dx) > Math.abs(dy) * VERTICAL_TOLERANCE_RATIO;
reset();
if (dx >= threshold && isHorizontal) {
onBack();
}
};
const handlePointerCancel = (event: PointerEvent) => {
const gesture = gestureRef.current;
if (!gesture?.active) return;
if (event.pointerId !== gesture.pointerId) return;
reset();
};
const handlePointerDown = (event: PointerEvent) => {
if (event.pointerType !== 'touch') return;
if (!event.isPrimary) return;
if (event.clientX > edgeSize) return;
gestureRef.current = {
pointerId: event.pointerId,
startX: event.clientX,
startY: event.clientY,
active: true,
};
};
el.addEventListener('pointerdown', handlePointerDown, { passive: true });
window.addEventListener('pointermove', handlePointerMove, { passive: true });
window.addEventListener('pointerup', handlePointerUp, { passive: true });
window.addEventListener('pointercancel', handlePointerCancel, { passive: true });
return () => {
el.removeEventListener('pointerdown', handlePointerDown);
window.removeEventListener('pointermove', handlePointerMove);
window.removeEventListener('pointerup', handlePointerUp);
window.removeEventListener('pointercancel', handlePointerCancel);
};
}, [edgeSize, enabled, onBack, threshold]);
return containerRef;
}

View File

@@ -2,6 +2,7 @@
"common": {
"login": "Login",
"logout": "Logout",
"back": "Back",
"cancel": "Cancel",
"confirm": "Confirm",
"save": "Save",

View File

@@ -2,6 +2,7 @@
"common": {
"login": "登录",
"logout": "登出",
"back": "返回",
"cancel": "取消",
"confirm": "确认",
"save": "保存",

View File

@@ -0,0 +1,265 @@
@use '../styles/variables' as *;
@use '../styles/mixins' as *;
.container {
display: flex;
flex-direction: column;
gap: $spacing-lg;
min-height: 0;
}
.topBar {
position: sticky;
top: 0;
z-index: 5;
display: grid;
grid-template-columns: 1fr auto 1fr;
align-items: center;
gap: $spacing-md;
padding: $spacing-sm $spacing-md;
background: var(--bg-secondary);
border-bottom: 1px solid var(--border-color);
min-height: 44px;
}
.topBarTitle {
min-width: 0;
text-align: center;
font-size: 16px;
font-weight: 650;
color: var(--text-primary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
justify-self: center;
}
.backButton {
padding-left: 6px;
padding-right: 10px;
justify-self: start;
gap: 0;
}
.backButton > span:last-child {
display: inline-flex;
align-items: center;
gap: 6px;
}
.backIcon {
display: inline-flex;
align-items: center;
justify-content: center;
svg {
display: block;
}
}
.backText {
font-weight: 600;
line-height: 18px;
}
.saveButton {
justify-self: end;
}
.loadingState {
display: flex;
align-items: center;
justify-content: center;
gap: $spacing-sm;
padding: $spacing-2xl 0;
color: var(--text-secondary);
}
.content {
display: flex;
flex-direction: column;
gap: $spacing-lg;
}
.settingsCard {
padding: 0;
overflow: hidden;
}
.settingsHeader {
display: flex;
flex-direction: column;
gap: $spacing-xs;
padding: $spacing-md $spacing-lg;
border-bottom: 1px solid var(--border-color);
background: var(--bg-primary);
}
.settingsHeaderTitle {
display: inline-flex;
align-items: center;
gap: $spacing-xs;
font-weight: 700;
color: var(--text-primary);
}
.settingsHeaderHint {
font-size: 13px;
color: var(--text-secondary);
}
.settingsSection {
display: flex;
flex-direction: column;
gap: $spacing-sm;
padding: $spacing-md $spacing-lg $spacing-lg;
background: var(--bg-primary);
}
.settingsRow {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: $spacing-lg;
}
.settingsInfo {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 4px;
}
.settingsLabel {
font-size: 14px;
font-weight: 600;
color: var(--text-primary);
}
.settingsDesc {
font-size: 13px;
color: var(--text-secondary);
}
.settingsControl {
flex: 0 0 auto;
width: min(360px, 45%);
min-width: 220px;
@include mobile {
width: 100%;
min-width: 0;
}
}
.tagList {
display: flex;
flex-wrap: wrap;
gap: $spacing-xs;
}
.tag {
display: inline-flex;
align-items: center;
padding: 4px 10px;
border-radius: $radius-full;
border: 1px solid var(--border-color);
background-color: var(--bg-secondary);
color: var(--text-secondary);
font-size: 12px;
cursor: pointer;
transition: all $transition-fast;
&:hover {
border-color: var(--primary-color);
color: var(--text-primary);
background-color: var(--bg-tertiary);
}
&:disabled {
opacity: 0.6;
cursor: not-allowed;
}
}
.tagActive {
background-color: var(--primary-color);
border-color: var(--primary-color);
color: #fff;
&:hover {
background-color: var(--primary-color);
border-color: var(--primary-color);
color: #fff;
}
}
.modelsHint {
display: flex;
align-items: center;
gap: $spacing-xs;
font-size: 13px;
color: var(--text-secondary);
}
.loadingModels {
display: flex;
align-items: center;
justify-content: center;
gap: $spacing-sm;
padding: $spacing-xl 0;
color: var(--text-secondary);
}
.modelList {
max-height: 520px;
overflow: auto;
padding: $spacing-sm $spacing-lg $spacing-lg;
background: var(--bg-primary);
}
.modelItem {
display: flex;
align-items: center;
gap: $spacing-sm;
padding: 10px 0;
border-bottom: 1px solid var(--border-color);
&:last-child {
border-bottom: none;
}
input {
width: 16px;
height: 16px;
}
}
.modelText {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.modelId {
font-size: 13px;
font-weight: 600;
color: var(--text-primary);
word-break: break-all;
}
.modelDisplayName {
font-size: 12px;
color: var(--text-secondary);
word-break: break-all;
}
.emptyModels {
padding: $spacing-xl $spacing-lg;
color: var(--text-secondary);
font-size: 13px;
text-align: center;
background: var(--bg-primary);
}

View 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>
);
}

View File

@@ -0,0 +1,267 @@
@use '../styles/variables' as *;
@use '../styles/mixins' as *;
.container {
display: flex;
flex-direction: column;
gap: $spacing-lg;
min-height: 0;
}
.topBar {
position: sticky;
top: 0;
z-index: 5;
display: grid;
grid-template-columns: 1fr auto 1fr;
align-items: center;
gap: $spacing-md;
padding: $spacing-sm $spacing-md;
background: var(--bg-secondary);
border-bottom: 1px solid var(--border-color);
min-height: 44px;
}
.topBarTitle {
min-width: 0;
text-align: center;
font-size: 16px;
font-weight: 650;
color: var(--text-primary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
justify-self: center;
}
.backButton {
padding-left: 6px;
padding-right: 10px;
justify-self: start;
gap: 0;
}
.backButton > span:last-child {
display: inline-flex;
align-items: center;
gap: 6px;
}
.backIcon {
display: inline-flex;
align-items: center;
justify-content: center;
svg {
display: block;
}
}
.backText {
font-weight: 600;
line-height: 18px;
}
.saveButton {
justify-self: end;
}
.loadingState {
display: flex;
align-items: center;
justify-content: center;
gap: $spacing-sm;
padding: $spacing-2xl 0;
color: var(--text-secondary);
}
.content {
display: flex;
flex-direction: column;
gap: $spacing-lg;
}
.settingsCard {
padding: 0;
overflow: hidden;
}
.settingsHeader {
display: flex;
flex-direction: column;
gap: $spacing-xs;
padding: $spacing-md $spacing-lg;
border-bottom: 1px solid var(--border-color);
background: var(--bg-primary);
}
.settingsHeaderTitle {
display: inline-flex;
align-items: center;
gap: $spacing-xs;
font-weight: 700;
color: var(--text-primary);
}
.settingsHeaderHint {
font-size: 13px;
color: var(--text-secondary);
}
.settingsSection {
display: flex;
flex-direction: column;
gap: $spacing-sm;
padding: $spacing-md $spacing-lg $spacing-lg;
background: var(--bg-primary);
}
.settingsRow {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: $spacing-lg;
}
.settingsInfo {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 4px;
}
.settingsLabel {
font-size: 14px;
font-weight: 600;
color: var(--text-primary);
}
.settingsDesc {
font-size: 13px;
color: var(--text-secondary);
}
.settingsControl {
flex: 0 0 auto;
width: min(360px, 45%);
min-width: 220px;
@include mobile {
width: 100%;
min-width: 0;
}
}
.tagList {
display: flex;
flex-wrap: wrap;
gap: $spacing-xs;
}
.tag {
display: inline-flex;
align-items: center;
padding: 4px 10px;
border-radius: $radius-full;
border: 1px solid var(--border-color);
background-color: var(--bg-secondary);
color: var(--text-secondary);
font-size: 12px;
cursor: pointer;
transition: all $transition-fast;
&:hover {
border-color: var(--primary-color);
color: var(--text-primary);
background-color: var(--bg-tertiary);
}
&:disabled {
opacity: 0.6;
cursor: not-allowed;
}
}
.tagActive {
background-color: var(--primary-color);
border-color: var(--primary-color);
color: #fff;
&:hover {
background-color: var(--primary-color);
border-color: var(--primary-color);
color: #fff;
}
}
.mappingsHeader {
display: flex;
align-items: center;
justify-content: space-between;
gap: $spacing-md;
padding: $spacing-md $spacing-lg;
border-bottom: 1px solid var(--border-color);
background: var(--bg-primary);
}
.mappingsTitle {
font-weight: 700;
color: var(--text-primary);
}
.modelsHint {
display: flex;
align-items: center;
gap: $spacing-xs;
padding: $spacing-sm $spacing-lg;
font-size: 13px;
color: var(--text-secondary);
background: var(--bg-primary);
border-bottom: 1px solid var(--border-color);
}
.mappingsBody {
padding: $spacing-sm $spacing-lg $spacing-lg;
background: var(--bg-primary);
}
.mappingRow {
display: grid;
grid-template-columns: 1fr auto 1fr auto auto;
align-items: center;
gap: $spacing-sm;
padding: 10px 0;
border-bottom: 1px solid var(--border-color);
&:last-child {
border-bottom: none;
}
@include mobile {
grid-template-columns: 1fr;
gap: $spacing-sm;
}
}
.mappingSeparator {
color: var(--text-secondary);
text-align: center;
@include mobile {
display: none;
}
}
.mappingAliasInput {
width: 100%;
}
.mappingFork {
display: flex;
align-items: center;
@include mobile {
justify-content: flex-start;
}
}

View File

@@ -0,0 +1,500 @@
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 { ToggleSwitch } from '@/components/ui/ToggleSwitch';
import { IconChevronLeft, IconInfo, IconX } 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 { generateId } from '@/utils/helpers';
import styles from './AuthFilesOAuthModelAliasEditPage.module.scss';
type AuthFileModelItem = { id: string; display_name?: string; type?: string; owned_by?: string };
type LocationState = { fromAuthFiles?: boolean } | null;
type OAuthModelMappingFormEntry = OAuthModelAliasEntry & { id: string };
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();
const buildEmptyMappingEntry = (): OAuthModelMappingFormEntry => ({
id: generateId(),
name: '',
alias: '',
fork: false,
});
const normalizeMappingEntries = (
entries?: OAuthModelAliasEntry[]
): OAuthModelMappingFormEntry[] => {
if (!Array.isArray(entries) || entries.length === 0) {
return [buildEmptyMappingEntry()];
}
return entries.map((entry) => ({
id: generateId(),
name: entry.name ?? '',
alias: entry.alias ?? '',
fork: Boolean(entry.fork),
}));
};
export function AuthFilesOAuthModelAliasEditPage() {
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 [modelAliasUnsupported, setModelAliasUnsupported] = useState(false);
const [mappings, setMappings] = useState<OAuthModelMappingFormEntry[]>([buildEmptyMappingEntry()]);
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 title = useMemo(() => t('oauth_model_alias.add_title'), [t]);
const headerHint = useMemo(() => {
if (!provider.trim()) {
return t('oauth_model_alias.provider_hint');
}
if (modelsLoading) {
return t('oauth_model_alias.model_source_loading');
}
if (modelsError === 'unsupported') {
return t('oauth_model_alias.model_source_unsupported');
}
return t('oauth_model_alias.model_source_loaded', { count: modelsList.length });
}, [modelsError, modelsList.length, modelsLoading, provider, 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);
setModelAliasUnsupported(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 (excludedResult.status === 'fulfilled') {
setExcluded(excludedResult.value ?? {});
}
if (aliasResult.status === 'fulfilled') {
setModelAlias(aliasResult.value ?? {});
return;
}
const err = aliasResult.status === 'rejected' ? aliasResult.reason : null;
const status =
typeof err === 'object' && err !== null && 'status' in err
? (err as { status?: unknown }).status
: undefined;
if (status === 404) {
setModelAliasUnsupported(true);
return;
}
} finally {
if (!cancelled) {
setInitialLoading(false);
}
}
};
load().catch(() => {
if (!cancelled) {
setInitialLoading(false);
}
});
return () => {
cancelled = true;
};
}, []);
useEffect(() => {
if (!resolvedProviderKey) {
setMappings([buildEmptyMappingEntry()]);
return;
}
const existing = modelAlias[resolvedProviderKey] ?? [];
setMappings(normalizeMappingEntries(existing));
}, [modelAlias, resolvedProviderKey]);
useEffect(() => {
if (!resolvedProviderKey || modelAliasUnsupported) {
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;
};
}, [modelAliasUnsupported, 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 updateMappingEntry = useCallback(
(index: number, field: keyof OAuthModelAliasEntry, value: string | boolean) => {
setMappings((prev) =>
prev.map((entry, idx) => (idx === index ? { ...entry, [field]: value } : entry))
);
},
[]
);
const addMappingEntry = useCallback(() => {
setMappings((prev) => [...prev, buildEmptyMappingEntry()]);
}, []);
const removeMappingEntry = useCallback((index: number) => {
setMappings((prev) => {
const next = prev.filter((_, idx) => idx !== index);
return next.length ? next : [buildEmptyMappingEntry()];
});
}, []);
const handleSave = useCallback(async () => {
const channel = provider.trim();
if (!channel) {
showNotification(t('oauth_model_alias.provider_required'), 'error');
return;
}
const seen = new Set<string>();
const normalized = mappings
.map((entry) => {
const name = String(entry.name ?? '').trim();
const alias = String(entry.alias ?? '').trim();
if (!name || !alias) return null;
const key = `${name.toLowerCase()}::${alias.toLowerCase()}::${entry.fork ? '1' : '0'}`;
if (seen.has(key)) return null;
seen.add(key);
return entry.fork ? { name, alias, fork: true } : { name, alias };
})
.filter(Boolean) as OAuthModelAliasEntry[];
setSaving(true);
try {
if (normalized.length) {
await authFilesApi.saveOauthModelAlias(channel, normalized);
} else {
await authFilesApi.deleteOauthModelAlias(channel);
}
showNotification(t('oauth_model_alias.save_success'), 'success');
handleBack();
} catch (err: unknown) {
const errorMessage = err instanceof Error ? err.message : '';
showNotification(`${t('oauth_model_alias.save_failed')}: ${errorMessage}`, 'error');
} finally {
setSaving(false);
}
}, [handleBack, mappings, provider, showNotification, t]);
const canSave = !disableControls && !saving && !modelAliasUnsupported;
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_model_alias.save')}
</Button>
</div>
{initialLoading ? (
<div className={styles.loadingState}>
<LoadingSpinner size={16} />
<span>{t('common.loading')}</span>
</div>
) : modelAliasUnsupported ? (
<Card>
<EmptyState
title={t('oauth_model_alias.upgrade_required_title')}
description={t('oauth_model_alias.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_model_alias.title')}</span>
</div>
<div className={styles.settingsHeaderHint}>{headerHint}</div>
</div>
<div className={styles.settingsSection}>
<div className={styles.settingsRow}>
<div className={styles.settingsInfo}>
<div className={styles.settingsLabel}>{t('oauth_model_alias.provider_label')}</div>
<div className={styles.settingsDesc}>{t('oauth_model_alias.provider_hint')}</div>
</div>
<div className={styles.settingsControl}>
<AutocompleteInput
id="oauth-model-alias-provider"
placeholder={t('oauth_model_alias.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.mappingsHeader}>
<div className={styles.mappingsTitle}>{t('oauth_model_alias.alias_label')}</div>
<Button
variant="secondary"
size="sm"
onClick={addMappingEntry}
disabled={disableControls || saving || modelAliasUnsupported}
>
{t('oauth_model_alias.add_alias')}
</Button>
</div>
<div className={styles.mappingsBody}>
{mappings.map((entry, index) => (
<div key={entry.id} className={styles.mappingRow}>
<AutocompleteInput
wrapperStyle={{ flex: 1, marginBottom: 0 }}
placeholder={t('oauth_model_alias.alias_name_placeholder')}
value={entry.name}
onChange={(val) => updateMappingEntry(index, 'name', val)}
disabled={disableControls || saving}
options={modelsList.map((model) => ({
value: model.id,
label:
model.display_name && model.display_name !== model.id
? model.display_name
: undefined,
}))}
/>
<span className={styles.mappingSeparator}></span>
<input
className={`input ${styles.mappingAliasInput}`}
placeholder={t('oauth_model_alias.alias_placeholder')}
value={entry.alias}
onChange={(e) => updateMappingEntry(index, 'alias', e.target.value)}
disabled={disableControls || saving}
/>
<div className={styles.mappingFork}>
<ToggleSwitch
label={t('oauth_model_alias.alias_fork_label')}
labelPosition="left"
checked={Boolean(entry.fork)}
onChange={(value) => updateMappingEntry(index, 'fork', value)}
disabled={disableControls || saving}
/>
</div>
<Button
variant="ghost"
size="sm"
onClick={() => removeMappingEntry(index)}
disabled={disableControls || saving || mappings.length <= 1}
title={t('common.delete')}
aria-label={t('common.delete')}
>
<IconX size={14} />
</Button>
</div>
))}
</div>
</Card>
</div>
)}
</div>
);
}

View File

@@ -1,12 +1,12 @@
import { useEffect, useMemo, useRef, useState, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import { useInterval } from '@/hooks/useInterval';
import { useHeaderRefresh } from '@/hooks/useHeaderRefresh';
import { Card } from '@/components/ui/Card';
import { Button } from '@/components/ui/Button';
import { LoadingSpinner } from '@/components/ui/LoadingSpinner';
import { Input } from '@/components/ui/Input';
import { AutocompleteInput } from '@/components/ui/AutocompleteInput';
import { Modal } from '@/components/ui/Modal';
import { EmptyState } from '@/components/ui/EmptyState';
import { ToggleSwitch } from '@/components/ui/ToggleSwitch';
@@ -16,7 +16,6 @@ import {
IconDownload,
IconInfo,
IconTrash2,
IconX,
} from '@/components/ui/icons';
import { useAuthStore, useNotificationStore, useThemeStore } from '@/stores';
import { authFilesApi, usageApi } from '@/services/api';
@@ -31,7 +30,6 @@ import {
type UsageDetail,
} from '@/utils/usage';
import { formatFileSize } from '@/utils/format';
import { generateId } from '@/utils/helpers';
import styles from './AuthFilesPage.module.scss';
type ThemeColors = { bg: string; text: string; border?: string };
@@ -83,36 +81,41 @@ const TYPE_COLORS: Record<string, TypeColorSet> = {
},
};
const OAUTH_PROVIDER_PRESETS = [
'gemini-cli',
'vertex',
'aistudio',
'antigravity',
'claude',
'codex',
'qwen',
'iflow',
];
const OAUTH_PROVIDER_EXCLUDES = new Set(['all', 'unknown', 'empty']);
const MIN_CARD_PAGE_SIZE = 3;
const MAX_CARD_PAGE_SIZE = 30;
const MAX_AUTH_FILE_SIZE = 50 * 1024;
const AUTH_FILES_UI_STATE_KEY = 'authFilesPage.uiState';
const clampCardPageSize = (value: number) =>
Math.min(MAX_CARD_PAGE_SIZE, Math.max(MIN_CARD_PAGE_SIZE, Math.round(value)));
interface ExcludedFormState {
provider: string;
selectedModels: Set<string>;
}
type AuthFilesUiState = {
filter?: string;
search?: string;
page?: number;
pageSize?: number;
};
type OAuthModelMappingFormEntry = OAuthModelAliasEntry & { id: string };
interface ModelAliasFormState {
provider: string;
mappings: OAuthModelMappingFormEntry[];
const readAuthFilesUiState = (): AuthFilesUiState | null => {
if (typeof window === 'undefined') return null;
try {
const raw = window.sessionStorage.getItem(AUTH_FILES_UI_STATE_KEY);
if (!raw) return null;
const parsed = JSON.parse(raw) as AuthFilesUiState;
return parsed && typeof parsed === 'object' ? parsed : null;
} catch {
return null;
}
};
const writeAuthFilesUiState = (state: AuthFilesUiState) => {
if (typeof window === 'undefined') return;
try {
window.sessionStorage.setItem(AUTH_FILES_UI_STATE_KEY, JSON.stringify(state));
} catch {
// ignore
}
};
interface PrefixProxyEditorState {
fileName: string;
@@ -125,13 +128,6 @@ interface PrefixProxyEditorState {
prefix: string;
proxyUrl: string;
}
const buildEmptyMappingEntry = (): OAuthModelMappingFormEntry => ({
id: generateId(),
name: '',
alias: '',
fork: false,
});
// 标准化 auth_index 值(与 usage.ts 中的 normalizeAuthIndex 保持一致)
function normalizeAuthIndexValue(value: unknown): string | null {
if (typeof value === 'number' && Number.isFinite(value)) {
@@ -197,6 +193,7 @@ export function AuthFilesPage() {
const { showNotification, showConfirmation } = useNotificationStore();
const connectionStatus = useAuthStore((state) => state.connectionStatus);
const resolvedTheme: ResolvedTheme = useThemeStore((state) => state.resolvedTheme);
const navigate = useNavigate();
const [files, setFiles] = useState<AuthFileItem[]>([]);
const [loading, setLoading] = useState(true);
@@ -229,28 +226,10 @@ export function AuthFilesPage() {
// OAuth 排除模型相关
const [excluded, setExcluded] = useState<Record<string, string[]>>({});
const [excludedError, setExcludedError] = useState<'unsupported' | null>(null);
const [excludedModalOpen, setExcludedModalOpen] = useState(false);
const [excludedForm, setExcludedForm] = useState<ExcludedFormState>({
provider: '',
selectedModels: new Set(),
});
const [excludedModelsList, setExcludedModelsList] = useState<AuthFileModelItem[]>([]);
const [excludedModelsLoading, setExcludedModelsLoading] = useState(false);
const [excludedModelsError, setExcludedModelsError] = useState<'unsupported' | null>(null);
const [savingExcluded, setSavingExcluded] = useState(false);
// OAuth 模型映射相关
const [modelAlias, setModelAlias] = useState<Record<string, OAuthModelAliasEntry[]>>({});
const [modelAliasError, setModelAliasError] = useState<'unsupported' | null>(null);
const [mappingModalOpen, setMappingModalOpen] = useState(false);
const [mappingForm, setMappingForm] = useState<ModelAliasFormState>({
provider: '',
mappings: [buildEmptyMappingEntry()],
});
const [mappingModelsList, setMappingModelsList] = useState<AuthFileModelItem[]>([]);
const [mappingModelsLoading, setMappingModelsLoading] = useState(false);
const [mappingModelsError, setMappingModelsError] = useState<'unsupported' | null>(null);
const [savingMappings, setSavingMappings] = useState(false);
const [prefixProxyEditor, setPrefixProxyEditor] = useState<PrefixProxyEditorState | null>(null);
@@ -263,122 +242,32 @@ export function AuthFilesPage() {
const disableControls = connectionStatus !== 'connected';
useEffect(() => {
const persisted = readAuthFilesUiState();
if (!persisted) return;
if (typeof persisted.filter === 'string' && persisted.filter.trim()) {
setFilter(persisted.filter);
}
if (typeof persisted.search === 'string') {
setSearch(persisted.search);
}
if (typeof persisted.page === 'number' && Number.isFinite(persisted.page)) {
setPage(Math.max(1, Math.round(persisted.page)));
}
if (typeof persisted.pageSize === 'number' && Number.isFinite(persisted.pageSize)) {
setPageSize(clampCardPageSize(persisted.pageSize));
}
}, []);
useEffect(() => {
writeAuthFilesUiState({ filter, search, page, pageSize });
}, [filter, search, page, pageSize]);
useEffect(() => {
setPageSizeInput(String(pageSize));
}, [pageSize]);
// 模型定义缓存(按 channel 缓存)
const modelDefinitionsCacheRef = useRef<Map<string, AuthFileModelItem[]>>(new Map());
useEffect(() => {
if (!mappingModalOpen) return;
const channel = normalizeProviderKey(mappingForm.provider);
if (!channel) {
setMappingModelsList([]);
setMappingModelsError(null);
setMappingModelsLoading(false);
return;
}
const cached = modelDefinitionsCacheRef.current.get(channel);
if (cached) {
setMappingModelsList(cached);
setMappingModelsError(null);
setMappingModelsLoading(false);
return;
}
let cancelled = false;
setMappingModelsLoading(true);
setMappingModelsError(null);
authFilesApi
.getModelDefinitions(channel)
.then((models) => {
if (cancelled) return;
modelDefinitionsCacheRef.current.set(channel, models);
setMappingModelsList(models);
})
.catch((err: unknown) => {
if (cancelled) return;
const errorMessage = err instanceof Error ? err.message : '';
if (
errorMessage.includes('404') ||
errorMessage.includes('not found') ||
errorMessage.includes('Not Found')
) {
setMappingModelsList([]);
setMappingModelsError('unsupported');
return;
}
showNotification(`${t('notification.load_failed')}: ${errorMessage}`, 'error');
})
.finally(() => {
if (cancelled) return;
setMappingModelsLoading(false);
});
return () => {
cancelled = true;
};
}, [mappingModalOpen, mappingForm.provider, showNotification, t]);
// 排除列表弹窗:根据 provider 加载模型定义
useEffect(() => {
if (!excludedModalOpen) return;
const channel = normalizeProviderKey(excludedForm.provider);
if (!channel) {
setExcludedModelsList([]);
setExcludedModelsError(null);
setExcludedModelsLoading(false);
return;
}
const cached = modelDefinitionsCacheRef.current.get(channel);
if (cached) {
setExcludedModelsList(cached);
setExcludedModelsError(null);
setExcludedModelsLoading(false);
return;
}
let cancelled = false;
setExcludedModelsLoading(true);
setExcludedModelsError(null);
authFilesApi
.getModelDefinitions(channel)
.then((models) => {
if (cancelled) return;
modelDefinitionsCacheRef.current.set(channel, models);
setExcludedModelsList(models);
})
.catch((err: unknown) => {
if (cancelled) return;
const errorMessage = err instanceof Error ? err.message : '';
if (
errorMessage.includes('404') ||
errorMessage.includes('not found') ||
errorMessage.includes('Not Found')
) {
setExcludedModelsList([]);
setExcludedModelsError('unsupported');
return;
}
showNotification(`${t('notification.load_failed')}: ${errorMessage}`, 'error');
})
.finally(() => {
if (cancelled) return;
setExcludedModelsLoading(false);
});
return () => {
cancelled = true;
};
}, [excludedModalOpen, excludedForm.provider, showNotification, t]);
const prefixProxyUpdatedText = useMemo(() => {
if (!prefixProxyEditor?.json) return prefixProxyEditor?.rawText ?? '';
const next: Record<string, unknown> = { ...prefixProxyEditor.json };
@@ -564,58 +453,6 @@ export function AuthFilesPage() {
return Array.from(types);
}, [files]);
const excludedProviderLookup = useMemo(() => {
const lookup = new Map<string, string>();
Object.keys(excluded).forEach((provider) => {
const key = provider.trim().toLowerCase();
if (key && !lookup.has(key)) {
lookup.set(key, provider);
}
});
return lookup;
}, [excluded]);
const mappingProviderLookup = useMemo(() => {
const lookup = new Map<string, string>();
Object.keys(modelAlias).forEach((provider) => {
const key = provider.trim().toLowerCase();
if (key && !lookup.has(key)) {
lookup.set(key, provider);
}
});
return lookup;
}, [modelAlias]);
const providerOptions = useMemo(() => {
const extraProviders = new Set<string>();
Object.keys(excluded).forEach((provider) => {
extraProviders.add(provider);
});
Object.keys(modelAlias).forEach((provider) => {
extraProviders.add(provider);
});
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 filtered = useMemo(() => {
return files.filter((item) => {
@@ -1060,45 +897,16 @@ export function AuthFilesPage() {
return resolvedTheme === 'dark' && set.dark ? set.dark : set.light;
};
// OAuth 排除相关方法
const openExcludedModal = (provider?: string) => {
const normalizedProvider = normalizeProviderKey(provider || '');
const fallbackProvider =
normalizedProvider || (filter !== 'all' ? normalizeProviderKey(String(filter)) : '');
const lookupKey = fallbackProvider ? excludedProviderLookup.get(fallbackProvider) : undefined;
const existingModels = lookupKey ? excluded[lookupKey] : [];
setExcludedForm({
provider: lookupKey || fallbackProvider,
selectedModels: new Set(existingModels),
const openExcludedEditor = (provider?: string) => {
const providerValue = (provider || (filter !== 'all' ? String(filter) : '')).trim();
const params = new URLSearchParams();
if (providerValue) {
params.set('provider', providerValue);
}
const search = params.toString();
navigate(`/auth-files/oauth-excluded${search ? `?${search}` : ''}`, {
state: { fromAuthFiles: true },
});
setExcludedModelsList([]);
setExcludedModelsError(null);
setExcludedModalOpen(true);
};
const saveExcludedModels = async () => {
const provider = normalizeProviderKey(excludedForm.provider);
if (!provider) {
showNotification(t('oauth_excluded.provider_required'), 'error');
return;
}
const models = [...excludedForm.selectedModels];
setSavingExcluded(true);
try {
if (models.length) {
await authFilesApi.saveOauthExcludedModels(provider, models);
} else {
await authFilesApi.deleteOauthExcludedEntry(provider);
}
await loadExcluded();
showNotification(t('oauth_excluded.save_success'), 'success');
setExcludedModalOpen(false);
} catch (err: unknown) {
const errorMessage = err instanceof Error ? err.message : '';
showNotification(`${t('oauth_excluded.save_failed')}: ${errorMessage}`, 'error');
} finally {
setSavingExcluded(false);
}
};
const deleteExcluded = async (provider: string) => {
@@ -1143,105 +951,16 @@ export function AuthFilesPage() {
});
};
// OAuth 模型映射相关方法
const normalizeMappingEntries = (
entries?: OAuthModelAliasEntry[]
): OAuthModelMappingFormEntry[] => {
if (!Array.isArray(entries) || entries.length === 0) {
return [buildEmptyMappingEntry()];
const openModelAliasEditor = (provider?: string) => {
const providerValue = (provider || (filter !== 'all' ? String(filter) : '')).trim();
const params = new URLSearchParams();
if (providerValue) {
params.set('provider', providerValue);
}
return entries.map((entry) => ({
id: generateId(),
name: entry.name ?? '',
alias: entry.alias ?? '',
fork: Boolean(entry.fork),
}));
};
const openMappingsModal = (provider?: string) => {
const normalizedProvider = (provider || '').trim();
const fallbackProvider = normalizedProvider || (filter !== 'all' ? String(filter) : '');
const lookupKey = fallbackProvider
? mappingProviderLookup.get(fallbackProvider.toLowerCase())
: undefined;
const mappings = lookupKey ? modelAlias[lookupKey] : [];
const providerValue = lookupKey || fallbackProvider;
setMappingForm({
provider: providerValue,
mappings: normalizeMappingEntries(mappings),
const search = params.toString();
navigate(`/auth-files/oauth-model-alias${search ? `?${search}` : ''}`, {
state: { fromAuthFiles: true },
});
setMappingModelsList([]);
setMappingModelsError(null);
setMappingModalOpen(true);
};
const updateMappingEntry = (
index: number,
field: keyof OAuthModelAliasEntry,
value: string | boolean
) => {
setMappingForm((prev) => ({
...prev,
mappings: prev.mappings.map((entry, idx) =>
idx === index ? { ...entry, [field]: value } : entry
),
}));
};
const addMappingEntry = () => {
setMappingForm((prev) => ({
...prev,
mappings: [...prev.mappings, buildEmptyMappingEntry()],
}));
};
const removeMappingEntry = (index: number) => {
setMappingForm((prev) => {
const next = prev.mappings.filter((_, idx) => idx !== index);
return {
...prev,
mappings: next.length ? next : [buildEmptyMappingEntry()],
};
});
};
const saveModelAlias = async () => {
const provider = mappingForm.provider.trim();
if (!provider) {
showNotification(t('oauth_model_alias.provider_required'), 'error');
return;
}
const seen = new Set<string>();
const mappings = mappingForm.mappings
.map((entry) => {
const name = String(entry.name ?? '').trim();
const alias = String(entry.alias ?? '').trim();
if (!name || !alias) return null;
const key = `${name.toLowerCase()}::${alias.toLowerCase()}::${entry.fork ? '1' : '0'}`;
if (seen.has(key)) return null;
seen.add(key);
return entry.fork ? { name, alias, fork: true } : { name, alias };
})
.filter(Boolean) as OAuthModelAliasEntry[];
setSavingMappings(true);
try {
if (mappings.length) {
await authFilesApi.saveOauthModelAlias(provider, mappings);
} else {
await authFilesApi.deleteOauthModelAlias(provider);
}
await loadModelAlias();
showNotification(t('oauth_model_alias.save_success'), 'success');
setMappingModalOpen(false);
} catch (err: unknown) {
const errorMessage = err instanceof Error ? err.message : '';
showNotification(`${t('oauth_model_alias.save_failed')}: ${errorMessage}`, 'error');
} finally {
setSavingMappings(false);
}
};
const deleteModelAlias = async (provider: string) => {
@@ -1621,7 +1340,7 @@ export function AuthFilesPage() {
extra={
<Button
size="sm"
onClick={() => openExcludedModal()}
onClick={() => openExcludedEditor()}
disabled={disableControls || excludedError === 'unsupported'}
>
{t('oauth_excluded.add')}
@@ -1648,7 +1367,7 @@ export function AuthFilesPage() {
</div>
</div>
<div className={styles.excludedActions}>
<Button variant="secondary" size="sm" onClick={() => openExcludedModal(provider)}>
<Button variant="secondary" size="sm" onClick={() => openExcludedEditor(provider)}>
{t('common.edit')}
</Button>
<Button variant="danger" size="sm" onClick={() => deleteExcluded(provider)}>
@@ -1667,7 +1386,7 @@ export function AuthFilesPage() {
extra={
<Button
size="sm"
onClick={() => openMappingsModal()}
onClick={() => openModelAliasEditor()}
disabled={disableControls || modelAliasError === 'unsupported'}
>
{t('oauth_model_alias.add')}
@@ -1694,7 +1413,11 @@ export function AuthFilesPage() {
</div>
</div>
<div className={styles.excludedActions}>
<Button variant="secondary" size="sm" onClick={() => openMappingsModal(provider)}>
<Button
variant="secondary"
size="sm"
onClick={() => openModelAliasEditor(provider)}
>
{t('common.edit')}
</Button>
<Button variant="danger" size="sm" onClick={() => deleteModelAlias(provider)}>
@@ -1893,230 +1616,6 @@ export function AuthFilesPage() {
)}
</Modal>
{/* OAuth 排除弹窗 */}
<Modal
open={excludedModalOpen}
onClose={() => setExcludedModalOpen(false)}
title={t('oauth_excluded.add_title')}
footer={
<>
<Button
variant="secondary"
onClick={() => setExcludedModalOpen(false)}
disabled={savingExcluded}
>
{t('common.cancel')}
</Button>
<Button onClick={saveExcludedModels} loading={savingExcluded}>
{t('oauth_excluded.save')}
</Button>
</>
}
>
<div className={styles.providerField}>
<AutocompleteInput
id="oauth-excluded-provider"
label={t('oauth_excluded.provider_label')}
hint={t('oauth_excluded.provider_hint')}
placeholder={t('oauth_excluded.provider_placeholder')}
value={excludedForm.provider}
onChange={(val) => setExcludedForm((prev) => ({ ...prev, provider: val }))}
options={providerOptions}
/>
{providerOptions.length > 0 && (
<div className={styles.providerTagList}>
{providerOptions.map((provider) => {
const isActive =
excludedForm.provider.trim().toLowerCase() === provider.toLowerCase();
return (
<button
key={provider}
type="button"
className={`${styles.providerTag} ${isActive ? styles.providerTagActive : ''}`}
onClick={() => setExcludedForm((prev) => ({ ...prev, provider }))}
disabled={savingExcluded}
>
{getTypeLabel(provider)}
</button>
);
})}
</div>
)}
</div>
{/* 模型勾选列表 */}
<div className={styles.formGroup}>
<label>{t('oauth_excluded.models_label')}</label>
{excludedModelsLoading ? (
<div className={styles.hint}>{t('common.loading')}</div>
) : excludedModelsList.length > 0 ? (
<>
<div className={styles.excludedCheckList}>
{excludedModelsList.map((model) => {
const isChecked = excludedForm.selectedModels.has(model.id);
return (
<label key={model.id} className={styles.excludedCheckItem}>
<input
type="checkbox"
checked={isChecked}
disabled={savingExcluded}
onChange={(e) => {
setExcludedForm((prev) => {
const next = new Set(prev.selectedModels);
if (e.target.checked) {
next.add(model.id);
} else {
next.delete(model.id);
}
return { ...prev, selectedModels: next };
});
}}
/>
<span className={styles.excludedCheckLabel}>
{model.id}
{model.display_name && model.display_name !== model.id && (
<span className={styles.excludedCheckDisplayName}>{model.display_name}</span>
)}
</span>
</label>
);
})}
</div>
{excludedForm.provider.trim() && (
<div className={styles.hint}>
{excludedModelsError === 'unsupported'
? t('oauth_excluded.models_unsupported')
: t('oauth_excluded.models_loaded', { count: excludedModelsList.length })}
</div>
)}
</>
) : excludedForm.provider.trim() && !excludedModelsLoading ? (
<div className={styles.hint}>{t('oauth_excluded.no_models_available')}</div>
) : null}
</div>
</Modal>
{/* OAuth 模型映射弹窗 */}
<Modal
open={mappingModalOpen}
onClose={() => setMappingModalOpen(false)}
title={t('oauth_model_alias.add_title')}
footer={
<>
<Button
variant="secondary"
onClick={() => setMappingModalOpen(false)}
disabled={savingMappings}
>
{t('common.cancel')}
</Button>
<Button onClick={saveModelAlias} loading={savingMappings}>
{t('oauth_model_alias.save')}
</Button>
</>
}
>
<div className={styles.providerField}>
<AutocompleteInput
id="oauth-model-alias-provider"
label={t('oauth_model_alias.provider_label')}
hint={t('oauth_model_alias.provider_hint')}
placeholder={t('oauth_model_alias.provider_placeholder')}
value={mappingForm.provider}
onChange={(val) => setMappingForm((prev) => ({ ...prev, provider: val }))}
options={providerOptions}
/>
{providerOptions.length > 0 && (
<div className={styles.providerTagList}>
{providerOptions.map((provider) => {
const isActive =
mappingForm.provider.trim().toLowerCase() === provider.toLowerCase();
return (
<button
key={provider}
type="button"
className={`${styles.providerTag} ${isActive ? styles.providerTagActive : ''}`}
onClick={() => setMappingForm((prev) => ({ ...prev, provider }))}
disabled={savingMappings}
>
{getTypeLabel(provider)}
</button>
);
})}
</div>
)}
</div>
{/* 模型定义加载状态提示 */}
{mappingForm.provider.trim() && (
<div className={styles.hint}>
{mappingModelsLoading
? t('oauth_model_alias.model_source_loading')
: mappingModelsError === 'unsupported'
? t('oauth_model_alias.model_source_unsupported')
: t('oauth_model_alias.model_source_loaded', {
count: mappingModelsList.length,
})}
</div>
)}
<div className={styles.formGroup}>
<label>{t('oauth_model_alias.alias_label')}</label>
<div className="header-input-list">
{(mappingForm.mappings.length ? mappingForm.mappings : [buildEmptyMappingEntry()]).map(
(entry, index) => (
<div key={entry.id} className={styles.mappingRow}>
<AutocompleteInput
wrapperStyle={{ flex: 1, marginBottom: 0 }}
placeholder={t('oauth_model_alias.alias_name_placeholder')}
value={entry.name}
onChange={(val) => updateMappingEntry(index, 'name', val)}
disabled={savingMappings}
options={mappingModelsList.map((m) => ({
value: m.id,
label: m.display_name && m.display_name !== m.id ? m.display_name : undefined,
}))}
/>
<span className={styles.mappingSeparator}></span>
<input
className="input"
placeholder={t('oauth_model_alias.alias_placeholder')}
value={entry.alias}
onChange={(e) => updateMappingEntry(index, 'alias', e.target.value)}
disabled={savingMappings}
style={{ flex: 1 }}
/>
<div className={styles.mappingFork}>
<ToggleSwitch
label={t('oauth_model_alias.alias_fork_label')}
labelPosition="left"
checked={Boolean(entry.fork)}
onChange={(value) => updateMappingEntry(index, 'fork', value)}
disabled={savingMappings}
/>
</div>
<Button
variant="ghost"
size="sm"
onClick={() => removeMappingEntry(index)}
disabled={savingMappings || mappingForm.mappings.length <= 1}
title={t('common.delete')}
aria-label={t('common.delete')}
>
<IconX size={14} />
</Button>
</div>
)
)}
<Button
variant="secondary"
size="sm"
onClick={addMappingEntry}
disabled={savingMappings}
className="align-start"
>
{t('oauth_model_alias.add_alias')}
</Button>
</div>
</div>
</Modal>
</div>
);
}

View File

@@ -4,6 +4,8 @@ import { SettingsPage } from '@/pages/SettingsPage';
import { ApiKeysPage } from '@/pages/ApiKeysPage';
import { AiProvidersPage } from '@/pages/AiProvidersPage';
import { AuthFilesPage } from '@/pages/AuthFilesPage';
import { AuthFilesOAuthExcludedEditPage } from '@/pages/AuthFilesOAuthExcludedEditPage';
import { AuthFilesOAuthModelAliasEditPage } from '@/pages/AuthFilesOAuthModelAliasEditPage';
import { OAuthPage } from '@/pages/OAuthPage';
import { QuotaPage } from '@/pages/QuotaPage';
import { UsagePage } from '@/pages/UsagePage';
@@ -18,6 +20,8 @@ const mainRoutes = [
{ path: '/api-keys', element: <ApiKeysPage /> },
{ path: '/ai-providers', element: <AiProvidersPage /> },
{ path: '/auth-files', element: <AuthFilesPage /> },
{ path: '/auth-files/oauth-excluded', element: <AuthFilesOAuthExcludedEditPage /> },
{ path: '/auth-files/oauth-model-alias', element: <AuthFilesOAuthModelAliasEditPage /> },
{ path: '/oauth', element: <OAuthPage /> },
{ path: '/quota', element: <QuotaPage /> },
{ path: '/usage', element: <UsagePage /> },