Merge branch 'dev'

This commit is contained in:
LTbinglingfeng
2026-01-02 01:58:06 +08:00
17 changed files with 333 additions and 63 deletions

7
package-lock.json generated
View File

@@ -12,6 +12,7 @@
"@uiw/react-codemirror": "^4.25.3", "@uiw/react-codemirror": "^4.25.3",
"axios": "^1.13.2", "axios": "^1.13.2",
"chart.js": "^4.5.1", "chart.js": "^4.5.1",
"gsap": "^3.14.2",
"i18next": "^25.7.1", "i18next": "^25.7.1",
"react": "^19.2.1", "react": "^19.2.1",
"react-chartjs-2": "^5.3.1", "react-chartjs-2": "^5.3.1",
@@ -3194,6 +3195,12 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/gsap": {
"version": "3.14.2",
"resolved": "https://registry.npmjs.org/gsap/-/gsap-3.14.2.tgz",
"integrity": "sha512-P8/mMxVLU7o4+55+1TCnQrPmgjPKnwkzkXOK1asnR9Jg2lna4tEY5qBJjMmAaOBDDZWtlRjBXjLa0w53G/uBLA==",
"license": "Standard 'no charge' license: https://gsap.com/standard-license."
},
"node_modules/has-flag": { "node_modules/has-flag": {
"version": "4.0.0", "version": "4.0.0",
"dev": true, "dev": true,

View File

@@ -16,6 +16,7 @@
"@uiw/react-codemirror": "^4.25.3", "@uiw/react-codemirror": "^4.25.3",
"axios": "^1.13.2", "axios": "^1.13.2",
"chart.js": "^4.5.1", "chart.js": "^4.5.1",
"gsap": "^3.14.2",
"i18next": "^25.7.1", "i18next": "^25.7.1",
"react": "^19.2.1", "react": "^19.2.1",
"react-chartjs-2": "^5.3.1", "react-chartjs-2": "^5.3.1",

View File

@@ -1,17 +1,6 @@
import { useCallback, useEffect, useState } from 'react'; import { useCallback, useEffect, useState } from 'react';
import { HashRouter, Navigate, Route, Routes } from 'react-router-dom'; import { HashRouter, Route, Routes } from 'react-router-dom';
import { LoginPage } from '@/pages/LoginPage'; import { LoginPage } from '@/pages/LoginPage';
import { DashboardPage } from '@/pages/DashboardPage';
import { SettingsPage } from '@/pages/SettingsPage';
import { ApiKeysPage } from '@/pages/ApiKeysPage';
import { AiProvidersPage } from '@/pages/AiProvidersPage';
import { AuthFilesPage } from '@/pages/AuthFilesPage';
import { OAuthPage } from '@/pages/OAuthPage';
import { QuotaPage } from '@/pages/QuotaPage';
import { UsagePage } from '@/pages/UsagePage';
import { ConfigPage } from '@/pages/ConfigPage';
import { LogsPage } from '@/pages/LogsPage';
import { SystemPage } from '@/pages/SystemPage';
import { NotificationContainer } from '@/components/common/NotificationContainer'; import { NotificationContainer } from '@/components/common/NotificationContainer';
import { SplashScreen } from '@/components/common/SplashScreen'; import { SplashScreen } from '@/components/common/SplashScreen';
import { MainLayout } from '@/components/layout/MainLayout'; import { MainLayout } from '@/components/layout/MainLayout';
@@ -75,27 +64,13 @@ function App() {
<Routes> <Routes>
<Route path="/login" element={<LoginPage />} /> <Route path="/login" element={<LoginPage />} />
<Route <Route
path="/" path="/*"
element={ element={
<ProtectedRoute> <ProtectedRoute>
<MainLayout /> <MainLayout />
</ProtectedRoute> </ProtectedRoute>
} }
> />
<Route index element={<DashboardPage />} />
<Route path="dashboard" element={<DashboardPage />} />
<Route path="settings" element={<SettingsPage />} />
<Route path="api-keys" element={<ApiKeysPage />} />
<Route path="ai-providers" element={<AiProvidersPage />} />
<Route path="auth-files" element={<AuthFilesPage />} />
<Route path="oauth" element={<OAuthPage />} />
<Route path="quota" element={<QuotaPage />} />
<Route path="usage" element={<UsagePage />} />
<Route path="config" element={<ConfigPage />} />
<Route path="logs" element={<LogsPage />} />
<Route path="system" element={<SystemPage />} />
<Route path="*" element={<Navigate to="/" replace />} />
</Route>
</Routes> </Routes>
</HashRouter> </HashRouter>
); );

View File

@@ -0,0 +1,34 @@
@use '@/styles/variables.scss' as *;
.page-transition {
position: relative;
flex: 1 1 auto;
display: flex;
flex-direction: column;
min-height: 0;
overflow: hidden;
&__layer {
display: flex;
flex-direction: column;
gap: $spacing-lg;
min-height: 0;
flex: 1;
will-change: transform, opacity;
// During animation, exit layer uses absolute positioning
&--exit {
position: absolute;
inset: 0;
z-index: 1;
overflow: hidden;
pointer-events: none;
}
}
// When both layers exist, current layer also needs positioning
&--animating &__layer:not(&__layer--exit) {
position: relative;
z-index: 0;
}
}

View File

@@ -0,0 +1,159 @@
import { ReactNode, useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react';
import { useLocation, type Location } from 'react-router-dom';
import gsap from 'gsap';
import './PageTransition.scss';
interface PageTransitionProps {
render: (location: Location) => ReactNode;
getRouteOrder?: (pathname: string) => number | null;
scrollContainerRef?: React.RefObject<HTMLElement | null>;
}
const TRANSITION_DURATION = 0.65;
type LayerStatus = 'current' | 'exiting';
type Layer = {
key: string;
location: Location;
status: LayerStatus;
};
type TransitionDirection = 'forward' | 'backward';
export function PageTransition({
render,
getRouteOrder,
scrollContainerRef,
}: PageTransitionProps) {
const location = useLocation();
const currentLayerRef = useRef<HTMLDivElement>(null);
const exitingLayerRef = useRef<HTMLDivElement>(null);
const exitScrollOffsetRef = useRef(0);
const [isAnimating, setIsAnimating] = useState(false);
const [transitionDirection, setTransitionDirection] = useState<TransitionDirection>('forward');
const [layers, setLayers] = useState<Layer[]>(() => [
{
key: location.key,
location,
status: 'current',
},
]);
const currentLayerKey = layers[layers.length - 1]?.key ?? location.key;
const currentLayerPathname = layers[layers.length - 1]?.location.pathname;
const resolveScrollContainer = useCallback(() => {
if (scrollContainerRef?.current) return scrollContainerRef.current;
if (typeof document === 'undefined') return null;
return document.scrollingElement as HTMLElement | null;
}, [scrollContainerRef]);
useEffect(() => {
if (isAnimating) return;
if (location.key === currentLayerKey) return;
const scrollContainer = resolveScrollContainer();
exitScrollOffsetRef.current = scrollContainer?.scrollTop ?? 0;
const resolveOrderIndex = (pathname?: string) => {
if (!getRouteOrder || !pathname) return null;
const index = getRouteOrder(pathname);
return typeof index === 'number' && index >= 0 ? index : null;
};
const fromIndex = resolveOrderIndex(currentLayerPathname);
const toIndex = resolveOrderIndex(location.pathname);
const nextDirection: TransitionDirection =
fromIndex === null || toIndex === null || fromIndex === toIndex
? 'forward'
: toIndex > fromIndex
? 'forward'
: 'backward';
setTransitionDirection(nextDirection);
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' },
];
});
setIsAnimating(true);
}, [
isAnimating,
location,
currentLayerKey,
currentLayerPathname,
getRouteOrder,
resolveScrollContainer,
]);
// Run GSAP animation when animating starts
useLayoutEffect(() => {
if (!isAnimating) return;
if (!currentLayerRef.current) return;
const scrollContainer = resolveScrollContainer();
const scrollOffset = exitScrollOffsetRef.current;
if (scrollContainer && scrollOffset > 0) {
scrollContainer.scrollTo({ top: 0, left: 0, behavior: 'auto' });
}
const tl = gsap.timeline({
onComplete: () => {
setLayers((prev) => prev.filter((layer) => layer.status !== 'exiting'));
setIsAnimating(false);
},
});
// Exit animation: fly out to top (slow-to-fast)
if (exitingLayerRef.current) {
gsap.set(exitingLayerRef.current, { y: scrollOffset ? -scrollOffset : 0 });
tl.fromTo(
exitingLayerRef.current,
{ yPercent: 0, opacity: 1 },
{
yPercent: transitionDirection === 'forward' ? -100 : 100,
opacity: 0,
duration: TRANSITION_DURATION,
ease: 'power3.in', // slow start, fast end
},
0
);
}
// Enter animation: slide in from bottom (slow-to-fast)
tl.fromTo(
currentLayerRef.current,
{ yPercent: transitionDirection === 'forward' ? 100 : -100, opacity: 0 },
{
yPercent: 0,
opacity: 1,
duration: TRANSITION_DURATION,
ease: 'power2.in', // slow start, fast end
},
0
);
return () => {
tl.kill();
gsap.killTweensOf([currentLayerRef.current, exitingLayerRef.current]);
};
}, [isAnimating, transitionDirection, resolveScrollContainer]);
return (
<div className={`page-transition${isAnimating ? ' page-transition--animating' : ''}`}>
{layers.map((layer) => (
<div
key={layer.key}
className={`page-transition__layer${
layer.status === 'exiting' ? ' page-transition__layer--exit' : ''
}`}
ref={layer.status === 'exiting' ? exitingLayerRef : currentLayerRef}
>
{render(layer.location)}
</div>
))}
</div>
);
}

