From 8148851a065975edfe77376dc6196e03c5654c92 Mon Sep 17 00:00:00 2001 From: LTbinglingfeng Date: Thu, 29 Jan 2026 02:21:04 +0800 Subject: [PATCH] feat: add OAuth model alias editing page and routing --- src/components/common/PageTransition.scss | 4 + src/components/common/PageTransition.tsx | 225 ++++-- src/components/layout/MainLayout.tsx | 26 + src/components/ui/icons.tsx | 8 + src/hooks/useEdgeSwipeBack.ts | 103 +++ src/i18n/locales/en.json | 1 + src/i18n/locales/zh-CN.json | 1 + ...AuthFilesOAuthExcludedEditPage.module.scss | 265 +++++++ src/pages/AuthFilesOAuthExcludedEditPage.tsx | 450 ++++++++++++ ...thFilesOAuthModelAliasEditPage.module.scss | 267 +++++++ .../AuthFilesOAuthModelAliasEditPage.tsx | 500 ++++++++++++++ src/pages/AuthFilesPage.tsx | 651 ++---------------- src/router/MainRoutes.tsx | 4 + 13 files changed, 1886 insertions(+), 619 deletions(-) create mode 100644 src/hooks/useEdgeSwipeBack.ts create mode 100644 src/pages/AuthFilesOAuthExcludedEditPage.module.scss create mode 100644 src/pages/AuthFilesOAuthExcludedEditPage.tsx create mode 100644 src/pages/AuthFilesOAuthModelAliasEditPage.module.scss create mode 100644 src/pages/AuthFilesOAuthModelAliasEditPage.tsx diff --git a/src/components/common/PageTransition.scss b/src/components/common/PageTransition.scss index 81a9e5e..1be19fa 100644 --- a/src/components/common/PageTransition.scss +++ b/src/components/common/PageTransition.scss @@ -26,6 +26,10 @@ pointer-events: none; will-change: transform, opacity; } + + &--stacked { + display: none; + } } &--animating &__layer { diff --git a/src/components/common/PageTransition.tsx b/src/components/common/PageTransition.tsx index eb0e82c..fb29a86 100644 --- a/src/components/common/PageTransition.tsx +++ b/src/components/common/PageTransition.tsx @@ -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; } -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(null); const exitingLayerRef = useRef(null); const transitionDirectionRef = useRef('forward'); + const transitionVariantRef = useRef('vertical'); const exitScrollOffsetRef = useRef(0); + const nextLayersRef = useRef(null); const [isAnimating, setIsAnimating] = useState(false); const [layers, setLayers] = useState(() => [ @@ -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,52 +175,120 @@ 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); }, }); - // Exit animation: fade out with slight movement (runs simultaneously) - if (exitingLayerEl) { - gsap.set(exitingLayerEl, { y: exitBaseY }); + 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( - exitingLayerEl, + currentLayerEl, { - y: exitBaseY + exitToY, - opacity: 0, - duration: TRANSITION_DURATION, + 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 }); + tl.to( + exitingLayerEl, + { + y: exitBaseY + exitToY, + opacity: 0, + duration: VERTICAL_TRANSITION_DURATION, + ease: 'circ.out', + force3D: true, + }, + 0 + ); + } + + // Enter animation: fade in with slight movement (runs simultaneously) + tl.fromTo( + currentLayerEl, + { y: enterFromY, opacity: 0 }, + { + y: 0, + opacity: 1, + duration: VERTICAL_TRANSITION_DURATION, ease: 'circ.out', force3D: true, + onComplete: () => { + if (currentLayerEl) { + gsap.set(currentLayerEl, { clearProps: 'transform,opacity' }); + } + }, }, 0 ); } - // Enter animation: fade in with slight movement (runs simultaneously) - tl.fromTo( - currentLayerEl, - { y: enterFromY, opacity: 0 }, - { - y: 0, - opacity: 1, - duration: TRANSITION_DURATION, - ease: 'circ.out', - force3D: true, - onComplete: () => { - if (currentLayerEl) { - gsap.set(currentLayerEl, { clearProps: 'transform,opacity' }); - } - }, - }, - 0 - ); - return () => { tl.kill(); gsap.killTweensOf([currentLayerEl, exitingLayerEl]); @@ -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)} diff --git a/src/components/layout/MainLayout.tsx b/src/components/layout/MainLayout.tsx index 4226607..72af167 100644 --- a/src/components/layout/MainLayout.tsx +++ b/src/components/layout/MainLayout.tsx @@ -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() { } getRouteOrder={getRouteOrder} + getTransitionVariant={getTransitionVariant} scrollContainerRef={contentRef} /> diff --git a/src/components/ui/icons.tsx b/src/components/ui/icons.tsx index de658a8..53487e4 100644 --- a/src/components/ui/icons.tsx +++ b/src/components/ui/icons.tsx @@ -164,6 +164,14 @@ export function IconChevronDown({ size = 20, ...props }: IconProps) { ); } +export function IconChevronLeft({ size = 20, ...props }: IconProps) { + return ( + + + + ); +} + export function IconSearch({ size = 20, ...props }: IconProps) { return ( diff --git a/src/hooks/useEdgeSwipeBack.ts b/src/hooks/useEdgeSwipeBack.ts new file mode 100644 index 0000000..e870ab2 --- /dev/null +++ b/src/hooks/useEdgeSwipeBack.ts @@ -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(null); + const gestureRef = useRef(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; +} + diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 5fc08e5..4bad12b 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -2,6 +2,7 @@ "common": { "login": "Login", "logout": "Logout", + "back": "Back", "cancel": "Cancel", "confirm": "Confirm", "save": "Save", diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index 42906bd..469aaa2 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -2,6 +2,7 @@ "common": { "login": "登录", "logout": "登出", + "back": "返回", "cancel": "取消", "confirm": "确认", "save": "保存", diff --git a/src/pages/AuthFilesOAuthExcludedEditPage.module.scss b/src/pages/AuthFilesOAuthExcludedEditPage.module.scss new file mode 100644 index 0000000..4a710e3 --- /dev/null +++ b/src/pages/AuthFilesOAuthExcludedEditPage.module.scss @@ -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); +} diff --git a/src/pages/AuthFilesOAuthExcludedEditPage.tsx b/src/pages/AuthFilesOAuthExcludedEditPage.tsx new file mode 100644 index 0000000..5042276 --- /dev/null +++ b/src/pages/AuthFilesOAuthExcludedEditPage.tsx @@ -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([]); + const [excluded, setExcluded] = useState>({}); + const [modelAlias, setModelAlias] = useState>({}); + const [initialLoading, setInitialLoading] = useState(true); + const [excludedUnsupported, setExcludedUnsupported] = useState(false); + + const [selectedModels, setSelectedModels] = useState>(new Set()); + const [modelsList, setModelsList] = useState([]); + 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(); + 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 ( +
+
+ +
+ {title} +
+ +
+ + {initialLoading ? ( +
+ + {t('common.loading')} +
+ ) : excludedUnsupported ? ( + + + + ) : ( +
+ +
+
+ + {t('oauth_excluded.title')} +
+
{t('oauth_excluded.description')}
+
+ +
+
+
+
{t('oauth_excluded.provider_label')}
+
{t('oauth_excluded.provider_hint')}
+
+
+ +
+
+ + {providerOptions.length > 0 && ( +
+ {providerOptions.map((option) => { + const isActive = normalizeProviderKey(provider) === option.toLowerCase(); + return ( + + ); + })} +
+ )} +
+
+ + +
+
{t('oauth_excluded.models_label')}
+ {resolvedProviderKey && ( +
+ {modelsLoading ? ( + <> + + {t('oauth_excluded.models_loading')} + + ) : modelsError === 'unsupported' ? ( + {t('oauth_excluded.models_unsupported')} + ) : modelsList.length > 0 ? ( + {t('oauth_excluded.models_loaded', { count: modelsList.length })} + ) : ( + {t('oauth_excluded.no_models_available')} + )} +
+ )} +
+ + {modelsLoading ? ( +
+ + {t('common.loading')} +
+ ) : modelsList.length > 0 ? ( +
+ {modelsList.map((model) => { + const checked = selectedModels.has(model.id); + return ( + + ); + })} +
+ ) : resolvedProviderKey ? ( +
+ {modelsError === 'unsupported' + ? t('oauth_excluded.models_unsupported') + : t('oauth_excluded.no_models_available')} +
+ ) : ( +
{t('oauth_excluded.provider_required')}
+ )} +
+
+ )} +
+ ); +} diff --git a/src/pages/AuthFilesOAuthModelAliasEditPage.module.scss b/src/pages/AuthFilesOAuthModelAliasEditPage.module.scss new file mode 100644 index 0000000..a3c440f --- /dev/null +++ b/src/pages/AuthFilesOAuthModelAliasEditPage.module.scss @@ -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; + } +} diff --git a/src/pages/AuthFilesOAuthModelAliasEditPage.tsx b/src/pages/AuthFilesOAuthModelAliasEditPage.tsx new file mode 100644 index 0000000..63df6ff --- /dev/null +++ b/src/pages/AuthFilesOAuthModelAliasEditPage.tsx @@ -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([]); + const [excluded, setExcluded] = useState>({}); + const [modelAlias, setModelAlias] = useState>({}); + const [initialLoading, setInitialLoading] = useState(true); + const [modelAliasUnsupported, setModelAliasUnsupported] = useState(false); + + const [mappings, setMappings] = useState([buildEmptyMappingEntry()]); + const [modelsList, setModelsList] = useState([]); + 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(); + 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(); + 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 ( +
+
+ +
+ {title} +
+ +
+ + {initialLoading ? ( +
+ + {t('common.loading')} +
+ ) : modelAliasUnsupported ? ( + + + + ) : ( +
+ +
+
+ + {t('oauth_model_alias.title')} +
+
{headerHint}
+
+ +
+
+
+
{t('oauth_model_alias.provider_label')}
+
{t('oauth_model_alias.provider_hint')}
+
+
+ +
+
+ + {providerOptions.length > 0 && ( +
+ {providerOptions.map((option) => { + const isActive = normalizeProviderKey(provider) === option.toLowerCase(); + return ( + + ); + })} +
+ )} +
+
+ + +
+
{t('oauth_model_alias.alias_label')}
+ +
+ +
+ {mappings.map((entry, index) => ( +
+ 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, + }))} + /> + + updateMappingEntry(index, 'alias', e.target.value)} + disabled={disableControls || saving} + /> +
+ updateMappingEntry(index, 'fork', value)} + disabled={disableControls || saving} + /> +
+ +
+ ))} +
+
+
+ )} +
+ ); +} diff --git a/src/pages/AuthFilesPage.tsx b/src/pages/AuthFilesPage.tsx index 5fbc669..b1328a8 100644 --- a/src/pages/AuthFilesPage.tsx +++ b/src/pages/AuthFilesPage.tsx @@ -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 = { }, }; -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; -} +type AuthFilesUiState = { + filter?: string; + search?: string; + page?: number; + pageSize?: number; +}; -type OAuthModelMappingFormEntry = OAuthModelAliasEntry & { id: string }; +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; + } +}; -interface ModelAliasFormState { - provider: string; - mappings: OAuthModelMappingFormEntry[]; -} +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([]); const [loading, setLoading] = useState(true); @@ -229,28 +226,10 @@ export function AuthFilesPage() { // OAuth 排除模型相关 const [excluded, setExcluded] = useState>({}); const [excludedError, setExcludedError] = useState<'unsupported' | null>(null); - const [excludedModalOpen, setExcludedModalOpen] = useState(false); - const [excludedForm, setExcludedForm] = useState({ - provider: '', - selectedModels: new Set(), - }); - const [excludedModelsList, setExcludedModelsList] = useState([]); - const [excludedModelsLoading, setExcludedModelsLoading] = useState(false); - const [excludedModelsError, setExcludedModelsError] = useState<'unsupported' | null>(null); - const [savingExcluded, setSavingExcluded] = useState(false); // OAuth 模型映射相关 const [modelAlias, setModelAlias] = useState>({}); const [modelAliasError, setModelAliasError] = useState<'unsupported' | null>(null); - const [mappingModalOpen, setMappingModalOpen] = useState(false); - const [mappingForm, setMappingForm] = useState({ - provider: '', - mappings: [buildEmptyMappingEntry()], - }); - const [mappingModelsList, setMappingModelsList] = useState([]); - const [mappingModelsLoading, setMappingModelsLoading] = useState(false); - const [mappingModelsError, setMappingModelsError] = useState<'unsupported' | null>(null); - const [savingMappings, setSavingMappings] = useState(false); const [prefixProxyEditor, setPrefixProxyEditor] = useState(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>(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 = { ...prefixProxyEditor.json }; @@ -564,58 +453,6 @@ export function AuthFilesPage() { return Array.from(types); }, [files]); - const excludedProviderLookup = useMemo(() => { - const lookup = new Map(); - 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(); - 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(); - - 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(); - 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={ - - - } - > -
- setExcludedForm((prev) => ({ ...prev, provider: val }))} - options={providerOptions} - /> - {providerOptions.length > 0 && ( -
- {providerOptions.map((provider) => { - const isActive = - excludedForm.provider.trim().toLowerCase() === provider.toLowerCase(); - return ( - - ); - })} -
- )} -
- {/* 模型勾选列表 */} -
- - {excludedModelsLoading ? ( -
{t('common.loading')}
- ) : excludedModelsList.length > 0 ? ( - <> -
- {excludedModelsList.map((model) => { - const isChecked = excludedForm.selectedModels.has(model.id); - return ( - - ); - })} -
- {excludedForm.provider.trim() && ( -
- {excludedModelsError === 'unsupported' - ? t('oauth_excluded.models_unsupported') - : t('oauth_excluded.models_loaded', { count: excludedModelsList.length })} -
- )} - - ) : excludedForm.provider.trim() && !excludedModelsLoading ? ( -
{t('oauth_excluded.no_models_available')}
- ) : null} -
- - - {/* OAuth 模型映射弹窗 */} - setMappingModalOpen(false)} - title={t('oauth_model_alias.add_title')} - footer={ - <> - - - - } - > -
- setMappingForm((prev) => ({ ...prev, provider: val }))} - options={providerOptions} - /> - {providerOptions.length > 0 && ( -
- {providerOptions.map((provider) => { - const isActive = - mappingForm.provider.trim().toLowerCase() === provider.toLowerCase(); - return ( - - ); - })} -
- )} -
- {/* 模型定义加载状态提示 */} - {mappingForm.provider.trim() && ( -
- {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, - })} -
- )} -
- -
- {(mappingForm.mappings.length ? mappingForm.mappings : [buildEmptyMappingEntry()]).map( - (entry, index) => ( -
- 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, - }))} - /> - - updateMappingEntry(index, 'alias', e.target.value)} - disabled={savingMappings} - style={{ flex: 1 }} - /> -
- updateMappingEntry(index, 'fork', value)} - disabled={savingMappings} - /> -
- -
- ) - )} - -
-
-
); } diff --git a/src/router/MainRoutes.tsx b/src/router/MainRoutes.tsx index ee01d80..734bf86 100644 --- a/src/router/MainRoutes.tsx +++ b/src/router/MainRoutes.tsx @@ -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: }, { path: '/ai-providers', element: }, { path: '/auth-files', element: }, + { path: '/auth-files/oauth-excluded', element: }, + { path: '/auth-files/oauth-model-alias', element: }, { path: '/oauth', element: }, { path: '/quota', element: }, { path: '/usage', element: },