Compare commits

...

15 Commits

Author SHA1 Message Date
LTbinglingfeng
8461de124f Merge branch 'dev' 2026-01-02 02:04:17 +08:00
Supra4E8C
276f416ec9 Merge pull request #41 from yanyuhualb/feature/make-project-id-optional
feat: make Gemini CLI project ID optional
2026-01-02 02:03:04 +08:00
LTbinglingfeng
583a844771 Merge branch 'dev' 2026-01-02 01:58:06 +08:00
LTbinglingfeng
62fa437285 fix(ui): use resolvedTheme for OpenAI icon switching in providers page 2026-01-02 01:54:22 +08:00
LTbinglingfeng
daab589c49 fix(ui): remove redundant add button from empty state in providers list 2026-01-02 01:42:43 +08:00
LTbinglingfeng
e18e9b25ce fix(transition): allow page container to shrink for proper layout 2026-01-02 01:37:17 +08:00
LTbinglingfeng
4cfb77dd44 fix(ui): center modals in viewport and lock background scroll 2026-01-02 01:15:04 +08:00
LTbinglingfeng
7cab1e8782 fix(oauth): remove iFlow provider from OAuth flow 2026-01-02 00:58:24 +08:00
LTbinglingfeng
079f37ec93 fix(quota): translate Codex window labels at render time 2026-01-02 00:41:35 +08:00
LTbinglingfeng
7ce97a616f fix(transition): preserve scroll position during page animations 2026-01-02 00:29:42 +08:00
LTbinglingfeng
946ed36af0 feat(router): add GSAP page transition animations 2026-01-02 00:01:25 +08:00
yanyuhualb
f139598526 feat: make Gemini CLI project ID optional
- Remove required validation for project ID field
- Update translations to indicate field is optional (zh-CN, en)
- Auto-select first available project when left empty
- Backend already supports empty project ID by fetching project list

This improves user experience by eliminating the need to manually enter project ID for users with only one or a preferred default project.
2025-12-31 23:06:44 +08:00
Supra4E8C
40ddd3c066 Merge pull request #40 from router-for-me/dev
feat(auth): add remember-password login and clear local auth data card
2025-12-31 20:48:25 +08:00
Supra4E8C
3a66dc225d feat(auth): add remember-password login and clear local auth data card 2025-12-31 20:04:32 +08:00
Supra4E8C
eadfd7a957 feat(quota): group Gemini CLI buckets and refine Gemini quota groups 2025-12-30 21:48:12 +08:00
24 changed files with 604 additions and 109 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