View File

@@ -7,11 +7,13 @@ import {
useRef, useRef,
useState, useState,
} from 'react'; } from 'react';
import { NavLink, Outlet, useLocation } from 'react-router-dom'; import { NavLink, useLocation } from 'react-router-dom';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Button } from '@/components/ui/Button'; import { Button } from '@/components/ui/Button';
import { Modal } from '@/components/ui/Modal'; import { Modal } from '@/components/ui/Modal';
import { ToggleSwitch } from '@/components/ui/ToggleSwitch'; import { ToggleSwitch } from '@/components/ui/ToggleSwitch';
import { PageTransition } from '@/components/common/PageTransition';
import { MainRoutes } from '@/router/MainRoutes';
import { import {
IconBot, IconBot,
IconChartLine, IconChartLine,
@@ -200,6 +202,7 @@ export function MainLayout() {
const [requestLogDraft, setRequestLogDraft] = useState(false); const [requestLogDraft, setRequestLogDraft] = useState(false);
const [requestLogTouched, setRequestLogTouched] = useState(false); const [requestLogTouched, setRequestLogTouched] = useState(false);
const [requestLogSaving, setRequestLogSaving] = useState(false); const [requestLogSaving, setRequestLogSaving] = useState(false);
const contentRef = useRef<HTMLDivElement | null>(null);
const brandCollapseTimer = useRef<ReturnType<typeof setTimeout> | null>(null); const brandCollapseTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const headerRef = useRef<HTMLElement | null>(null); const headerRef = useRef<HTMLElement | null>(null);
const versionTapCount = useRef(0); const versionTapCount = useRef(0);
@@ -341,6 +344,7 @@ export function MainLayout() {
}); });
}, [fetchConfig]); }, [fetchConfig]);
const statusClass = const statusClass =
connectionStatus === 'connected' connectionStatus === 'connected'
? 'success' ? 'success'
@@ -365,6 +369,18 @@ export function MainLayout() {
: []), : []),
{ path: '/system', label: t('nav.system_info'), icon: sidebarIcons.system }, { path: '/system', label: t('nav.system_info'), icon: sidebarIcons.system },
]; ];
const navOrder = navItems.map((item) => item.path);
const getRouteOrder = (pathname: string) => {
const trimmedPath =
pathname.length > 1 && pathname.endsWith('/') ? pathname.slice(0, -1) : pathname;
const normalizedPath = trimmedPath === '/dashboard' ? '/' : trimmedPath;
const exactIndex = navOrder.indexOf(normalizedPath);
if (exactIndex !== -1) return exactIndex;
const nestedIndex = navOrder.findIndex(
(path) => path !== '/' && normalizedPath.startsWith(`${path}/`)
);
return nestedIndex === -1 ? null : nestedIndex;
};
const handleRefreshAll = async () => { const handleRefreshAll = async () => {
clearCache(); clearCache();
@@ -508,9 +524,13 @@ export function MainLayout() {
</div> </div>
</aside> </aside>
<div className={`content${isLogsPage ? ' content-logs' : ''}`}> <div className={`content${isLogsPage ? ' content-logs' : ''}`} ref={contentRef}>
<main className={`main-content${isLogsPage ? ' main-content-logs' : ''}`}> <main className={`main-content${isLogsPage ? ' main-content-logs' : ''}`}>
<Outlet /> <PageTransition
render={(location) => <MainRoutes location={location} />}
getRouteOrder={getRouteOrder}
scrollContainerRef={contentRef}
/>
</main> </main>
<footer className="footer"> <footer className="footer">

View File

@@ -1,4 +1,5 @@
import { useState, useEffect, useCallback, useRef, type PropsWithChildren, type ReactNode } from 'react'; import { useState, useEffect, useCallback, useRef, type PropsWithChildren, type ReactNode } from 'react';
import { createPortal } from 'react-dom';
import { IconX } from './icons'; import { IconX } from './icons';
interface ModalProps { interface ModalProps {
@@ -10,6 +11,26 @@ interface ModalProps {
} }
const CLOSE_ANIMATION_DURATION = 350; const CLOSE_ANIMATION_DURATION = 350;
const MODAL_LOCK_CLASS = 'modal-open';
let activeModalCount = 0;
const lockScroll = () => {
if (typeof document === 'undefined') return;
if (activeModalCount === 0) {
document.body?.classList.add(MODAL_LOCK_CLASS);
document.documentElement?.classList.add(MODAL_LOCK_CLASS);
}
activeModalCount += 1;
};
const unlockScroll = () => {
if (typeof document === 'undefined') return;
activeModalCount = Math.max(0, activeModalCount - 1);
if (activeModalCount === 0) {
document.body?.classList.remove(MODAL_LOCK_CLASS);
document.documentElement?.classList.remove(MODAL_LOCK_CLASS);
}
};
export function Modal({ open, title, onClose, footer, width = 520, children }: PropsWithChildren<ModalProps>) { export function Modal({ open, title, onClose, footer, width = 520, children }: PropsWithChildren<ModalProps>) {
const [isVisible, setIsVisible] = useState(false); const [isVisible, setIsVisible] = useState(false);
@@ -60,12 +81,20 @@ export function Modal({ open, title, onClose, footer, width = 520, children }: P
}; };
}, []); }, []);
const shouldLockScroll = open || isVisible;
useEffect(() => {
if (!shouldLockScroll) return;
lockScroll();
return () => unlockScroll();
}, [shouldLockScroll]);
if (!open && !isVisible) return null; if (!open && !isVisible) return null;
const overlayClass = `modal-overlay ${isClosing ? 'modal-overlay-closing' : 'modal-overlay-entering'}`; const overlayClass = `modal-overlay ${isClosing ? 'modal-overlay-closing' : 'modal-overlay-entering'}`;
const modalClass = `modal ${isClosing ? 'modal-closing' : 'modal-entering'}`; const modalClass = `modal ${isClosing ? 'modal-closing' : 'modal-entering'}`;
return ( const modalContent = (
<div className={overlayClass}> <div className={overlayClass}>
<div className={modalClass} style={{ width }} role="dialog" aria-modal="true"> <div className={modalClass} style={{ width }} role="dialog" aria-modal="true">
<button className="modal-close-floating" onClick={handleClose} aria-label="Close"> <button className="modal-close-floating" onClick={handleClose} aria-label="Close">
@@ -79,4 +108,10 @@ export function Modal({ open, title, onClose, footer, width = 520, children }: P
</div> </div>
</div> </div>
); );
if (typeof document === 'undefined') {
return modalContent;
}
return createPortal(modalContent, document.body);
} }