@@ -63,6 +63,7 @@
"custom_connection_placeholder": "Eg: https://example.com:8317", "custom_connection_placeholder": "Eg: https://example.com:8317",
"custom_connection_hint": "By default the current URL is used. Override it here if needed.", "custom_connection_hint": "By default the current URL is used. Override it here if needed.",
"use_current_address": "Use Current URL", "use_current_address": "Use Current URL",
"remember_password_label": "Remember password",
"management_key_label": "Management Key:", "management_key_label": "Management Key:",
"management_key_placeholder": "Enter the management key", "management_key_placeholder": "Enter the management key",
"connect_button": "Connect", "connect_button": "Connect",
@@ -500,9 +501,9 @@
"gemini_cli_oauth_title": "Gemini CLI OAuth", "gemini_cli_oauth_title": "Gemini CLI OAuth",
"gemini_cli_oauth_button": "Start Gemini CLI Login", "gemini_cli_oauth_button": "Start Gemini CLI Login",
"gemini_cli_oauth_hint": "Login to Google Gemini CLI service through OAuth flow, automatically obtain and save authentication files.", "gemini_cli_oauth_hint": "Login to Google Gemini CLI service through OAuth flow, automatically obtain and save authentication files.",
"gemini_cli_project_id_label": "Google Cloud Project ID:", "gemini_cli_project_id_label": "Google Cloud Project ID (Optional):",
"gemini_cli_project_id_placeholder": "Enter Google Cloud Project ID", "gemini_cli_project_id_placeholder": "Leave blank to auto-select first available project",
"gemini_cli_project_id_hint": "Project ID is required for Gemini CLI OAuth.", "gemini_cli_project_id_hint": "Optional. If not provided, the system will automatically select the first available project from your account.",
"gemini_cli_project_id_required": "Please enter a Google Cloud project ID.", "gemini_cli_project_id_required": "Please enter a Google Cloud project ID.",
"gemini_cli_oauth_url_label": "Authorization URL:", "gemini_cli_oauth_url_label": "Authorization URL:",
"gemini_cli_open_link": "Open Link", "gemini_cli_open_link": "Open Link",
@@ -745,7 +746,11 @@
"link_webui_repo": "WebUI Repository", "link_webui_repo": "WebUI Repository",
"link_webui_repo_desc": "Management Center frontend source code", "link_webui_repo_desc": "Management Center frontend source code",
"link_docs": "Documentation", "link_docs": "Documentation",
"link_docs_desc": "Usage tutorials and configuration guides" "link_docs_desc": "Usage tutorials and configuration guides",
"clear_login_title": "Local Login Data",
"clear_login_desc": "Clear locally saved login data and sign out. Usage stats pricing settings will remain untouched.",
"clear_login_button": "Clear login data",
"clear_login_confirm": "Clear local login data and sign out now?"
}, },
"notification": { "notification": {
"debug_updated": "Debug settings updated", "debug_updated": "Debug settings updated",
@@ -758,6 +763,7 @@
"logging_to_file_updated": "Logging settings updated", "logging_to_file_updated": "Logging settings updated",
"request_log_updated": "Request logging setting updated", "request_log_updated": "Request logging setting updated",
"ws_auth_updated": "WebSocket authentication setting updated", "ws_auth_updated": "WebSocket authentication setting updated",
"login_storage_cleared": "Local login data cleared",
"api_key_added": "API key added successfully", "api_key_added": "API key added successfully",
"api_key_updated": "API key updated successfully", "api_key_updated": "API key updated successfully",
"api_key_deleted": "API key deleted successfully", "api_key_deleted": "API key deleted successfully",

View File

@@ -63,6 +63,7 @@
"custom_connection_placeholder": "例如: https://example.com:8317", "custom_connection_placeholder": "例如: https://example.com:8317",
"custom_connection_hint": "默认使用当前访问地址,若需要可手动输入其他地址。", "custom_connection_hint": "默认使用当前访问地址,若需要可手动输入其他地址。",
"use_current_address": "使用当前地址", "use_current_address": "使用当前地址",
"remember_password_label": "记住密码",
"management_key_label": "管理密钥:", "management_key_label": "管理密钥:",
"management_key_placeholder": "请输入管理密钥", "management_key_placeholder": "请输入管理密钥",
"connect_button": "连接", "connect_button": "连接",
@@ -500,9 +501,9 @@
"gemini_cli_oauth_title": "Gemini CLI OAuth", "gemini_cli_oauth_title": "Gemini CLI OAuth",
"gemini_cli_oauth_button": "开始 Gemini CLI 登录", "gemini_cli_oauth_button": "开始 Gemini CLI 登录",
"gemini_cli_oauth_hint": "通过 OAuth 流程登录 Google Gemini CLI 服务,自动获取并保存认证文件。", "gemini_cli_oauth_hint": "通过 OAuth 流程登录 Google Gemini CLI 服务,自动获取并保存认证文件。",
"gemini_cli_project_id_label": "Google Cloud 项目 ID:", "gemini_cli_project_id_label": "Google Cloud 项目 ID (可选):",
"gemini_cli_project_id_placeholder": "输入 Google Cloud 项目 ID", "gemini_cli_project_id_placeholder": "留空将自动选择第一个可用项目",
"gemini_cli_project_id_hint": "填写项目 ID,用于 Gemini CLI OAuth 登录。", "gemini_cli_project_id_hint": "可选填写项目 ID。如不填写,系统将自动选择您账号下的第一个可用项目。",
"gemini_cli_project_id_required": "请填写 Google Cloud 项目 ID。", "gemini_cli_project_id_required": "请填写 Google Cloud 项目 ID。",
"gemini_cli_oauth_url_label": "授权链接:", "gemini_cli_oauth_url_label": "授权链接:",
"gemini_cli_open_link": "打开链接", "gemini_cli_open_link": "打开链接",
@@ -745,7 +746,11 @@
"link_webui_repo": "WebUI 仓库", "link_webui_repo": "WebUI 仓库",
"link_webui_repo_desc": "管理中心前端界面源代码", "link_webui_repo_desc": "管理中心前端界面源代码",
"link_docs": "使用教程", "link_docs": "使用教程",
"link_docs_desc": "配置指南和使用说明" "link_docs_desc": "配置指南和使用说明",
"clear_login_title": "本地登录信息",
"clear_login_desc": "清理本地保存的登录信息并退出登录,不会影响使用统计中的价格设置。",
"clear_login_button": "清理登录信息",
"clear_login_confirm": "确认清理本地登录信息并退出登录?"
}, },
"notification": { "notification": {
"debug_updated": "调试设置已更新", "debug_updated": "调试设置已更新",
@@ -758,6 +763,7 @@
"logging_to_file_updated": "日志记录设置已更新", "logging_to_file_updated": "日志记录设置已更新",
"request_log_updated": "请求日志设置已更新", "request_log_updated": "请求日志设置已更新",
"ws_auth_updated": "WebSocket 鉴权设置已更新", "ws_auth_updated": "WebSocket 鉴权设置已更新",
"login_storage_cleared": "本地登录信息已清理",
"api_key_added": "API密钥添加成功", "api_key_added": "API密钥添加成功",
"api_key_updated": "API密钥更新成功", "api_key_updated": "API密钥更新成功",
"api_key_deleted": "API密钥删除成功", "api_key_deleted": "API密钥删除成功",

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

@@ -19,11 +19,13 @@ export function LoginPage() {
const restoreSession = useAuthStore((state) => state.restoreSession); const restoreSession = useAuthStore((state) => state.restoreSession);
const storedBase = useAuthStore((state) => state.apiBase); const storedBase = useAuthStore((state) => state.apiBase);
const storedKey = useAuthStore((state) => state.managementKey); const storedKey = useAuthStore((state) => state.managementKey);
const storedRememberPassword = useAuthStore((state) => state.rememberPassword);
const [apiBase, setApiBase] = useState(''); const [apiBase, setApiBase] = useState('');
const [managementKey, setManagementKey] = useState(''); const [managementKey, setManagementKey] = useState('');
const [showCustomBase, setShowCustomBase] = useState(false); const [showCustomBase, setShowCustomBase] = useState(false);
const [showKey, setShowKey] = useState(false); const [showKey, setShowKey] = useState(false);
const [rememberPassword, setRememberPassword] = useState(false);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [autoLoading, setAutoLoading] = useState(true); const [autoLoading, setAutoLoading] = useState(true);
const [error, setError] = useState(''); const [error, setError] = useState('');
@@ -38,6 +40,7 @@ export function LoginPage() {
if (!autoLoggedIn) { if (!autoLoggedIn) {
setApiBase(storedBase || detectedBase); setApiBase(storedBase || detectedBase);
setManagementKey(storedKey || ''); setManagementKey(storedKey || '');
setRememberPassword(storedRememberPassword || Boolean(storedKey));
} }
} finally { } finally {
setAutoLoading(false); setAutoLoading(false);
@@ -45,7 +48,7 @@ export function LoginPage() {
}; };
init(); init();
}, [detectedBase, restoreSession, storedBase, storedKey]); }, [detectedBase, restoreSession, storedBase, storedKey, storedRememberPassword]);
if (isAuthenticated) { if (isAuthenticated) {
const redirect = (location.state as any)?.from?.pathname || '/'; const redirect = (location.state as any)?.from?.pathname || '/';
@@ -62,7 +65,11 @@ export function LoginPage() {
setLoading(true); setLoading(true);
setError(''); setError('');
try { try {
await login({ apiBase: baseToUse, managementKey: managementKey.trim() }); await login({
apiBase: baseToUse,
managementKey: managementKey.trim(),
rememberPassword
});
showNotification(t('common.connected_status'), 'success'); showNotification(t('common.connected_status'), 'success');
navigate('/', { replace: true }); navigate('/', { replace: true });
} catch (err: any) { } catch (err: any) {
@@ -148,6 +155,16 @@ export function LoginPage() {
} }
/> />
<div className="toggle-advanced">
<input
id="remember-password-toggle"
type="checkbox"
checked={rememberPassword}
onChange={(e) => setRememberPassword(e.target.checked)}
/>
<label htmlFor="remember-password-toggle">{t('login.remember_password_label')}</label>
</div>
<Button fullWidth onClick={handleSubmit} loading={loading}> <Button fullWidth onClick={handleSubmit} loading={loading}>
{loading ? t('login.submitting') : t('login.submit_button')} {loading ? t('login.submitting') : t('login.submit_button')}
</Button> </Button>

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}`;
@@ -131,12 +130,7 @@ export function OAuthPage() {
const startAuth = async (provider: OAuthProvider) => { const startAuth = async (provider: OAuthProvider) => {
const projectId = provider === 'gemini-cli' ? (states[provider]?.projectId || '').trim() : undefined; const projectId = provider === 'gemini-cli' ? (states[provider]?.projectId || '').trim() : undefined;
if (provider === 'gemini-cli' && !projectId) { // 项目 ID 现在是可选的,如果不输入将自动选择第一个可用项目
const message = t('auth_login.gemini_cli_project_id_required');
updateProviderState(provider, { projectIdError: message });
showNotification(message, 'warning');
return;
}
if (provider === 'gemini-cli') { if (provider === 'gemini-cli') {
updateProviderState(provider, { projectIdError: undefined }); updateProviderState(provider, { projectIdError: undefined });
} }
@@ -151,7 +145,7 @@ export function OAuthPage() {
try { try {
const res = await oauthApi.startAuth( const res = await oauthApi.startAuth(
provider, provider,
provider === 'gemini-cli' ? { projectId: projectId! } : undefined provider === 'gemini-cli' ? { projectId: projectId || undefined } : undefined
); );
updateProviderState(provider, { url: res.url, state: res.state, status: 'waiting', polling: true }); updateProviderState(provider, { url: res.url, state: res.state, status: 'waiting', polling: true });
if (res.state) { if (res.state) {

View File

@@ -132,15 +132,24 @@ const ANTIGRAVITY_QUOTA_GROUPS: AntigravityQuotaGroupDefinition[] = [
] ]
}, },
{ {
id: 'gemini', id: 'gemini-3-pro',
label: 'Gemini', label: 'Gemini 3 Pro',
identifiers: [ identifiers: ['gemini-3-pro-high', 'gemini-3-pro-low']
'gemini-3-pro-high', },
'gemini-3-pro-low', {
'gemini-2.5-flash', id: 'gemini-2-5-flash',
'gemini-2.5-flash-lite', label: 'Gemini 2.5 Flash',
'rev19-uic3-1p' identifiers: ['gemini-2.5-flash', 'gemini-2.5-flash-thinking']
] },
{
id: 'gemini-2-5-flash-lite',
label: 'Gemini 2.5 Flash Lite',
identifiers: ['gemini-2.5-flash-lite']
},
{
id: 'gemini-2-5-cu',
label: 'Gemini 2.5 CU',
identifiers: ['rev19-uic3-1p']
}, },
{ {
id: 'gemini-3-flash', id: 'gemini-3-flash',
@@ -162,6 +171,51 @@ const GEMINI_CLI_REQUEST_HEADERS = {
'Content-Type': 'application/json' 'Content-Type': 'application/json'
}; };
interface GeminiCliQuotaGroupDefinition {
id: string;
label: string;
modelIds: string[];
}
interface GeminiCliParsedBucket {
modelId: string;
tokenType: string | null;
remainingFraction: number | null;
remainingAmount: number | null;
resetTime: string | undefined;
}
const GEMINI_CLI_QUOTA_GROUPS: GeminiCliQuotaGroupDefinition[] = [
{
id: 'gemini-2-5-flash-series',
label: 'Gemini 2.5 Flash Series',
modelIds: ['gemini-2.5-flash', 'gemini-2.5-flash-lite']
},
{
id: 'gemini-2-5-pro',
label: 'Gemini 2.5 Pro',
modelIds: ['gemini-2.5-pro']
},
{
id: 'gemini-3-pro-preview',
label: 'Gemini 3 Pro Preview',
modelIds: ['gemini-3-pro-preview']
},
{
id: 'gemini-3-flash-preview',
label: 'Gemini 3 Flash Preview',
modelIds: ['gemini-3-flash-preview']
}
];
const GEMINI_CLI_GROUP_LOOKUP = new Map(
GEMINI_CLI_QUOTA_GROUPS.flatMap((group) =>
group.modelIds.map((modelId) => [modelId, group] as const)
)
);
const GEMINI_CLI_IGNORED_MODEL_PREFIXES = ['gemini-2.0-flash'];
interface CodexUsageWindow { interface CodexUsageWindow {
used_percent?: number | string; used_percent?: number | string;
usedPercent?: number | string; usedPercent?: number | string;
@@ -472,6 +526,80 @@ function parseGeminiCliQuotaPayload(payload: unknown): GeminiCliQuotaPayload | n
return null; return null;
} }
function isIgnoredGeminiCliModel(modelId: string): boolean {
return GEMINI_CLI_IGNORED_MODEL_PREFIXES.some(
(prefix) => modelId === prefix || modelId.startsWith(`${prefix}-`)
);
}
function pickEarlierResetTime(current?: string, next?: string): string | undefined {
if (!current) return next;
if (!next) return current;
const currentTime = new Date(current).getTime();
const nextTime = new Date(next).getTime();
if (Number.isNaN(currentTime)) return next;
if (Number.isNaN(nextTime)) return current;
return currentTime <= nextTime ? current : next;
}
function minNullableNumber(current: number | null, next: number | null): number | null {
if (current === null) return next;
if (next === null) return current;
return Math.min(current, next);
}
function buildGeminiCliQuotaBuckets(
buckets: GeminiCliParsedBucket[]
): GeminiCliQuotaBucketState[] {
if (buckets.length === 0) return [];
const grouped = new Map<string, GeminiCliQuotaBucketState & { modelIds: string[] }>();
buckets.forEach((bucket) => {
if (isIgnoredGeminiCliModel(bucket.modelId)) return;
const group = GEMINI_CLI_GROUP_LOOKUP.get(bucket.modelId);
const groupId = group?.id ?? bucket.modelId;
const label = group?.label ?? bucket.modelId;
const tokenKey = bucket.tokenType ?? '';
const mapKey = `${groupId}::${tokenKey}`;
const existing = grouped.get(mapKey);
if (!existing) {
grouped.set(mapKey, {
id: `${groupId}${tokenKey ? `-${tokenKey}` : ''}`,
label,
remainingFraction: bucket.remainingFraction,
remainingAmount: bucket.remainingAmount,
resetTime: bucket.resetTime,
tokenType: bucket.tokenType,
modelIds: [bucket.modelId]
});
return;
}
existing.remainingFraction = minNullableNumber(
existing.remainingFraction,
bucket.remainingFraction
);
existing.remainingAmount = minNullableNumber(existing.remainingAmount, bucket.remainingAmount);
existing.resetTime = pickEarlierResetTime(existing.resetTime, bucket.resetTime);
existing.modelIds.push(bucket.modelId);
});
return Array.from(grouped.values()).map((bucket) => {
const uniqueModelIds = Array.from(new Set(bucket.modelIds));
return {
id: bucket.id,
label: bucket.label,
remainingFraction: bucket.remainingFraction,
remainingAmount: bucket.remainingAmount,
resetTime: bucket.resetTime,
tokenType: bucket.tokenType,
modelIds: uniqueModelIds
};
});
}
function getAntigravityQuotaInfo(entry?: AntigravityQuotaInfo): { function getAntigravityQuotaInfo(entry?: AntigravityQuotaInfo): {
remainingFraction: number | null; remainingFraction: number | null;
resetTime?: string; resetTime?: string;
@@ -517,8 +645,16 @@ function findAntigravityModel(
function buildAntigravityQuotaGroups(models: AntigravityModelsPayload): AntigravityQuotaGroup[] { function buildAntigravityQuotaGroups(models: AntigravityModelsPayload): AntigravityQuotaGroup[] {
const groups: AntigravityQuotaGroup[] = []; const groups: AntigravityQuotaGroup[] = [];
let geminiResetTime: string | undefined; let geminiProResetTime: string | undefined;
const [claudeDef, geminiDef, flashDef, imageDef] = ANTIGRAVITY_QUOTA_GROUPS; const [
claudeDef,
geminiProDef,
flashDef,
flashLiteDef,
cuDef,
geminiFlashDef,
imageDef
] = ANTIGRAVITY_QUOTA_GROUPS;
const buildGroup = ( const buildGroup = (
def: AntigravityQuotaGroupDefinition, def: AntigravityQuotaGroupDefinition,
@@ -565,10 +701,10 @@ function buildAntigravityQuotaGroups(models: AntigravityModelsPayload): Antigrav
groups.push(claudeGroup); groups.push(claudeGroup);
} }
const geminiGroup = buildGroup(geminiDef); const geminiProGroup = buildGroup(geminiProDef);
if (geminiGroup) { if (geminiProGroup) {
geminiResetTime = geminiGroup.resetTime; geminiProResetTime = geminiProGroup.resetTime;
groups.push(geminiGroup); groups.push(geminiProGroup);
} }
const flashGroup = buildGroup(flashDef); const flashGroup = buildGroup(flashDef);
@@ -576,7 +712,22 @@ function buildAntigravityQuotaGroups(models: AntigravityModelsPayload): Antigrav
groups.push(flashGroup); groups.push(flashGroup);
} }
const imageGroup = buildGroup(imageDef, geminiResetTime); const flashLiteGroup = buildGroup(flashLiteDef);
if (flashLiteGroup) {
groups.push(flashLiteGroup);
}
const cuGroup = buildGroup(cuDef);
if (cuGroup) {
groups.push(cuGroup);
}
const geminiFlashGroup = buildGroup(geminiFlashDef);
if (geminiFlashGroup) {
groups.push(geminiFlashGroup);
}
const imageGroup = buildGroup(imageDef, geminiProResetTime);
if (imageGroup) { if (imageGroup) {
groups.push(imageGroup); groups.push(imageGroup);
} }
@@ -880,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
@@ -893,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
}); });
@@ -901,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
@@ -1066,8 +1218,8 @@ export function QuotaPage() {
const buckets = Array.isArray(payload?.buckets) ? payload?.buckets : []; const buckets = Array.isArray(payload?.buckets) ? payload?.buckets : [];
if (buckets.length === 0) return []; if (buckets.length === 0) return [];
return buckets const parsedBuckets = buckets
.map((bucket, index) => { .map((bucket) => {
const modelId = normalizeStringValue(bucket.modelId ?? bucket.model_id); const modelId = normalizeStringValue(bucket.modelId ?? bucket.model_id);
if (!modelId) return null; if (!modelId) return null;
const tokenType = normalizeStringValue(bucket.tokenType ?? bucket.token_type); const tokenType = normalizeStringValue(bucket.tokenType ?? bucket.token_type);
@@ -1086,15 +1238,16 @@ export function QuotaPage() {
} }
const remainingFraction = remainingFractionRaw ?? fallbackFraction; const remainingFraction = remainingFractionRaw ?? fallbackFraction;
return { return {
id: `${modelId}-${tokenType ?? index}`, modelId,
label: modelId, tokenType,
remainingFraction, remainingFraction,
remainingAmount, remainingAmount,
resetTime, resetTime
tokenType
}; };
}) })
.filter((bucket): bucket is GeminiCliQuotaBucketState => bucket !== null); .filter((bucket): bucket is GeminiCliParsedBucket => bucket !== null);
return buildGeminiCliQuotaBuckets(parsedBuckets);
}, },
[t] [t]
); );
@@ -1400,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>
@@ -1479,6 +1634,10 @@ export function QuotaPage() {
: t('gemini_cli_quota.remaining_amount', { : t('gemini_cli_quota.remaining_amount', {
count: bucket.remainingAmount count: bucket.remainingAmount
}); });
const titleBase =
bucket.modelIds && bucket.modelIds.length > 0
? bucket.modelIds.join(', ')
: bucket.label;
const quotaBarClass = const quotaBarClass =
percent === null percent === null
? styles.quotaBarFillMedium ? styles.quotaBarFillMedium
@@ -1494,7 +1653,7 @@ export function QuotaPage() {
<span <span
className={styles.quotaModel} className={styles.quotaModel}
title={ title={
bucket.tokenType ? `${bucket.label} (${bucket.tokenType})` : bucket.label bucket.tokenType ? `${titleBase} (${bucket.tokenType})` : titleBase
} }
> >
{bucket.label} {bucket.label}

View File

@@ -34,6 +34,12 @@
margin: 0 0 $spacing-md 0; margin: 0 0 $spacing-md 0;
} }
.clearLoginActions {
display: flex;
justify-content: flex-end;
align-items: center;
}
.infoGrid { .infoGrid {
display: grid; display: grid;
gap: $spacing-sm; gap: $spacing-sm;

View File

@@ -6,6 +6,7 @@ import { IconGithub, IconBookOpen, IconExternalLink, IconCode } from '@/componen
import { useAuthStore, useConfigStore, useNotificationStore, useModelsStore } from '@/stores'; import { useAuthStore, useConfigStore, useNotificationStore, useModelsStore } from '@/stores';
import { apiKeysApi } from '@/services/api/apiKeys'; import { apiKeysApi } from '@/services/api/apiKeys';
import { classifyModels } from '@/utils/models'; import { classifyModels } from '@/utils/models';
import { STORAGE_KEY_AUTH } from '@/utils/constants';
import styles from './SystemPage.module.scss'; import styles from './SystemPage.module.scss';
export function SystemPage() { export function SystemPage() {
@@ -104,6 +105,15 @@ export function SystemPage() {
} }
}; };
const handleClearLoginStorage = () => {
if (!window.confirm(t('system_info.clear_login_confirm'))) return;
auth.logout();
if (typeof localStorage === 'undefined') return;
const keysToRemove = [STORAGE_KEY_AUTH, 'isLoggedIn', 'apiBase', 'apiUrl', 'managementKey'];
keysToRemove.forEach((key) => localStorage.removeItem(key));
showNotification(t('notification.login_storage_cleared'), 'success');
};
useEffect(() => { useEffect(() => {
fetchConfig().catch(() => { fetchConfig().catch(() => {
// ignore // ignore
@@ -248,6 +258,15 @@ export function SystemPage() {
</div> </div>
)} )}
</Card> </Card>
<Card title={t('system_info.clear_login_title')}>
<p className={styles.sectionDescription}>{t('system_info.clear_login_desc')}</p>
<div className={styles.clearLoginActions}>
<Button variant="danger" onClick={handleClearLoginStorage}>
{t('system_info.clear_login_button')}
</Button>
</div>
</Card>
</div> </div>
</div> </div>
); );

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

@@ -34,6 +34,7 @@ export const useAuthStore = create<AuthStoreState>()(
isAuthenticated: false, isAuthenticated: false,
apiBase: '', apiBase: '',
managementKey: '', managementKey: '',
rememberPassword: false,
serverVersion: null, serverVersion: null,
serverBuildDate: null, serverBuildDate: null,
connectionStatus: 'disconnected', connectionStatus: 'disconnected',
@@ -52,16 +53,25 @@ export const useAuthStore = create<AuthStoreState>()(
secureStorage.getItem<string>('apiUrl', { encrypt: true }); secureStorage.getItem<string>('apiUrl', { encrypt: true });
const legacyKey = secureStorage.getItem<string>('managementKey'); const legacyKey = secureStorage.getItem<string>('managementKey');
const { apiBase, managementKey } = get(); const { apiBase, managementKey, rememberPassword } = get();
const resolvedBase = normalizeApiBase(apiBase || legacyBase || detectApiBaseFromLocation()); const resolvedBase = normalizeApiBase(apiBase || legacyBase || detectApiBaseFromLocation());
const resolvedKey = managementKey || legacyKey || ''; const resolvedKey = managementKey || legacyKey || '';
const resolvedRememberPassword = rememberPassword || Boolean(managementKey) || Boolean(legacyKey);
set({ apiBase: resolvedBase, managementKey: resolvedKey }); set({
apiBase: resolvedBase,
managementKey: resolvedKey,
rememberPassword: resolvedRememberPassword
});
apiClient.setConfig({ apiBase: resolvedBase, managementKey: resolvedKey }); apiClient.setConfig({ apiBase: resolvedBase, managementKey: resolvedKey });
if (wasLoggedIn && resolvedBase && resolvedKey) { if (wasLoggedIn && resolvedBase && resolvedKey) {
try { try {
await get().login({ apiBase: resolvedBase, managementKey: resolvedKey }); await get().login({
apiBase: resolvedBase,
managementKey: resolvedKey,
rememberPassword: resolvedRememberPassword
});
return true; return true;
} catch (error) { } catch (error) {
console.warn('Auto login failed:', error); console.warn('Auto login failed:', error);
@@ -79,6 +89,7 @@ export const useAuthStore = create<AuthStoreState>()(
login: async (credentials) => { login: async (credentials) => {
const apiBase = normalizeApiBase(credentials.apiBase); const apiBase = normalizeApiBase(credentials.apiBase);
const managementKey = credentials.managementKey.trim(); const managementKey = credentials.managementKey.trim();
const rememberPassword = credentials.rememberPassword ?? get().rememberPassword ?? false;
try { try {
set({ connectionStatus: 'connecting' }); set({ connectionStatus: 'connecting' });
@@ -97,10 +108,15 @@ export const useAuthStore = create<AuthStoreState>()(
isAuthenticated: true, isAuthenticated: true,
apiBase, apiBase,
managementKey, managementKey,
rememberPassword,
connectionStatus: 'connected', connectionStatus: 'connected',
connectionError: null connectionError: null
}); });
localStorage.setItem('isLoggedIn', 'true'); if (rememberPassword) {
localStorage.setItem('isLoggedIn', 'true');
} else {
localStorage.removeItem('isLoggedIn');
}
} catch (error: any) { } catch (error: any) {
set({ set({
connectionStatus: 'error', connectionStatus: 'error',
@@ -185,7 +201,8 @@ export const useAuthStore = create<AuthStoreState>()(
})), })),
partialize: (state) => ({ partialize: (state) => ({
apiBase: state.apiBase, apiBase: state.apiBase,
managementKey: state.managementKey, ...(state.rememberPassword ? { managementKey: state.managementKey } : {}),
rememberPassword: state.rememberPassword,
serverVersion: state.serverVersion, serverVersion: state.serverVersion,
serverBuildDate: state.serverBuildDate serverBuildDate: state.serverBuildDate
}) })

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

@@ -7,6 +7,7 @@
export interface LoginCredentials { export interface LoginCredentials {
apiBase: string; apiBase: string;
managementKey: string; managementKey: string;
rememberPassword?: boolean;
} }
// 认证状态 // 认证状态
@@ -14,6 +15,7 @@ export interface AuthState {
isAuthenticated: boolean; isAuthenticated: boolean;
apiBase: string; apiBase: string;
managementKey: string; managementKey: string;
rememberPassword: boolean;
serverVersion: string | null; serverVersion: string | null;
serverBuildDate: string | null; serverBuildDate: string | null;
} }

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

@@ -24,6 +24,7 @@ export interface GeminiCliQuotaBucketState {
remainingAmount: number | null; remainingAmount: number | null;
resetTime: string | undefined; resetTime: string | undefined;
tokenType: string | null; tokenType: string | null;
modelIds?: string[];
} }
export interface GeminiCliQuotaState { export interface GeminiCliQuotaState {
@@ -36,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 端点