View File

@@ -202,7 +202,7 @@ const buildAmpcodeFormState = (ampcode?: AmpcodeConfig | null): AmpcodeFormState
export function AiProvidersPage() { export function AiProvidersPage() {
const { t } = useTranslation(); const { t } = useTranslation();
const { showNotification } = useNotificationStore(); const { showNotification } = useNotificationStore();
const { theme } = useThemeStore(); const resolvedTheme = useThemeStore((state) => state.resolvedTheme);
const connectionStatus = useAuthStore((state) => state.connectionStatus); const connectionStatus = useAuthStore((state) => state.connectionStatus);
const config = useConfigStore((state) => state.config); const config = useConfigStore((state) => state.config);
@@ -1221,7 +1221,6 @@ export function AiProvidersPage() {
renderContent: (item: T, index: number) => ReactNode, renderContent: (item: T, index: number) => ReactNode,
onEdit: (index: number) => void, onEdit: (index: number) => void,
onDelete: (item: T) => void, onDelete: (item: T) => void,
addLabel: string,
emptyTitle: string, emptyTitle: string,
emptyDescription: string, emptyDescription: string,
deleteLabel?: string, deleteLabel?: string,
@@ -1239,11 +1238,6 @@ export function AiProvidersPage() {
<EmptyState <EmptyState
title={emptyTitle} title={emptyTitle}
description={emptyDescription} description={emptyDescription}
action={
<Button onClick={() => onEdit(-1)} disabled={disableControls}>
{addLabel}
</Button>
}
/> />
); );
} }
@@ -1388,7 +1382,6 @@ export function AiProvidersPage() {
}, },
(index) => openGeminiModal(index), (index) => openGeminiModal(index),
(item) => deleteGemini(item.apiKey), (item) => deleteGemini(item.apiKey),
t('ai_providers.gemini_add_button'),
t('ai_providers.gemini_empty_title'), t('ai_providers.gemini_empty_title'),
t('ai_providers.gemini_empty_desc'), t('ai_providers.gemini_empty_desc'),
undefined, undefined,
@@ -1409,7 +1402,11 @@ export function AiProvidersPage() {
<Card <Card
title={ title={
<span className={styles.cardTitle}> <span className={styles.cardTitle}>
<img src={theme === 'dark' ? iconOpenaiDark : iconOpenaiLight} alt="" className={styles.cardTitleIcon} /> <img
src={resolvedTheme === 'dark' ? iconOpenaiDark : iconOpenaiLight}
alt=""
className={styles.cardTitleIcon}
/>
{t('ai_providers.codex_title')} {t('ai_providers.codex_title')}
</span> </span>
} }
@@ -1508,7 +1505,6 @@ export function AiProvidersPage() {
}, },
(index) => openProviderModal('codex', index), (index) => openProviderModal('codex', index),
(item) => deleteProviderEntry('codex', item.apiKey), (item) => deleteProviderEntry('codex', item.apiKey),
t('ai_providers.codex_add_button'),
t('ai_providers.codex_empty_title'), t('ai_providers.codex_empty_title'),
t('ai_providers.codex_empty_desc'), t('ai_providers.codex_empty_desc'),
undefined, undefined,
@@ -1644,7 +1640,6 @@ export function AiProvidersPage() {
}, },
(index) => openProviderModal('claude', index), (index) => openProviderModal('claude', index),
(item) => deleteProviderEntry('claude', item.apiKey), (item) => deleteProviderEntry('claude', item.apiKey),
t('ai_providers.claude_add_button'),
t('ai_providers.claude_empty_title'), t('ai_providers.claude_empty_title'),
t('ai_providers.claude_empty_desc'), t('ai_providers.claude_empty_desc'),
undefined, undefined,
@@ -1743,7 +1738,11 @@ export function AiProvidersPage() {
<Card <Card
title={ title={
<span className={styles.cardTitle}> <span className={styles.cardTitle}>
<img src={theme === 'dark' ? iconOpenaiDark : iconOpenaiLight} alt="" className={styles.cardTitleIcon} /> <img
src={resolvedTheme === 'dark' ? iconOpenaiDark : iconOpenaiLight}
alt=""
className={styles.cardTitleIcon}
/>
{t('ai_providers.openai_title')} {t('ai_providers.openai_title')}
</span> </span>
} }
@@ -1867,7 +1866,6 @@ export function AiProvidersPage() {
}, },
(index) => openOpenaiModal(index), (index) => openOpenaiModal(index),
(item) => deleteOpenai(item.name), (item) => deleteOpenai(item.name),
t('ai_providers.openai_add_button'),
t('ai_providers.openai_empty_title'), t('ai_providers.openai_empty_title'),
t('ai_providers.openai_empty_desc') t('ai_providers.openai_empty_desc')
)} )}

View File

@@ -59,11 +59,10 @@ const PROVIDERS: { id: OAuthProvider; titleKey: string; hintKey: string; urlLabe
{ id: 'anthropic', titleKey: 'auth_login.anthropic_oauth_title', hintKey: 'auth_login.anthropic_oauth_hint', urlLabelKey: 'auth_login.anthropic_oauth_url_label', icon: iconClaude }, { id: 'anthropic', titleKey: 'auth_login.anthropic_oauth_title', hintKey: 'auth_login.anthropic_oauth_hint', urlLabelKey: 'auth_login.anthropic_oauth_url_label', icon: iconClaude },
{ id: 'antigravity', titleKey: 'auth_login.antigravity_oauth_title', hintKey: 'auth_login.antigravity_oauth_hint', urlLabelKey: 'auth_login.antigravity_oauth_url_label', icon: iconAntigravity }, { id: 'antigravity', titleKey: 'auth_login.antigravity_oauth_title', hintKey: 'auth_login.antigravity_oauth_hint', urlLabelKey: 'auth_login.antigravity_oauth_url_label', icon: iconAntigravity },
{ id: 'gemini-cli', titleKey: 'auth_login.gemini_cli_oauth_title', hintKey: 'auth_login.gemini_cli_oauth_hint', urlLabelKey: 'auth_login.gemini_cli_oauth_url_label', icon: iconGemini }, { id: 'gemini-cli', titleKey: 'auth_login.gemini_cli_oauth_title', hintKey: 'auth_login.gemini_cli_oauth_hint', urlLabelKey: 'auth_login.gemini_cli_oauth_url_label', icon: iconGemini },
{ id: 'qwen', titleKey: 'auth_login.qwen_oauth_title', hintKey: 'auth_login.qwen_oauth_hint', urlLabelKey: 'auth_login.qwen_oauth_url_label', icon: iconQwen }, { id: 'qwen', titleKey: 'auth_login.qwen_oauth_title', hintKey: 'auth_login.qwen_oauth_hint', urlLabelKey: 'auth_login.qwen_oauth_url_label', icon: iconQwen }
{ id: 'iflow', titleKey: 'auth_login.iflow_oauth_title', hintKey: 'auth_login.iflow_oauth_hint', urlLabelKey: 'auth_login.iflow_oauth_url_label', icon: iconIflow }
]; ];
const CALLBACK_SUPPORTED: OAuthProvider[] = ['codex', 'anthropic', 'antigravity', 'gemini-cli', 'iflow']; const CALLBACK_SUPPORTED: OAuthProvider[] = ['codex', 'anthropic', 'antigravity', 'gemini-cli'];
const getProviderI18nPrefix = (provider: OAuthProvider) => provider.replace('-', '_'); const getProviderI18nPrefix = (provider: OAuthProvider) => provider.replace('-', '_');
const getAuthKey = (provider: OAuthProvider, suffix: string) => const getAuthKey = (provider: OAuthProvider, suffix: string) =>
`auth_login.${getProviderI18nPrefix(provider)}_${suffix}`; `auth_login.${getProviderI18nPrefix(provider)}_${suffix}`;

View File

@@ -1031,7 +1031,7 @@ export function QuotaPage() {
const windows: CodexQuotaWindow[] = []; const windows: CodexQuotaWindow[] = [];
const addWindow = ( const addWindow = (
id: string, id: string,
label: string, labelKey: string,
window?: CodexUsageWindow | null, window?: CodexUsageWindow | null,
limitReached?: boolean, limitReached?: boolean,
allowed?: boolean allowed?: boolean
@@ -1044,7 +1044,8 @@ export function QuotaPage() {
usedPercentRaw ?? (isLimitReached && resetLabel !== '-' ? 100 : null); usedPercentRaw ?? (isLimitReached && resetLabel !== '-' ? 100 : null);
windows.push({ windows.push({
id, id,
label, label: t(labelKey),
labelKey,
usedPercent, usedPercent,
resetLabel resetLabel
}); });
@@ -1052,21 +1053,21 @@ export function QuotaPage() {
addWindow( addWindow(
'primary', 'primary',
t('codex_quota.primary_window'), 'codex_quota.primary_window',
rateLimit?.primary_window ?? rateLimit?.primaryWindow, rateLimit?.primary_window ?? rateLimit?.primaryWindow,
rateLimit?.limit_reached ?? rateLimit?.limitReached, rateLimit?.limit_reached ?? rateLimit?.limitReached,
rateLimit?.allowed rateLimit?.allowed
); );
addWindow( addWindow(
'secondary', 'secondary',
t('codex_quota.secondary_window'), 'codex_quota.secondary_window',
rateLimit?.secondary_window ?? rateLimit?.secondaryWindow, rateLimit?.secondary_window ?? rateLimit?.secondaryWindow,
rateLimit?.limit_reached ?? rateLimit?.limitReached, rateLimit?.limit_reached ?? rateLimit?.limitReached,
rateLimit?.allowed rateLimit?.allowed
); );
addWindow( addWindow(
'code-review', 'code-review',
t('codex_quota.code_review_window'), 'codex_quota.code_review_window',
codeReviewLimit?.primary_window ?? codeReviewLimit?.primaryWindow, codeReviewLimit?.primary_window ?? codeReviewLimit?.primaryWindow,
codeReviewLimit?.limit_reached ?? codeReviewLimit?.limitReached, codeReviewLimit?.limit_reached ?? codeReviewLimit?.limitReached,
codeReviewLimit?.allowed codeReviewLimit?.allowed
@@ -1552,10 +1553,12 @@ export function QuotaPage() {
? styles.quotaBarFillMedium ? styles.quotaBarFillMedium
: styles.quotaBarFillLow; : styles.quotaBarFillLow;
const windowLabel = window.labelKey ? t(window.labelKey) : window.label;
return ( return (
<div key={window.id} className={styles.quotaRow}> <div key={window.id} className={styles.quotaRow}>
<div className={styles.quotaRowHeader}> <div className={styles.quotaRowHeader}>
<span className={styles.quotaModel}>{window.label}</span> <span className={styles.quotaModel}>{windowLabel}</span>
<div className={styles.quotaMeta}> <div className={styles.quotaMeta}>
<span className={styles.quotaPercent}>{percentLabel}</span> <span className={styles.quotaPercent}>{percentLabel}</span>
<span className={styles.quotaReset}>{window.resetLabel}</span> <span className={styles.quotaReset}>{window.resetLabel}</span>

32
src/router/MainRoutes.tsx Normal file
View File

@@ -0,0 +1,32 @@
import { Navigate, useRoutes, type Location } from 'react-router-dom';
import { DashboardPage } from '@/pages/DashboardPage';
import { SettingsPage } from '@/pages/SettingsPage';
import { ApiKeysPage } from '@/pages/ApiKeysPage';
import { AiProvidersPage } from '@/pages/AiProvidersPage';
import { AuthFilesPage } from '@/pages/AuthFilesPage';
import { OAuthPage } from '@/pages/OAuthPage';
import { QuotaPage } from '@/pages/QuotaPage';
import { UsagePage } from '@/pages/UsagePage';
import { ConfigPage } from '@/pages/ConfigPage';
import { LogsPage } from '@/pages/LogsPage';
import { SystemPage } from '@/pages/SystemPage';
const mainRoutes = [
{ path: '/', element: <DashboardPage /> },
{ path: '/dashboard', element: <DashboardPage /> },
{ path: '/settings', element: <SettingsPage /> },
{ path: '/api-keys', element: <ApiKeysPage /> },
{ path: '/ai-providers', element: <AiProvidersPage /> },
{ path: '/auth-files', element: <AuthFilesPage /> },
{ path: '/oauth', element: <OAuthPage /> },
{ path: '/quota', element: <QuotaPage /> },
{ path: '/usage', element: <UsagePage /> },
{ path: '/config', element: <ConfigPage /> },
{ path: '/logs', element: <LogsPage /> },
{ path: '/system', element: <SystemPage /> },
{ path: '*', element: <Navigate to="/" replace /> },
];
export function MainRoutes({ location }: { location?: Location }) {
return useRoutes(mainRoutes, location);
}

View File

@@ -9,8 +9,7 @@ export type OAuthProvider =
| 'anthropic' | 'anthropic'
| 'antigravity' | 'antigravity'
| 'gemini-cli' | 'gemini-cli'
| 'qwen' | 'qwen';
| 'iflow';
export interface OAuthStartResponse { export interface OAuthStartResponse {
url: string; url: string;
@@ -30,7 +29,7 @@ export interface IFlowCookieAuthResponse {
type?: string; type?: string;
} }
const WEBUI_SUPPORTED: OAuthProvider[] = ['codex', 'anthropic', 'antigravity', 'gemini-cli', 'iflow']; const WEBUI_SUPPORTED: OAuthProvider[] = ['codex', 'anthropic', 'antigravity', 'gemini-cli'];
const CALLBACK_PROVIDER_MAP: Partial<Record<OAuthProvider, string>> = { const CALLBACK_PROVIDER_MAP: Partial<Record<OAuthProvider, string>> = {
'gemini-cli': 'gemini' 'gemini-cli': 'gemini'
}; };

View File

@@ -15,6 +15,15 @@ body {
transition: background-color $transition-normal, color $transition-normal; transition: background-color $transition-normal, color $transition-normal;
} }
html.modal-open,
body.modal-open {
overflow: hidden;
}
body.modal-open .content {
overflow: hidden;
}
// 滚动条样式 // 滚动条样式
::-webkit-scrollbar { ::-webkit-scrollbar {
width: 8px; width: 8px;

View File

@@ -348,6 +348,7 @@
flex-direction: column; flex-direction: column;
min-width: 0; min-width: 0;
overflow-y: auto; overflow-y: auto;
scrollbar-gutter: stable;
height: 100%; height: 100%;
&.content-logs { &.content-logs {

View File

@@ -9,8 +9,7 @@ export type OAuthProvider =
| 'anthropic' | 'anthropic'
| 'antigravity' | 'antigravity'
| 'gemini-cli' | 'gemini-cli'
| 'qwen' | 'qwen';
| 'iflow';
// OAuth 流程状态 // OAuth 流程状态
export interface OAuthFlow { export interface OAuthFlow {

View File

@@ -37,6 +37,7 @@ export interface GeminiCliQuotaState {
export interface CodexQuotaWindow { export interface CodexQuotaWindow {
id: string; id: string;
label: string; label: string;
labelKey?: string;
usedPercent: number | null; usedPercent: number | null;
resetLabel: string; resetLabel: string;
} }

View File

@@ -42,16 +42,14 @@ export const OAUTH_CARD_IDS = [
'anthropic-oauth-card', 'anthropic-oauth-card',
'antigravity-oauth-card', 'antigravity-oauth-card',
'gemini-cli-oauth-card', 'gemini-cli-oauth-card',
'qwen-oauth-card', 'qwen-oauth-card'
'iflow-oauth-card'
]; ];
export const OAUTH_PROVIDERS = { export const OAUTH_PROVIDERS = {
CODEX: 'codex', CODEX: 'codex',
ANTHROPIC: 'anthropic', ANTHROPIC: 'anthropic',
ANTIGRAVITY: 'antigravity', ANTIGRAVITY: 'antigravity',
GEMINI_CLI: 'gemini-cli', GEMINI_CLI: 'gemini-cli',
QWEN: 'qwen', QWEN: 'qwen'
IFLOW: 'iflow'
} as const; } as const;
// API 端点 // API 端点