mirror of
https://github.com/router-for-me/Cli-Proxy-API-Management-Center.git
synced 2026-02-03 03:10:50 +08:00
Compare commits
18 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
03bf58671e | ||
|
|
cb6b810d6d | ||
|
|
408e6e5872 | ||
|
|
b3808add0f | ||
|
|
0b2e6efe28 | ||
|
|
8ca6d31a26 | ||
|
|
66c6073bbc | ||
|
|
2dd3f233d3 | ||
|
|
7a65e03ad3 | ||
|
|
589a5bad4c | ||
|
|
bcaa0c8545 | ||
|
|
312a06a8b8 | ||
|
|
24861dabd2 | ||
|
|
ea1bdc3ac1 | ||
|
|
46701b40ad | ||
|
|
c9fc22bae5 | ||
|
|
961cc802b2 | ||
|
|
5f7df33469 |
@@ -31,10 +31,11 @@ function App() {
|
|||||||
const [authReady, setAuthReady] = useState(false);
|
const [authReady, setAuthReady] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
initializeTheme();
|
const cleanupTheme = initializeTheme();
|
||||||
void restoreSession().finally(() => {
|
void restoreSession().finally(() => {
|
||||||
setAuthReady(true);
|
setAuthReady(true);
|
||||||
});
|
});
|
||||||
|
return cleanupTheme;
|
||||||
}, [initializeTheme, restoreSession]);
|
}, [initializeTheme, restoreSession]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
1
src/assets/icons/vertex.svg
Normal file
1
src/assets/icons/vertex.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24px" height="24px"><path d="M20,13.89A.77.77,0,0,0,19,13.73l-7,5.14v.22a.72.72,0,1,1,0,1.43v0a.74.74,0,0,0,.45-.15l7.41-5.47A.76.76,0,0,0,20,13.89Z" style="fill:#669df6"/><path d="M12,20.52a.72.72,0,0,1,0-1.43h0v-.22L5,13.73a.76.76,0,0,0-1,.16.74.74,0,0,0,.16,1l7.41,5.47a.73.73,0,0,0,.44.15v0Z" style="fill:#aecbfa"/><path d="M12,18.34a1.47,1.47,0,1,0,1.47,1.47A1.47,1.47,0,0,0,12,18.34Zm0,2.18a.72.72,0,1,1,.72-.71A.71.71,0,0,1,12,20.52Z" style="fill:#4285f4"/><path d="M6,6.11a.76.76,0,0,1-.75-.75V3.48a.76.76,0,1,1,1.51,0V5.36A.76.76,0,0,1,6,6.11Z" style="fill:#aecbfa"/><circle cx="5.98" cy="12" r="0.76" style="fill:#aecbfa"/><circle cx="5.98" cy="9.79" r="0.76" style="fill:#aecbfa"/><circle cx="5.98" cy="7.57" r="0.76" style="fill:#aecbfa"/><path d="M18,8.31a.76.76,0,0,1-.75-.76V5.67a.75.75,0,1,1,1.5,0V7.55A.75.75,0,0,1,18,8.31Z" style="fill:#4285f4"/><circle cx="18.02" cy="12.01" r="0.76" style="fill:#4285f4"/><circle cx="18.02" cy="9.76" r="0.76" style="fill:#4285f4"/><circle cx="18.02" cy="3.48" r="0.76" style="fill:#4285f4"/><path d="M12,15a.76.76,0,0,1-.75-.75V12.34a.76.76,0,0,1,1.51,0v1.89A.76.76,0,0,1,12,15Z" style="fill:#669df6"/><circle cx="12" cy="16.45" r="0.76" style="fill:#669df6"/><circle cx="12" cy="10.14" r="0.76" style="fill:#669df6"/><circle cx="12" cy="7.92" r="0.76" style="fill:#669df6"/><path d="M15,10.54a.76.76,0,0,1-.75-.75V7.91a.76.76,0,1,1,1.51,0V9.79A.76.76,0,0,1,15,10.54Z" style="fill:#4285f4"/><circle cx="15.01" cy="5.69" r="0.76" style="fill:#4285f4"/><circle cx="15.01" cy="14.19" r="0.76" style="fill:#4285f4"/><circle cx="15.01" cy="11.97" r="0.76" style="fill:#4285f4"/><circle cx="8.99" cy="14.19" r="0.76" style="fill:#aecbfa"/><circle cx="8.99" cy="7.92" r="0.76" style="fill:#aecbfa"/><circle cx="8.99" cy="5.69" r="0.76" style="fill:#aecbfa"/><path d="M9,12.73A.76.76,0,0,1,8.24,12V10.1a.75.75,0,1,1,1.5,0V12A.75.75,0,0,1,9,12.73Z" style="fill:#aecbfa"/></svg>
|
||||||
|
After Width: | Height: | Size: 1.9 KiB |
@@ -1,5 +1,13 @@
|
|||||||
import { ReactNode, SVGProps, useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react';
|
import {
|
||||||
import { NavLink, Outlet } from 'react-router-dom';
|
ReactNode,
|
||||||
|
SVGProps,
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useLayoutEffect,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
} from 'react';
|
||||||
|
import { NavLink, Outlet, 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';
|
||||||
@@ -14,10 +22,16 @@ import {
|
|||||||
IconScrollText,
|
IconScrollText,
|
||||||
IconSettings,
|
IconSettings,
|
||||||
IconShield,
|
IconShield,
|
||||||
IconSlidersHorizontal
|
IconSlidersHorizontal,
|
||||||
} from '@/components/ui/icons';
|
} from '@/components/ui/icons';
|
||||||
import { INLINE_LOGO_JPEG } from '@/assets/logoInline';
|
import { INLINE_LOGO_JPEG } from '@/assets/logoInline';
|
||||||
import { useAuthStore, useConfigStore, useLanguageStore, useNotificationStore, useThemeStore } from '@/stores';
|
import {
|
||||||
|
useAuthStore,
|
||||||
|
useConfigStore,
|
||||||
|
useLanguageStore,
|
||||||
|
useNotificationStore,
|
||||||
|
useThemeStore,
|
||||||
|
} from '@/stores';
|
||||||
import { configApi, versionApi } from '@/services/api';
|
import { configApi, versionApi } from '@/services/api';
|
||||||
|
|
||||||
const sidebarIcons: Record<string, ReactNode> = {
|
const sidebarIcons: Record<string, ReactNode> = {
|
||||||
@@ -30,7 +44,7 @@ const sidebarIcons: Record<string, ReactNode> = {
|
|||||||
usage: <IconChartLine size={18} />,
|
usage: <IconChartLine size={18} />,
|
||||||
config: <IconSettings size={18} />,
|
config: <IconSettings size={18} />,
|
||||||
logs: <IconScrollText size={18} />,
|
logs: <IconScrollText size={18} />,
|
||||||
system: <IconInfo size={18} />
|
system: <IconInfo size={18} />,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Header action icons - smaller size for header buttons
|
// Header action icons - smaller size for header buttons
|
||||||
@@ -44,7 +58,7 @@ const headerIconProps: SVGProps<SVGSVGElement> = {
|
|||||||
strokeLinecap: 'round',
|
strokeLinecap: 'round',
|
||||||
strokeLinejoin: 'round',
|
strokeLinejoin: 'round',
|
||||||
'aria-hidden': 'true',
|
'aria-hidden': 'true',
|
||||||
focusable: 'false'
|
focusable: 'false',
|
||||||
};
|
};
|
||||||
|
|
||||||
const headerIcons = {
|
const headerIcons = {
|
||||||
@@ -97,19 +111,38 @@ const headerIcons = {
|
|||||||
<path d="m19.07 4.93-1.41 1.41" />
|
<path d="m19.07 4.93-1.41 1.41" />
|
||||||
</svg>
|
</svg>
|
||||||
),
|
),
|
||||||
moon: (
|
moon: (
|
||||||
<svg {...headerIconProps}>
|
<svg {...headerIconProps}>
|
||||||
<path d="M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9z" />
|
<path d="M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9z" />
|
||||||
</svg>
|
</svg>
|
||||||
),
|
),
|
||||||
logout: (
|
autoTheme: (
|
||||||
<svg {...headerIconProps}>
|
<svg {...headerIconProps}>
|
||||||
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" />
|
<defs>
|
||||||
<path d="m16 17 5-5-5-5" />
|
<clipPath id="mainLayoutAutoThemeSunLeftHalf">
|
||||||
<path d="M21 12H9" />
|
<rect x="0" y="0" width="12" height="24" />
|
||||||
</svg>
|
</clipPath>
|
||||||
)
|
</defs>
|
||||||
};
|
<circle cx="12" cy="12" r="4" />
|
||||||
|
<circle cx="12" cy="12" r="4" clipPath="url(#mainLayoutAutoThemeSunLeftHalf)" fill="currentColor" />
|
||||||
|
<path d="M12 2v2" />
|
||||||
|
<path d="M12 20v2" />
|
||||||
|
<path d="M4.93 4.93l1.41 1.41" />
|
||||||
|
<path d="M17.66 17.66l1.41 1.41" />
|
||||||
|
<path d="M2 12h2" />
|
||||||
|
<path d="M20 12h2" />
|
||||||
|
<path d="M6.34 17.66l-1.41 1.41" />
|
||||||
|
<path d="M19.07 4.93l-1.41 1.41" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
logout: (
|
||||||
|
<svg {...headerIconProps}>
|
||||||
|
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" />
|
||||||
|
<path d="m16 17 5-5-5-5" />
|
||||||
|
<path d="M21 12H9" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
const parseVersionSegments = (version?: string | null) => {
|
const parseVersionSegments = (version?: string | null) => {
|
||||||
if (!version) return null;
|
if (!version) return null;
|
||||||
@@ -140,6 +173,7 @@ const compareVersions = (latest?: string | null, current?: string | null) => {
|
|||||||
export function MainLayout() {
|
export function MainLayout() {
|
||||||
const { t, i18n } = useTranslation();
|
const { t, i18n } = useTranslation();
|
||||||
const { showNotification } = useNotificationStore();
|
const { showNotification } = useNotificationStore();
|
||||||
|
const location = useLocation();
|
||||||
|
|
||||||
const apiBase = useAuthStore((state) => state.apiBase);
|
const apiBase = useAuthStore((state) => state.apiBase);
|
||||||
const serverVersion = useAuthStore((state) => state.serverVersion);
|
const serverVersion = useAuthStore((state) => state.serverVersion);
|
||||||
@@ -153,7 +187,7 @@ export function MainLayout() {
|
|||||||
const updateConfigValue = useConfigStore((state) => state.updateConfigValue);
|
const updateConfigValue = useConfigStore((state) => state.updateConfigValue);
|
||||||
|
|
||||||
const theme = useThemeStore((state) => state.theme);
|
const theme = useThemeStore((state) => state.theme);
|
||||||
const toggleTheme = useThemeStore((state) => state.toggleTheme);
|
const cycleTheme = useThemeStore((state) => state.cycleTheme);
|
||||||
const toggleLanguage = useLanguageStore((state) => state.toggleLanguage);
|
const toggleLanguage = useLanguageStore((state) => state.toggleLanguage);
|
||||||
|
|
||||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||||
@@ -174,6 +208,7 @@ export function MainLayout() {
|
|||||||
const requestLogEnabled = config?.requestLog ?? false;
|
const requestLogEnabled = config?.requestLog ?? false;
|
||||||
const requestLogDirty = requestLogDraft !== requestLogEnabled;
|
const requestLogDirty = requestLogDraft !== requestLogEnabled;
|
||||||
const canEditRequestLog = connectionStatus === 'connected' && Boolean(config);
|
const canEditRequestLog = connectionStatus === 'connected' && Boolean(config);
|
||||||
|
const isLogsPage = location.pathname.startsWith('/logs');
|
||||||
|
|
||||||
// 将顶栏高度写入 CSS 变量,确保侧栏/内容区计算一致,防止滚动时抖动
|
// 将顶栏高度写入 CSS 变量,确保侧栏/内容区计算一致,防止滚动时抖动
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
@@ -187,7 +222,9 @@ export function MainLayout() {
|
|||||||
updateHeaderHeight();
|
updateHeaderHeight();
|
||||||
|
|
||||||
const resizeObserver =
|
const resizeObserver =
|
||||||
typeof ResizeObserver !== 'undefined' && headerRef.current ? new ResizeObserver(updateHeaderHeight) : null;
|
typeof ResizeObserver !== 'undefined' && headerRef.current
|
||||||
|
? new ResizeObserver(updateHeaderHeight)
|
||||||
|
: null;
|
||||||
if (resizeObserver && headerRef.current) {
|
if (resizeObserver && headerRef.current) {
|
||||||
resizeObserver.observe(headerRef.current);
|
resizeObserver.observe(headerRef.current);
|
||||||
}
|
}
|
||||||
@@ -320,8 +357,10 @@ export function MainLayout() {
|
|||||||
{ path: '/oauth', label: t('nav.oauth', { defaultValue: 'OAuth' }), icon: sidebarIcons.oauth },
|
{ path: '/oauth', label: t('nav.oauth', { defaultValue: 'OAuth' }), icon: sidebarIcons.oauth },
|
||||||
{ path: '/usage', label: t('nav.usage_stats'), icon: sidebarIcons.usage },
|
{ path: '/usage', label: t('nav.usage_stats'), icon: sidebarIcons.usage },
|
||||||
{ path: '/config', label: t('nav.config_management'), icon: sidebarIcons.config },
|
{ path: '/config', label: t('nav.config_management'), icon: sidebarIcons.config },
|
||||||
...(config?.loggingToFile ? [{ path: '/logs', label: t('nav.logs'), icon: sidebarIcons.logs }] : []),
|
...(config?.loggingToFile
|
||||||
{ path: '/system', label: t('nav.system_info'), icon: sidebarIcons.system }
|
? [{ path: '/logs', label: t('nav.logs'), icon: sidebarIcons.logs }]
|
||||||
|
: []),
|
||||||
|
{ path: '/system', label: t('nav.system_info'), icon: sidebarIcons.system },
|
||||||
];
|
];
|
||||||
|
|
||||||
const handleRefreshAll = async () => {
|
const handleRefreshAll = async () => {
|
||||||
@@ -370,7 +409,11 @@ export function MainLayout() {
|
|||||||
<button
|
<button
|
||||||
className="sidebar-toggle-header"
|
className="sidebar-toggle-header"
|
||||||
onClick={() => setSidebarCollapsed((prev) => !prev)}
|
onClick={() => setSidebarCollapsed((prev) => !prev)}
|
||||||
title={sidebarCollapsed ? t('sidebar.expand', { defaultValue: '展开' }) : t('sidebar.collapse', { defaultValue: '收起' })}
|
title={
|
||||||
|
sidebarCollapsed
|
||||||
|
? t('sidebar.expand', { defaultValue: '展开' })
|
||||||
|
: t('sidebar.collapse', { defaultValue: '收起' })
|
||||||
|
}
|
||||||
>
|
>
|
||||||
{sidebarCollapsed ? headerIcons.chevronRight : headerIcons.chevronLeft}
|
{sidebarCollapsed ? headerIcons.chevronRight : headerIcons.chevronLeft}
|
||||||
</button>
|
</button>
|
||||||
@@ -400,20 +443,40 @@ export function MainLayout() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="header-actions">
|
<div className="header-actions">
|
||||||
<Button className="mobile-menu-btn" variant="ghost" size="sm" onClick={() => setSidebarOpen((prev) => !prev)}>
|
<Button
|
||||||
|
className="mobile-menu-btn"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setSidebarOpen((prev) => !prev)}
|
||||||
|
>
|
||||||
{headerIcons.menu}
|
{headerIcons.menu}
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="ghost" size="sm" onClick={handleRefreshAll} title={t('header.refresh_all')}>
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleRefreshAll}
|
||||||
|
title={t('header.refresh_all')}
|
||||||
|
>
|
||||||
{headerIcons.refresh}
|
{headerIcons.refresh}
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="ghost" size="sm" onClick={handleVersionCheck} loading={checkingVersion} title={t('system_info.version_check_button')}>
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleVersionCheck}
|
||||||
|
loading={checkingVersion}
|
||||||
|
title={t('system_info.version_check_button')}
|
||||||
|
>
|
||||||
{headerIcons.update}
|
{headerIcons.update}
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="ghost" size="sm" onClick={toggleLanguage} title={t('language.switch')}>
|
<Button variant="ghost" size="sm" onClick={toggleLanguage} title={t('language.switch')}>
|
||||||
{headerIcons.language}
|
{headerIcons.language}
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="ghost" size="sm" onClick={toggleTheme} title={t('theme.switch')}>
|
<Button variant="ghost" size="sm" onClick={cycleTheme} title={t('theme.switch')}>
|
||||||
{theme === 'dark' ? headerIcons.sun : headerIcons.moon}
|
{theme === 'auto'
|
||||||
|
? headerIcons.autoTheme
|
||||||
|
: theme === 'dark'
|
||||||
|
? headerIcons.moon
|
||||||
|
: headerIcons.sun}
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="ghost" size="sm" onClick={logout} title={t('header.logout')}>
|
<Button variant="ghost" size="sm" onClick={logout} title={t('header.logout')}>
|
||||||
{headerIcons.logout}
|
{headerIcons.logout}
|
||||||
@@ -423,7 +486,9 @@ export function MainLayout() {
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div className="main-body">
|
<div className="main-body">
|
||||||
<aside className={`sidebar ${sidebarOpen ? 'open' : ''} ${sidebarCollapsed ? 'collapsed' : ''}`}>
|
<aside
|
||||||
|
className={`sidebar ${sidebarOpen ? 'open' : ''} ${sidebarCollapsed ? 'collapsed' : ''}`}
|
||||||
|
>
|
||||||
<div className="nav-section">
|
<div className="nav-section">
|
||||||
{navItems.map((item) => (
|
{navItems.map((item) => (
|
||||||
<NavLink
|
<NavLink
|
||||||
@@ -440,8 +505,8 @@ export function MainLayout() {
|
|||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<div className="content">
|
<div className={`content${isLogsPage ? ' content-logs' : ''}`}>
|
||||||
<main className="main-content">
|
<main className={`main-content${isLogsPage ? ' main-content-logs' : ''}`}>
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
@@ -454,7 +519,9 @@ export function MainLayout() {
|
|||||||
</span>
|
</span>
|
||||||
<span>
|
<span>
|
||||||
{t('footer.build_date')}:{' '}
|
{t('footer.build_date')}:{' '}
|
||||||
{serverBuildDate ? new Date(serverBuildDate).toLocaleString(i18n.language) : t('system_info.version_unknown')}
|
{serverBuildDate
|
||||||
|
? new Date(serverBuildDate).toLocaleString(i18n.language)
|
||||||
|
: t('system_info.version_unknown')}
|
||||||
</span>
|
</span>
|
||||||
</footer>
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -358,7 +358,7 @@
|
|||||||
"models_excluded_hint": "This model is excluded by OAuth"
|
"models_excluded_hint": "This model is excluded by OAuth"
|
||||||
},
|
},
|
||||||
"vertex_import": {
|
"vertex_import": {
|
||||||
"title": "Vertex AI Credential Import",
|
"title": "Vertex JSON Login",
|
||||||
"description": "Upload a Google service account JSON to store it as auth-dir/vertex-<project>.json using the same rules as the CLI vertex-import helper.",
|
"description": "Upload a Google service account JSON to store it as auth-dir/vertex-<project>.json using the same rules as the CLI vertex-import helper.",
|
||||||
"location_label": "Region (optional)",
|
"location_label": "Region (optional)",
|
||||||
"location_placeholder": "us-central1",
|
"location_placeholder": "us-central1",
|
||||||
@@ -534,6 +534,11 @@
|
|||||||
"by_hour": "By Hour",
|
"by_hour": "By Hour",
|
||||||
"by_day": "By Day",
|
"by_day": "By Day",
|
||||||
"refresh": "Refresh",
|
"refresh": "Refresh",
|
||||||
|
"export": "Export",
|
||||||
|
"import": "Import",
|
||||||
|
"export_success": "Usage export downloaded",
|
||||||
|
"import_success": "Import complete: added {{added}}, skipped {{skipped}}, total {{total}}, failed {{failed}}",
|
||||||
|
"import_invalid": "Invalid usage export file",
|
||||||
"chart_line_label_1": "Line 1",
|
"chart_line_label_1": "Line 1",
|
||||||
"chart_line_label_2": "Line 2",
|
"chart_line_label_2": "Line 2",
|
||||||
"chart_line_label_3": "Line 3",
|
"chart_line_label_3": "Line 3",
|
||||||
@@ -589,12 +594,18 @@
|
|||||||
"error_log_button": "Select Error Log",
|
"error_log_button": "Select Error Log",
|
||||||
"error_logs_modal_title": "Error Request Logs",
|
"error_logs_modal_title": "Error Request Logs",
|
||||||
"error_logs_description": "Pick an error request log file to download (only generated when request logging is off).",
|
"error_logs_description": "Pick an error request log file to download (only generated when request logging is off).",
|
||||||
|
"error_logs_request_log_enabled": "Request logging is enabled, so this list will always be empty. Disable request logging and refresh to view error logs.",
|
||||||
"error_logs_empty": "No error request log files found",
|
"error_logs_empty": "No error request log files found",
|
||||||
"error_logs_load_error": "Failed to load error log list",
|
"error_logs_load_error": "Failed to load error log list",
|
||||||
"error_logs_size": "Size",
|
"error_logs_size": "Size",
|
||||||
"error_logs_modified": "Last modified",
|
"error_logs_modified": "Last modified",
|
||||||
"error_logs_download": "Download",
|
"error_logs_download": "Download",
|
||||||
"error_log_download_success": "Error log downloaded successfully",
|
"error_log_download_success": "Error log downloaded successfully",
|
||||||
|
"request_log_download_title": "Download Request Log",
|
||||||
|
"request_log_download_confirm": "Download request log for ID {{id}}?",
|
||||||
|
"request_log_download_success": "Request log downloaded successfully",
|
||||||
|
"action_hint": "Double-click a log line to copy the raw text. Long-press a line with a request ID to download the request log.",
|
||||||
|
"action_hint_disabled": "Double-click a log line to copy the raw text. Enable request logging to long-press a line with a request ID and download the request log.",
|
||||||
"empty_title": "No Logs Available",
|
"empty_title": "No Logs Available",
|
||||||
"empty_desc": "When \"Enable logging to file\" is enabled, logs will be displayed here",
|
"empty_desc": "When \"Enable logging to file\" is enabled, logs will be displayed here",
|
||||||
"log_content": "Log Content",
|
"log_content": "Log Content",
|
||||||
|
|||||||
@@ -358,7 +358,7 @@
|
|||||||
"models_excluded_hint": "此模型已被 OAuth 排除"
|
"models_excluded_hint": "此模型已被 OAuth 排除"
|
||||||
},
|
},
|
||||||
"vertex_import": {
|
"vertex_import": {
|
||||||
"title": "Vertex AI 凭证导入",
|
"title": "Vertex JSON 登录",
|
||||||
"description": "上传 Google 服务账号 JSON,使用 CLI vertex-import 同步规则写入 auth-dir/vertex-<project>.json。",
|
"description": "上传 Google 服务账号 JSON,使用 CLI vertex-import 同步规则写入 auth-dir/vertex-<project>.json。",
|
||||||
"location_label": "目标区域 (可选)",
|
"location_label": "目标区域 (可选)",
|
||||||
"location_placeholder": "us-central1",
|
"location_placeholder": "us-central1",
|
||||||
@@ -534,6 +534,11 @@
|
|||||||
"by_hour": "按小时",
|
"by_hour": "按小时",
|
||||||
"by_day": "按天",
|
"by_day": "按天",
|
||||||
"refresh": "刷新",
|
"refresh": "刷新",
|
||||||
|
"export": "导出数据",
|
||||||
|
"import": "导入数据",
|
||||||
|
"export_success": "使用统计已导出",
|
||||||
|
"import_success": "导入完成:新增 {{added}},跳过 {{skipped}},总请求 {{total}},失败 {{failed}}",
|
||||||
|
"import_invalid": "导入文件格式不正确",
|
||||||
"chart_line_label_1": "曲线 1",
|
"chart_line_label_1": "曲线 1",
|
||||||
"chart_line_label_2": "曲线 2",
|
"chart_line_label_2": "曲线 2",
|
||||||
"chart_line_label_3": "曲线 3",
|
"chart_line_label_3": "曲线 3",
|
||||||
@@ -589,12 +594,18 @@
|
|||||||
"error_log_button": "选择错误日志",
|
"error_log_button": "选择错误日志",
|
||||||
"error_logs_modal_title": "错误请求日志",
|
"error_logs_modal_title": "错误请求日志",
|
||||||
"error_logs_description": "请选择要下载的错误请求日志文件(仅在关闭请求日志时生成)。",
|
"error_logs_description": "请选择要下载的错误请求日志文件(仅在关闭请求日志时生成)。",
|
||||||
|
"error_logs_request_log_enabled": "当前已开启请求日志,按接口约定错误请求日志列表会始终为空。关闭请求日志后再刷新即可查看。",
|
||||||
"error_logs_empty": "暂无错误请求日志文件",
|
"error_logs_empty": "暂无错误请求日志文件",
|
||||||
"error_logs_load_error": "加载错误日志列表失败",
|
"error_logs_load_error": "加载错误日志列表失败",
|
||||||
"error_logs_size": "大小",
|
"error_logs_size": "大小",
|
||||||
"error_logs_modified": "最后修改",
|
"error_logs_modified": "最后修改",
|
||||||
"error_logs_download": "下载",
|
"error_logs_download": "下载",
|
||||||
"error_log_download_success": "错误日志下载成功",
|
"error_log_download_success": "错误日志下载成功",
|
||||||
|
"request_log_download_title": "下载报文",
|
||||||
|
"request_log_download_confirm": "是否要下载id为{{id}}的报文?",
|
||||||
|
"request_log_download_success": "报文下载成功",
|
||||||
|
"action_hint": "双击日志行可复制原文,长按带有请求 ID 的日志可下载报文。",
|
||||||
|
"action_hint_disabled": "双击日志行可复制原文,启用请求日志后可长按带请求 ID 的日志下载报文。",
|
||||||
"empty_title": "暂无日志记录",
|
"empty_title": "暂无日志记录",
|
||||||
"empty_desc": "当启用\"日志记录到文件\"功能后,日志将显示在这里",
|
"empty_desc": "当启用\"日志记录到文件\"功能后,日志将显示在这里",
|
||||||
"log_content": "日志内容",
|
"log_content": "日志内容",
|
||||||
|
|||||||
@@ -397,6 +397,79 @@
|
|||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 状态监测栏
|
||||||
|
.statusBar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 8px;
|
||||||
|
padding: 8px 0;
|
||||||
|
max-width: 280px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.statusBlocks {
|
||||||
|
display: flex;
|
||||||
|
gap: 2px;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 180px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.statusBlock {
|
||||||
|
flex: 1;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 2px;
|
||||||
|
min-width: 6px;
|
||||||
|
transition: transform 0.15s ease, opacity 0.15s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
transform: scaleY(1.5);
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.statusBlockSuccess {
|
||||||
|
background-color: var(--success-color, #22c55e);
|
||||||
|
}
|
||||||
|
|
||||||
|
.statusBlockFailure {
|
||||||
|
background-color: var(--danger-color, #ef4444);
|
||||||
|
}
|
||||||
|
|
||||||
|
.statusBlockMixed {
|
||||||
|
background-color: var(--warning-color, #f59e0b);
|
||||||
|
}
|
||||||
|
|
||||||
|
.statusBlockIdle {
|
||||||
|
background-color: var(--border-secondary, #e5e7eb);
|
||||||
|
}
|
||||||
|
|
||||||
|
.statusRate {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
white-space: nowrap;
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.statusRateHigh {
|
||||||
|
color: var(--success-badge-text, #065f46);
|
||||||
|
background: var(--success-badge-bg, #d1fae5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.statusRateMedium {
|
||||||
|
color: var(--warning-text, #92400e);
|
||||||
|
background: var(--warning-bg, #fef3c7);
|
||||||
|
}
|
||||||
|
|
||||||
|
.statusRateLow {
|
||||||
|
color: var(--failure-badge-text, #991b1b);
|
||||||
|
background: var(--failure-badge-bg, #fee2e2);
|
||||||
|
}
|
||||||
|
|
||||||
// 暗色主题适配
|
// 暗色主题适配
|
||||||
:global([data-theme='dark']) {
|
:global([data-theme='dark']) {
|
||||||
.headerBadge {
|
.headerBadge {
|
||||||
@@ -436,4 +509,23 @@
|
|||||||
.apiKeyEntryIndex {
|
.apiKeyEntryIndex {
|
||||||
background: var(--primary-color);
|
background: var(--primary-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.statusBlockIdle {
|
||||||
|
background-color: var(--border-primary, #374151);
|
||||||
|
}
|
||||||
|
|
||||||
|
.statusRateHigh {
|
||||||
|
background: rgba(34, 197, 94, 0.2);
|
||||||
|
color: #86efac;
|
||||||
|
}
|
||||||
|
|
||||||
|
.statusRateMedium {
|
||||||
|
background: rgba(251, 191, 36, 0.2);
|
||||||
|
color: #fde68a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.statusRateLow {
|
||||||
|
background: rgba(239, 68, 68, 0.2);
|
||||||
|
color: #fca5a5;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Fragment, useCallback, useEffect, useMemo, useState, type ReactNode } from 'react';
|
import { Fragment, useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { useInterval } from '@/hooks/useInterval';
|
||||||
import { Card } from '@/components/ui/Card';
|
import { Card } from '@/components/ui/Card';
|
||||||
import { Button } from '@/components/ui/Button';
|
import { Button } from '@/components/ui/Button';
|
||||||
import { Input } from '@/components/ui/Input';
|
import { Input } from '@/components/ui/Input';
|
||||||
@@ -24,7 +25,8 @@ import type {
|
|||||||
AmpcodeConfig,
|
AmpcodeConfig,
|
||||||
AmpcodeModelMapping,
|
AmpcodeModelMapping,
|
||||||
} from '@/types';
|
} from '@/types';
|
||||||
import type { KeyStats, KeyStatBucket } from '@/utils/usage';
|
import type { KeyStats, KeyStatBucket, UsageDetail } from '@/utils/usage';
|
||||||
|
import { collectUsageDetails, calculateStatusBarData } from '@/utils/usage';
|
||||||
import type { ModelInfo } from '@/utils/models';
|
import type { ModelInfo } from '@/utils/models';
|
||||||
import { headersToEntries, buildHeaderObject, type HeaderEntry } from '@/utils/headers';
|
import { headersToEntries, buildHeaderObject, type HeaderEntry } from '@/utils/headers';
|
||||||
import { maskApiKey } from '@/utils/format';
|
import { maskApiKey } from '@/utils/format';
|
||||||
@@ -202,6 +204,8 @@ export function AiProvidersPage() {
|
|||||||
const [claudeConfigs, setClaudeConfigs] = useState<ProviderKeyConfig[]>([]);
|
const [claudeConfigs, setClaudeConfigs] = useState<ProviderKeyConfig[]>([]);
|
||||||
const [openaiProviders, setOpenaiProviders] = useState<OpenAIProviderConfig[]>([]);
|
const [openaiProviders, setOpenaiProviders] = useState<OpenAIProviderConfig[]>([]);
|
||||||
const [keyStats, setKeyStats] = useState<KeyStats>({ bySource: {}, byAuthIndex: {} });
|
const [keyStats, setKeyStats] = useState<KeyStats>({ bySource: {}, byAuthIndex: {} });
|
||||||
|
const [usageDetails, setUsageDetails] = useState<UsageDetail[]>([]);
|
||||||
|
const loadingKeyStatsRef = useRef(false);
|
||||||
|
|
||||||
const [modal, setModal] = useState<ProviderModal | null>(null);
|
const [modal, setModal] = useState<ProviderModal | null>(null);
|
||||||
|
|
||||||
@@ -273,13 +277,23 @@ export function AiProvidersPage() {
|
|||||||
[openaiForm.modelEntries]
|
[openaiForm.modelEntries]
|
||||||
);
|
);
|
||||||
|
|
||||||
// 加载 key 统计
|
// 加载 key 统计和 usage 明细(API 层已有60秒超时)
|
||||||
const loadKeyStats = useCallback(async () => {
|
const loadKeyStats = useCallback(async () => {
|
||||||
|
// 防止重复请求
|
||||||
|
if (loadingKeyStatsRef.current) return;
|
||||||
|
loadingKeyStatsRef.current = true;
|
||||||
try {
|
try {
|
||||||
const stats = await usageApi.getKeyStats();
|
const usageResponse = await usageApi.getUsage();
|
||||||
|
const usageData = usageResponse?.usage ?? usageResponse;
|
||||||
|
const stats = await usageApi.getKeyStats(usageData);
|
||||||
setKeyStats(stats);
|
setKeyStats(stats);
|
||||||
|
// 收集 usage 明细用于状态栏
|
||||||
|
const details = collectUsageDetails(usageData);
|
||||||
|
setUsageDetails(details);
|
||||||
} catch {
|
} catch {
|
||||||
// 静默失败
|
// 静默失败
|
||||||
|
} finally {
|
||||||
|
loadingKeyStatsRef.current = false;
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -311,6 +325,9 @@ export function AiProvidersPage() {
|
|||||||
loadKeyStats();
|
loadKeyStats();
|
||||||
}, [loadKeyStats]);
|
}, [loadKeyStats]);
|
||||||
|
|
||||||
|
// 定时刷新状态数据(每240秒)
|
||||||
|
useInterval(loadKeyStats, 240_000);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (config?.geminiApiKeys) setGeminiKeys(config.geminiApiKeys);
|
if (config?.geminiApiKeys) setGeminiKeys(config.geminiApiKeys);
|
||||||
if (config?.codexApiKeys) setCodexConfigs(config.codexApiKeys);
|
if (config?.codexApiKeys) setCodexConfigs(config.codexApiKeys);
|
||||||
@@ -1090,6 +1107,108 @@ export function AiProvidersPage() {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 预计算所有 apiKey 的状态栏数据(避免每次渲染重复计算)
|
||||||
|
const statusBarCache = useMemo(() => {
|
||||||
|
const cache = new Map<string, ReturnType<typeof calculateStatusBarData>>();
|
||||||
|
|
||||||
|
// 收集所有需要计算的 apiKey
|
||||||
|
const allApiKeys = new Set<string>();
|
||||||
|
geminiKeys.forEach((k) => k.apiKey && allApiKeys.add(k.apiKey));
|
||||||
|
codexConfigs.forEach((k) => k.apiKey && allApiKeys.add(k.apiKey));
|
||||||
|
claudeConfigs.forEach((k) => k.apiKey && allApiKeys.add(k.apiKey));
|
||||||
|
openaiProviders.forEach((p) => {
|
||||||
|
(p.apiKeyEntries || []).forEach((e) => e.apiKey && allApiKeys.add(e.apiKey));
|
||||||
|
});
|
||||||
|
|
||||||
|
// 预计算每个 apiKey 的状态数据
|
||||||
|
allApiKeys.forEach((apiKey) => {
|
||||||
|
cache.set(apiKey, calculateStatusBarData(usageDetails, apiKey));
|
||||||
|
});
|
||||||
|
|
||||||
|
return cache;
|
||||||
|
}, [usageDetails, geminiKeys, codexConfigs, claudeConfigs, openaiProviders]);
|
||||||
|
|
||||||
|
// 预计算 OpenAI 提供商的汇总状态栏数据
|
||||||
|
const openaiStatusBarCache = useMemo(() => {
|
||||||
|
const cache = new Map<string, ReturnType<typeof calculateStatusBarData>>();
|
||||||
|
|
||||||
|
openaiProviders.forEach((provider) => {
|
||||||
|
const allKeys = (provider.apiKeyEntries || []).map((e) => e.apiKey).filter(Boolean);
|
||||||
|
const filteredDetails = usageDetails.filter((detail) => allKeys.includes(detail.source));
|
||||||
|
cache.set(provider.name, calculateStatusBarData(filteredDetails));
|
||||||
|
});
|
||||||
|
|
||||||
|
return cache;
|
||||||
|
}, [usageDetails, openaiProviders]);
|
||||||
|
|
||||||
|
// 渲染状态监测栏
|
||||||
|
const renderStatusBar = (apiKey: string) => {
|
||||||
|
const statusData = statusBarCache.get(apiKey) || calculateStatusBarData([], apiKey);
|
||||||
|
const hasData = statusData.totalSuccess + statusData.totalFailure > 0;
|
||||||
|
const rateClass = !hasData
|
||||||
|
? ''
|
||||||
|
: statusData.successRate >= 90
|
||||||
|
? styles.statusRateHigh
|
||||||
|
: statusData.successRate >= 50
|
||||||
|
? styles.statusRateMedium
|
||||||
|
: styles.statusRateLow;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.statusBar}>
|
||||||
|
<div className={styles.statusBlocks}>
|
||||||
|
{statusData.blocks.map((state, idx) => {
|
||||||
|
const blockClass =
|
||||||
|
state === 'success'
|
||||||
|
? styles.statusBlockSuccess
|
||||||
|
: state === 'failure'
|
||||||
|
? styles.statusBlockFailure
|
||||||
|
: state === 'mixed'
|
||||||
|
? styles.statusBlockMixed
|
||||||
|
: styles.statusBlockIdle;
|
||||||
|
return <div key={idx} className={`${styles.statusBlock} ${blockClass}`} />;
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<span className={`${styles.statusRate} ${rateClass}`}>
|
||||||
|
{hasData ? `${statusData.successRate.toFixed(1)}%` : '--'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 渲染 OpenAI 提供商的状态栏(汇总多个 apiKey)
|
||||||
|
const renderOpenAIStatusBar = (providerName: string) => {
|
||||||
|
const statusData = openaiStatusBarCache.get(providerName) || calculateStatusBarData([]);
|
||||||
|
const hasData = statusData.totalSuccess + statusData.totalFailure > 0;
|
||||||
|
const rateClass = !hasData
|
||||||
|
? ''
|
||||||
|
: statusData.successRate >= 90
|
||||||
|
? styles.statusRateHigh
|
||||||
|
: statusData.successRate >= 50
|
||||||
|
? styles.statusRateMedium
|
||||||
|
: styles.statusRateLow;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.statusBar}>
|
||||||
|
<div className={styles.statusBlocks}>
|
||||||
|
{statusData.blocks.map((state, idx) => {
|
||||||
|
const blockClass =
|
||||||
|
state === 'success'
|
||||||
|
? styles.statusBlockSuccess
|
||||||
|
: state === 'failure'
|
||||||
|
? styles.statusBlockFailure
|
||||||
|
: state === 'mixed'
|
||||||
|
? styles.statusBlockMixed
|
||||||
|
: styles.statusBlockIdle;
|
||||||
|
return <div key={idx} className={`${styles.statusBlock} ${blockClass}`} />;
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<span className={`${styles.statusRate} ${rateClass}`}>
|
||||||
|
{hasData ? `${statusData.successRate.toFixed(1)}%` : '--'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const renderList = <T,>(
|
const renderList = <T,>(
|
||||||
items: T[],
|
items: T[],
|
||||||
keyField: (item: T) => string,
|
keyField: (item: T) => string,
|
||||||
@@ -1254,6 +1373,8 @@ export function AiProvidersPage() {
|
|||||||
{t('stats.failure')}: {stats.failure}
|
{t('stats.failure')}: {stats.failure}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
{/* 状态监测栏 */}
|
||||||
|
{renderStatusBar(item.apiKey)}
|
||||||
</Fragment>
|
</Fragment>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -1370,6 +1491,8 @@ export function AiProvidersPage() {
|
|||||||
{t('stats.failure')}: {stats.failure}
|
{t('stats.failure')}: {stats.failure}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
{/* 状态监测栏 */}
|
||||||
|
{renderStatusBar(item.apiKey)}
|
||||||
</Fragment>
|
</Fragment>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -1502,6 +1625,8 @@ export function AiProvidersPage() {
|
|||||||
{t('stats.failure')}: {stats.failure}
|
{t('stats.failure')}: {stats.failure}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
{/* 状态监测栏 */}
|
||||||
|
{renderStatusBar(item.apiKey)}
|
||||||
</Fragment>
|
</Fragment>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -1721,6 +1846,8 @@ export function AiProvidersPage() {
|
|||||||
{t('stats.failure')}: {stats.failure}
|
{t('stats.failure')}: {stats.failure}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
{/* 状态监测栏(汇总) */}
|
||||||
|
{renderOpenAIStatusBar(item.name)}
|
||||||
</Fragment>
|
</Fragment>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -250,6 +250,78 @@
|
|||||||
border-color: var(--failure-badge-border, #fca5a5);
|
border-color: var(--failure-badge-border, #fca5a5);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 状态监测栏
|
||||||
|
.statusBar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 4px 0;
|
||||||
|
max-width: 280px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.statusBlocks {
|
||||||
|
display: flex;
|
||||||
|
gap: 2px;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 180px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.statusBlock {
|
||||||
|
flex: 1;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 2px;
|
||||||
|
min-width: 6px;
|
||||||
|
transition: transform 0.15s ease, opacity 0.15s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
transform: scaleY(1.5);
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.statusBlockSuccess {
|
||||||
|
background-color: var(--success-color, #22c55e);
|
||||||
|
}
|
||||||
|
|
||||||
|
.statusBlockFailure {
|
||||||
|
background-color: var(--danger-color, #ef4444);
|
||||||
|
}
|
||||||
|
|
||||||
|
.statusBlockMixed {
|
||||||
|
background-color: var(--warning-color, #f59e0b);
|
||||||
|
}
|
||||||
|
|
||||||
|
.statusBlockIdle {
|
||||||
|
background-color: var(--border-secondary, #e5e7eb);
|
||||||
|
}
|
||||||
|
|
||||||
|
.statusRate {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
white-space: nowrap;
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.statusRateHigh {
|
||||||
|
color: var(--success-badge-text, #065f46);
|
||||||
|
background: var(--success-badge-bg, #d1fae5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.statusRateMedium {
|
||||||
|
color: var(--warning-text, #92400e);
|
||||||
|
background: var(--warning-bg, #fef3c7);
|
||||||
|
}
|
||||||
|
|
||||||
|
.statusRateLow {
|
||||||
|
color: var(--failure-badge-text, #991b1b);
|
||||||
|
background: var(--failure-badge-bg, #fee2e2);
|
||||||
|
}
|
||||||
|
|
||||||
.cardActions {
|
.cardActions {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: $spacing-xs;
|
gap: $spacing-xs;
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useEffect, useMemo, useRef, useState, useCallback } from 'react';
|
import { useEffect, useMemo, useRef, useState, useCallback } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { useInterval } from '@/hooks/useInterval';
|
||||||
import { Card } from '@/components/ui/Card';
|
import { Card } from '@/components/ui/Card';
|
||||||
import { Button } from '@/components/ui/Button';
|
import { Button } from '@/components/ui/Button';
|
||||||
import { LoadingSpinner } from '@/components/ui/LoadingSpinner';
|
import { LoadingSpinner } from '@/components/ui/LoadingSpinner';
|
||||||
@@ -11,12 +12,14 @@ import { useAuthStore, useNotificationStore, useThemeStore } from '@/stores';
|
|||||||
import { authFilesApi, usageApi } from '@/services/api';
|
import { authFilesApi, usageApi } from '@/services/api';
|
||||||
import { apiClient } from '@/services/api/client';
|
import { apiClient } from '@/services/api/client';
|
||||||
import type { AuthFileItem } from '@/types';
|
import type { AuthFileItem } from '@/types';
|
||||||
import type { KeyStats, KeyStatBucket } from '@/utils/usage';
|
import type { KeyStats, KeyStatBucket, UsageDetail } from '@/utils/usage';
|
||||||
|
import { collectUsageDetails, calculateStatusBarData } from '@/utils/usage';
|
||||||
import { formatFileSize } from '@/utils/format';
|
import { formatFileSize } from '@/utils/format';
|
||||||
import styles from './AuthFilesPage.module.scss';
|
import styles from './AuthFilesPage.module.scss';
|
||||||
|
|
||||||
type ThemeColors = { bg: string; text: string; border?: string };
|
type ThemeColors = { bg: string; text: string; border?: string };
|
||||||
type TypeColorSet = { light: ThemeColors; dark?: ThemeColors };
|
type TypeColorSet = { light: ThemeColors; dark?: ThemeColors };
|
||||||
|
type ResolvedTheme = 'light' | 'dark';
|
||||||
|
|
||||||
// 标签类型颜色配置(对齐重构前 styles.css 的 file-type-badge 颜色)
|
// 标签类型颜色配置(对齐重构前 styles.css 的 file-type-badge 颜色)
|
||||||
const TYPE_COLORS: Record<string, TypeColorSet> = {
|
const TYPE_COLORS: Record<string, TypeColorSet> = {
|
||||||
@@ -129,7 +132,7 @@ export function AuthFilesPage() {
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { showNotification } = useNotificationStore();
|
const { showNotification } = useNotificationStore();
|
||||||
const connectionStatus = useAuthStore((state) => state.connectionStatus);
|
const connectionStatus = useAuthStore((state) => state.connectionStatus);
|
||||||
const theme = useThemeStore((state) => state.theme);
|
const resolvedTheme: ResolvedTheme = useThemeStore((state) => state.resolvedTheme);
|
||||||
|
|
||||||
const [files, setFiles] = useState<AuthFileItem[]>([]);
|
const [files, setFiles] = useState<AuthFileItem[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
@@ -142,6 +145,7 @@ export function AuthFilesPage() {
|
|||||||
const [deleting, setDeleting] = useState<string | null>(null);
|
const [deleting, setDeleting] = useState<string | null>(null);
|
||||||
const [deletingAll, setDeletingAll] = useState(false);
|
const [deletingAll, setDeletingAll] = useState(false);
|
||||||
const [keyStats, setKeyStats] = useState<KeyStats>({ bySource: {}, byAuthIndex: {} });
|
const [keyStats, setKeyStats] = useState<KeyStats>({ bySource: {}, byAuthIndex: {} });
|
||||||
|
const [usageDetails, setUsageDetails] = useState<UsageDetail[]>([]);
|
||||||
|
|
||||||
// 详情弹窗相关
|
// 详情弹窗相关
|
||||||
const [detailModalOpen, setDetailModalOpen] = useState(false);
|
const [detailModalOpen, setDetailModalOpen] = useState(false);
|
||||||
@@ -163,6 +167,7 @@ export function AuthFilesPage() {
|
|||||||
const [savingExcluded, setSavingExcluded] = useState(false);
|
const [savingExcluded, setSavingExcluded] = useState(false);
|
||||||
|
|
||||||
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
||||||
|
const loadingKeyStatsRef = useRef(false);
|
||||||
const excludedUnsupportedRef = useRef(false);
|
const excludedUnsupportedRef = useRef(false);
|
||||||
|
|
||||||
const disableControls = connectionStatus !== 'connected';
|
const disableControls = connectionStatus !== 'connected';
|
||||||
@@ -194,13 +199,23 @@ export function AuthFilesPage() {
|
|||||||
}
|
}
|
||||||
}, [t]);
|
}, [t]);
|
||||||
|
|
||||||
// 加载 key 统计
|
// 加载 key 统计和 usage 明细(API 层已有60秒超时)
|
||||||
const loadKeyStats = useCallback(async () => {
|
const loadKeyStats = useCallback(async () => {
|
||||||
|
// 防止重复请求
|
||||||
|
if (loadingKeyStatsRef.current) return;
|
||||||
|
loadingKeyStatsRef.current = true;
|
||||||
try {
|
try {
|
||||||
const stats = await usageApi.getKeyStats();
|
const usageResponse = await usageApi.getUsage();
|
||||||
|
const usageData = usageResponse?.usage ?? usageResponse;
|
||||||
|
const stats = await usageApi.getKeyStats(usageData);
|
||||||
setKeyStats(stats);
|
setKeyStats(stats);
|
||||||
|
// 收集 usage 明细用于状态栏
|
||||||
|
const details = collectUsageDetails(usageData);
|
||||||
|
setUsageDetails(details);
|
||||||
} catch {
|
} catch {
|
||||||
// 静默失败
|
// 静默失败
|
||||||
|
} finally {
|
||||||
|
loadingKeyStatsRef.current = false;
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -236,6 +251,9 @@ export function AuthFilesPage() {
|
|||||||
loadExcluded();
|
loadExcluded();
|
||||||
}, [loadFiles, loadKeyStats, loadExcluded]);
|
}, [loadFiles, loadKeyStats, loadExcluded]);
|
||||||
|
|
||||||
|
// 定时刷新状态数据(每240秒)
|
||||||
|
useInterval(loadKeyStats, 240_000);
|
||||||
|
|
||||||
// 提取所有存在的类型
|
// 提取所有存在的类型
|
||||||
const existingTypes = useMemo(() => {
|
const existingTypes = useMemo(() => {
|
||||||
const types = new Set<string>(['all']);
|
const types = new Set<string>(['all']);
|
||||||
@@ -488,7 +506,7 @@ export function AuthFilesPage() {
|
|||||||
// 获取类型颜色
|
// 获取类型颜色
|
||||||
const getTypeColor = (type: string): ThemeColors => {
|
const getTypeColor = (type: string): ThemeColors => {
|
||||||
const set = TYPE_COLORS[type] || TYPE_COLORS.unknown;
|
const set = TYPE_COLORS[type] || TYPE_COLORS.unknown;
|
||||||
return theme === 'dark' && set.dark ? set.dark : set.light;
|
return resolvedTheme === 'dark' && set.dark ? set.dark : set.light;
|
||||||
};
|
};
|
||||||
|
|
||||||
// OAuth 排除相关方法
|
// OAuth 排除相关方法
|
||||||
@@ -547,7 +565,7 @@ export function AuthFilesPage() {
|
|||||||
{existingTypes.map((type) => {
|
{existingTypes.map((type) => {
|
||||||
const isActive = filter === type;
|
const isActive = filter === type;
|
||||||
const color = type === 'all' ? { bg: 'var(--bg-tertiary)', text: 'var(--text-primary)' } : getTypeColor(type);
|
const color = type === 'all' ? { bg: 'var(--bg-tertiary)', text: 'var(--text-primary)' } : getTypeColor(type);
|
||||||
const activeTextColor = theme === 'dark' ? '#111827' : '#fff';
|
const activeTextColor = resolvedTheme === 'dark' ? '#111827' : '#fff';
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={type}
|
key={type}
|
||||||
@@ -569,6 +587,65 @@ export function AuthFilesPage() {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// 预计算所有认证文件的状态栏数据(避免每次渲染重复计算)
|
||||||
|
const statusBarCache = useMemo(() => {
|
||||||
|
const cache = new Map<string, ReturnType<typeof calculateStatusBarData>>();
|
||||||
|
|
||||||
|
files.forEach((file) => {
|
||||||
|
const rawAuthIndex = file['auth_index'] ?? file.authIndex;
|
||||||
|
const authIndexKey = normalizeAuthIndexValue(rawAuthIndex);
|
||||||
|
|
||||||
|
if (authIndexKey) {
|
||||||
|
// 过滤出属于该认证文件的 usage 明细
|
||||||
|
const filteredDetails = usageDetails.filter((detail) => {
|
||||||
|
const detailAuthIndex = normalizeAuthIndexValue(detail.auth_index);
|
||||||
|
return detailAuthIndex !== null && detailAuthIndex === authIndexKey;
|
||||||
|
});
|
||||||
|
cache.set(authIndexKey, calculateStatusBarData(filteredDetails));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return cache;
|
||||||
|
}, [usageDetails, files]);
|
||||||
|
|
||||||
|
// 渲染状态监测栏
|
||||||
|
const renderStatusBar = (item: AuthFileItem) => {
|
||||||
|
// 认证文件使用 authIndex 来匹配 usage 数据
|
||||||
|
const rawAuthIndex = item['auth_index'] ?? item.authIndex;
|
||||||
|
const authIndexKey = normalizeAuthIndexValue(rawAuthIndex);
|
||||||
|
|
||||||
|
const statusData = (authIndexKey && statusBarCache.get(authIndexKey)) || calculateStatusBarData([]);
|
||||||
|
const hasData = statusData.totalSuccess + statusData.totalFailure > 0;
|
||||||
|
const rateClass = !hasData
|
||||||
|
? ''
|
||||||
|
: statusData.successRate >= 90
|
||||||
|
? styles.statusRateHigh
|
||||||
|
: statusData.successRate >= 50
|
||||||
|
? styles.statusRateMedium
|
||||||
|
: styles.statusRateLow;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.statusBar}>
|
||||||
|
<div className={styles.statusBlocks}>
|
||||||
|
{statusData.blocks.map((state, idx) => {
|
||||||
|
const blockClass =
|
||||||
|
state === 'success'
|
||||||
|
? styles.statusBlockSuccess
|
||||||
|
: state === 'failure'
|
||||||
|
? styles.statusBlockFailure
|
||||||
|
: state === 'mixed'
|
||||||
|
? styles.statusBlockMixed
|
||||||
|
: styles.statusBlockIdle;
|
||||||
|
return <div key={idx} className={`${styles.statusBlock} ${blockClass}`} />;
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<span className={`${styles.statusRate} ${rateClass}`}>
|
||||||
|
{hasData ? `${statusData.successRate.toFixed(1)}%` : '--'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
// 渲染单个认证文件卡片
|
// 渲染单个认证文件卡片
|
||||||
const renderFileCard = (item: AuthFileItem) => {
|
const renderFileCard = (item: AuthFileItem) => {
|
||||||
const fileStats = resolveAuthFileStats(item, keyStats);
|
const fileStats = resolveAuthFileStats(item, keyStats);
|
||||||
@@ -605,6 +682,9 @@ export function AuthFilesPage() {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 状态监测栏 */}
|
||||||
|
{renderStatusBar(item)}
|
||||||
|
|
||||||
<div className={styles.cardActions}>
|
<div className={styles.cardActions}>
|
||||||
{isRuntimeOnly ? (
|
{isRuntimeOnly ? (
|
||||||
<div className={styles.virtualBadge}>{t('auth_files.type_virtual') || '虚拟认证文件'}</div>
|
<div className={styles.virtualBadge}>{t('auth_files.type_virtual') || '虚拟认证文件'}</div>
|
||||||
|
|||||||
@@ -134,8 +134,8 @@
|
|||||||
|
|
||||||
.editorWrapper {
|
.editorWrapper {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
flex: 1;
|
flex: 0 0 auto;
|
||||||
min-height: 200px;
|
height: 480px;
|
||||||
border: 1px solid var(--border-color);
|
border: 1px solid var(--border-color);
|
||||||
border-radius: $radius-lg;
|
border-radius: $radius-lg;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
@@ -238,3 +238,26 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (max-height: 820px) {
|
||||||
|
.pageTitle {
|
||||||
|
font-size: 24px;
|
||||||
|
margin-bottom: $spacing-sm;
|
||||||
|
}
|
||||||
|
|
||||||
|
.description {
|
||||||
|
margin-bottom: $spacing-lg;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content {
|
||||||
|
gap: $spacing-md;
|
||||||
|
}
|
||||||
|
|
||||||
|
.configCard {
|
||||||
|
padding: $spacing-md;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editorWrapper {
|
||||||
|
height: 360px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ export function ConfigPage() {
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { showNotification } = useNotificationStore();
|
const { showNotification } = useNotificationStore();
|
||||||
const connectionStatus = useAuthStore((state) => state.connectionStatus);
|
const connectionStatus = useAuthStore((state) => state.connectionStatus);
|
||||||
const theme = useThemeStore((state) => state.theme);
|
const resolvedTheme = useThemeStore((state) => state.resolvedTheme);
|
||||||
|
|
||||||
const [content, setContent] = useState('');
|
const [content, setContent] = useState('');
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
@@ -289,7 +289,7 @@ export function ConfigPage() {
|
|||||||
value={content}
|
value={content}
|
||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
extensions={extensions}
|
extensions={extensions}
|
||||||
theme={theme === 'dark' ? 'dark' : 'light'}
|
theme={resolvedTheme}
|
||||||
editable={!disableControls && !loading}
|
editable={!disableControls && !loading}
|
||||||
placeholder={t('config_management.editor_placeholder')}
|
placeholder={t('config_management.editor_placeholder')}
|
||||||
height="100%"
|
height="100%"
|
||||||
|
|||||||
@@ -242,7 +242,11 @@ export function DashboardPage() {
|
|||||||
</div>
|
</div>
|
||||||
<div className={styles.connectionInfo}>
|
<div className={styles.connectionInfo}>
|
||||||
<span className={styles.serverUrl}>{apiBase || '-'}</span>
|
<span className={styles.serverUrl}>{apiBase || '-'}</span>
|
||||||
{serverVersion && <span className={styles.serverVersion}>v{serverVersion}</span>}
|
{serverVersion && (
|
||||||
|
<span className={styles.serverVersion}>
|
||||||
|
v{serverVersion.trim().replace(/^[vV]+/, '')}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
{serverBuildDate && (
|
{serverBuildDate && (
|
||||||
<span className={styles.buildDate}>
|
<span className={styles.buildDate}>
|
||||||
{new Date(serverBuildDate).toLocaleDateString(i18n.language)}
|
{new Date(serverBuildDate).toLocaleDateString(i18n.language)}
|
||||||
|
|||||||
@@ -2,11 +2,15 @@
|
|||||||
|
|
||||||
.container {
|
.container {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
|
||||||
flex: 1;
|
flex: 1;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
|
|
||||||
|
@include mobile {
|
||||||
|
min-height: auto;
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.pageTitle {
|
.pageTitle {
|
||||||
@@ -53,6 +57,11 @@
|
|||||||
gap: $spacing-lg;
|
gap: $spacing-lg;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
|
|
||||||
|
@include mobile {
|
||||||
|
gap: $spacing-md;
|
||||||
|
min-height: auto;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.logCard {
|
.logCard {
|
||||||
@@ -61,6 +70,12 @@
|
|||||||
flex: 1;
|
flex: 1;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
|
||||||
|
@include mobile {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
min-height: auto;
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.toolbar {
|
.toolbar {
|
||||||
@@ -87,6 +102,11 @@
|
|||||||
:global(.form-group) {
|
:global(.form-group) {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@include mobile {
|
||||||
|
gap: $spacing-sm;
|
||||||
|
margin-bottom: $spacing-sm;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.searchWrapper {
|
.searchWrapper {
|
||||||
@@ -161,13 +181,33 @@
|
|||||||
background: var(--bg-secondary);
|
background: var(--bg-secondary);
|
||||||
border: 1px solid var(--border-color);
|
border: 1px solid var(--border-color);
|
||||||
border-radius: $radius-md;
|
border-radius: $radius-md;
|
||||||
flex: 1;
|
flex: 1 1 auto;
|
||||||
min-height: 200px;
|
min-height: 280px;
|
||||||
|
max-height: calc(100vh - 320px);
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
position: relative;
|
position: relative;
|
||||||
-webkit-overflow-scrolling: touch;
|
-webkit-overflow-scrolling: touch;
|
||||||
touch-action: pan-y;
|
touch-action: pan-y;
|
||||||
overscroll-behavior: contain;
|
overscroll-behavior: contain;
|
||||||
|
|
||||||
|
@include tablet {
|
||||||
|
min-height: 240px;
|
||||||
|
max-height: calc(100vh - 300px);
|
||||||
|
}
|
||||||
|
|
||||||
|
@include mobile {
|
||||||
|
min-height: 360px;
|
||||||
|
max-height: 480px;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.errorPanel {
|
||||||
|
height: 480px;
|
||||||
|
overflow: auto;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
overscroll-behavior: contain;
|
||||||
}
|
}
|
||||||
|
|
||||||
.loadMoreBanner {
|
.loadMoreBanner {
|
||||||
@@ -183,6 +223,17 @@
|
|||||||
background: var(--bg-primary);
|
background: var(--bg-primary);
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
|
|
||||||
|
@include mobile {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: flex-start;
|
||||||
|
gap: $spacing-xs;
|
||||||
|
|
||||||
|
> span {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.loadMoreCount {
|
.loadMoreCount {
|
||||||
@@ -194,6 +245,16 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: $spacing-md;
|
gap: $spacing-md;
|
||||||
|
|
||||||
|
@include mobile {
|
||||||
|
width: 100%;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: $spacing-sm;
|
||||||
|
|
||||||
|
> span {
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.logList {
|
.logList {
|
||||||
@@ -220,9 +281,18 @@
|
|||||||
background: rgba(59, 130, 246, 0.06);
|
background: rgba(59, 130, 246, 0.06);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@include tablet {
|
||||||
|
grid-template-columns: 140px 1fr;
|
||||||
|
gap: $spacing-sm;
|
||||||
|
padding: 8px 10px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
@include mobile {
|
@include mobile {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
gap: $spacing-xs;
|
gap: $spacing-xs;
|
||||||
|
padding: 8px 10px;
|
||||||
|
font-size: 11.5px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -364,10 +434,101 @@
|
|||||||
|
|
||||||
@include mobile {
|
@include mobile {
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
|
flex-basis: 100%;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.message {
|
.message {
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
word-break: break-word;
|
word-break: break-word;
|
||||||
|
|
||||||
|
@include mobile {
|
||||||
|
flex-basis: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-height: 820px) {
|
||||||
|
.pageTitle {
|
||||||
|
font-size: 24px;
|
||||||
|
margin-bottom: $spacing-md;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tabBar {
|
||||||
|
margin-bottom: $spacing-md;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tabItem {
|
||||||
|
padding: 10px 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content {
|
||||||
|
gap: $spacing-md;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logCard {
|
||||||
|
padding: $spacing-md;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logPanel {
|
||||||
|
min-height: 200px;
|
||||||
|
max-height: calc(100vh - 280px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.logRow {
|
||||||
|
padding: 8px 10px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.errorPanel {
|
||||||
|
height: 360px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-height: 600px) {
|
||||||
|
.pageTitle {
|
||||||
|
font-size: 20px;
|
||||||
|
margin-bottom: $spacing-sm;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tabBar {
|
||||||
|
margin-bottom: $spacing-sm;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tabItem {
|
||||||
|
padding: 8px 12px;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content {
|
||||||
|
gap: $spacing-sm;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filters {
|
||||||
|
margin-bottom: $spacing-sm;
|
||||||
|
gap: $spacing-sm;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logCard {
|
||||||
|
padding: $spacing-sm;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logPanel {
|
||||||
|
min-height: 160px;
|
||||||
|
max-height: calc(100vh - 220px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.logRow {
|
||||||
|
padding: 6px 8px;
|
||||||
|
font-size: 11px;
|
||||||
|
grid-template-columns: 130px 1fr;
|
||||||
|
gap: $spacing-xs;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loadMoreBanner {
|
||||||
|
padding: 6px 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.errorPanel {
|
||||||
|
height: 280px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
import { useDeferredValue, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
import { useDeferredValue, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
import type { PointerEvent as ReactPointerEvent } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Card } from '@/components/ui/Card';
|
import { Card } from '@/components/ui/Card';
|
||||||
import { Button } from '@/components/ui/Button';
|
import { Button } from '@/components/ui/Button';
|
||||||
import { EmptyState } from '@/components/ui/EmptyState';
|
import { EmptyState } from '@/components/ui/EmptyState';
|
||||||
import { Input } from '@/components/ui/Input';
|
import { Input } from '@/components/ui/Input';
|
||||||
|
import { Modal } from '@/components/ui/Modal';
|
||||||
import { ToggleSwitch } from '@/components/ui/ToggleSwitch';
|
import { ToggleSwitch } from '@/components/ui/ToggleSwitch';
|
||||||
import {
|
import {
|
||||||
IconDownload,
|
IconDownload,
|
||||||
@@ -14,7 +16,7 @@ import {
|
|||||||
IconTrash2,
|
IconTrash2,
|
||||||
IconX,
|
IconX,
|
||||||
} from '@/components/ui/icons';
|
} from '@/components/ui/icons';
|
||||||
import { useNotificationStore, useAuthStore } from '@/stores';
|
import { useAuthStore, useConfigStore, useNotificationStore } from '@/stores';
|
||||||
import { logsApi } from '@/services/api/logs';
|
import { logsApi } from '@/services/api/logs';
|
||||||
import { MANAGEMENT_API_PREFIX } from '@/utils/constants';
|
import { MANAGEMENT_API_PREFIX } from '@/utils/constants';
|
||||||
import { formatUnixTimestamp } from '@/utils/format';
|
import { formatUnixTimestamp } from '@/utils/format';
|
||||||
@@ -38,6 +40,8 @@ const INITIAL_DISPLAY_LINES = 100;
|
|||||||
const LOAD_MORE_LINES = 200;
|
const LOAD_MORE_LINES = 200;
|
||||||
const MAX_BUFFER_LINES = 10000;
|
const MAX_BUFFER_LINES = 10000;
|
||||||
const LOAD_MORE_THRESHOLD_PX = 72;
|
const LOAD_MORE_THRESHOLD_PX = 72;
|
||||||
|
const LONG_PRESS_MS = 650;
|
||||||
|
const LONG_PRESS_MOVE_THRESHOLD = 10;
|
||||||
|
|
||||||
const HTTP_METHODS = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS', 'HEAD'] as const;
|
const HTTP_METHODS = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS', 'HEAD'] as const;
|
||||||
type HttpMethod = (typeof HTTP_METHODS)[number];
|
type HttpMethod = (typeof HTTP_METHODS)[number];
|
||||||
@@ -361,6 +365,7 @@ export function LogsPage() {
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { showNotification } = useNotificationStore();
|
const { showNotification } = useNotificationStore();
|
||||||
const connectionStatus = useAuthStore((state) => state.connectionStatus);
|
const connectionStatus = useAuthStore((state) => state.connectionStatus);
|
||||||
|
const requestLogEnabled = useConfigStore((state) => state.config?.requestLog ?? false);
|
||||||
|
|
||||||
const [activeTab, setActiveTab] = useState<TabType>('logs');
|
const [activeTab, setActiveTab] = useState<TabType>('logs');
|
||||||
const [logState, setLogState] = useState<LogState>({ buffer: [], visibleFrom: 0 });
|
const [logState, setLogState] = useState<LogState>({ buffer: [], visibleFrom: 0 });
|
||||||
@@ -369,13 +374,22 @@ export function LogsPage() {
|
|||||||
const [autoRefresh, setAutoRefresh] = useState(false);
|
const [autoRefresh, setAutoRefresh] = useState(false);
|
||||||
const [searchQuery, setSearchQuery] = useState('');
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
const deferredSearchQuery = useDeferredValue(searchQuery);
|
const deferredSearchQuery = useDeferredValue(searchQuery);
|
||||||
const [hideManagementLogs, setHideManagementLogs] = useState(false);
|
const [hideManagementLogs, setHideManagementLogs] = useState(true);
|
||||||
const [errorLogs, setErrorLogs] = useState<ErrorLogItem[]>([]);
|
const [errorLogs, setErrorLogs] = useState<ErrorLogItem[]>([]);
|
||||||
const [loadingErrors, setLoadingErrors] = useState(false);
|
const [loadingErrors, setLoadingErrors] = useState(false);
|
||||||
|
const [errorLogsError, setErrorLogsError] = useState('');
|
||||||
|
const [requestLogId, setRequestLogId] = useState<string | null>(null);
|
||||||
|
const [requestLogDownloading, setRequestLogDownloading] = useState(false);
|
||||||
|
|
||||||
const logViewerRef = useRef<HTMLDivElement | null>(null);
|
const logViewerRef = useRef<HTMLDivElement | null>(null);
|
||||||
const pendingScrollToBottomRef = useRef(false);
|
const pendingScrollToBottomRef = useRef(false);
|
||||||
const pendingPrependScrollRef = useRef<{ scrollHeight: number; scrollTop: number } | null>(null);
|
const pendingPrependScrollRef = useRef<{ scrollHeight: number; scrollTop: number } | null>(null);
|
||||||
|
const longPressRef = useRef<{
|
||||||
|
timer: number | null;
|
||||||
|
startX: number;
|
||||||
|
startY: number;
|
||||||
|
fired: boolean;
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
// 保存最新时间戳用于增量获取
|
// 保存最新时间戳用于增量获取
|
||||||
const latestTimestampRef = useRef<number>(0);
|
const latestTimestampRef = useRef<number>(0);
|
||||||
@@ -488,14 +502,18 @@ export function LogsPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
setLoadingErrors(true);
|
setLoadingErrors(true);
|
||||||
|
setErrorLogsError('');
|
||||||
try {
|
try {
|
||||||
const res = await logsApi.fetchErrorLogs();
|
const res = await logsApi.fetchErrorLogs();
|
||||||
// API 返回 { files: [...] }
|
// API 返回 { files: [...] }
|
||||||
setErrorLogs(Array.isArray(res.files) ? res.files : []);
|
setErrorLogs(Array.isArray(res.files) ? res.files : []);
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
console.error('Failed to load error logs:', err);
|
console.error('Failed to load error logs:', err);
|
||||||
// 静默失败,不影响主日志显示
|
|
||||||
setErrorLogs([]);
|
setErrorLogs([]);
|
||||||
|
const message = getErrorMessage(err);
|
||||||
|
setErrorLogsError(
|
||||||
|
message ? `${t('logs.error_logs_load_error')}: ${message}` : t('logs.error_logs_load_error')
|
||||||
|
);
|
||||||
} finally {
|
} finally {
|
||||||
setLoadingErrors(false);
|
setLoadingErrors(false);
|
||||||
}
|
}
|
||||||
@@ -525,11 +543,17 @@ export function LogsPage() {
|
|||||||
if (connectionStatus === 'connected') {
|
if (connectionStatus === 'connected') {
|
||||||
latestTimestampRef.current = 0;
|
latestTimestampRef.current = 0;
|
||||||
loadLogs(false);
|
loadLogs(false);
|
||||||
loadErrorLogs();
|
|
||||||
}
|
}
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [connectionStatus]);
|
}, [connectionStatus]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (activeTab !== 'errors') return;
|
||||||
|
if (connectionStatus !== 'connected') return;
|
||||||
|
void loadErrorLogs();
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [activeTab, connectionStatus, requestLogEnabled]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!autoRefresh || connectionStatus !== 'connected') {
|
if (!autoRefresh || connectionStatus !== 'connected') {
|
||||||
return;
|
return;
|
||||||
@@ -635,6 +659,85 @@ export function LogsPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const clearLongPressTimer = () => {
|
||||||
|
if (longPressRef.current?.timer) {
|
||||||
|
window.clearTimeout(longPressRef.current.timer);
|
||||||
|
longPressRef.current.timer = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const startLongPress = (event: ReactPointerEvent<HTMLDivElement>, id?: string) => {
|
||||||
|
if (!requestLogEnabled) return;
|
||||||
|
if (!id) return;
|
||||||
|
if (requestLogId) return;
|
||||||
|
clearLongPressTimer();
|
||||||
|
longPressRef.current = {
|
||||||
|
timer: window.setTimeout(() => {
|
||||||
|
setRequestLogId(id);
|
||||||
|
if (longPressRef.current) {
|
||||||
|
longPressRef.current.fired = true;
|
||||||
|
longPressRef.current.timer = null;
|
||||||
|
}
|
||||||
|
}, LONG_PRESS_MS),
|
||||||
|
startX: event.clientX,
|
||||||
|
startY: event.clientY,
|
||||||
|
fired: false,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const cancelLongPress = () => {
|
||||||
|
clearLongPressTimer();
|
||||||
|
longPressRef.current = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleLongPressMove = (event: ReactPointerEvent<HTMLDivElement>) => {
|
||||||
|
const current = longPressRef.current;
|
||||||
|
if (!current || current.timer === null || current.fired) return;
|
||||||
|
const deltaX = Math.abs(event.clientX - current.startX);
|
||||||
|
const deltaY = Math.abs(event.clientY - current.startY);
|
||||||
|
if (deltaX > LONG_PRESS_MOVE_THRESHOLD || deltaY > LONG_PRESS_MOVE_THRESHOLD) {
|
||||||
|
cancelLongPress();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeRequestLogModal = () => {
|
||||||
|
if (requestLogDownloading) return;
|
||||||
|
setRequestLogId(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const downloadRequestLog = async (id: string) => {
|
||||||
|
setRequestLogDownloading(true);
|
||||||
|
try {
|
||||||
|
const response = await logsApi.downloadRequestLogById(id);
|
||||||
|
const blob = new Blob([response.data], { type: 'text/plain' });
|
||||||
|
const url = window.URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = `request-${id}.log`;
|
||||||
|
a.click();
|
||||||
|
window.URL.revokeObjectURL(url);
|
||||||
|
showNotification(t('logs.request_log_download_success'), 'success');
|
||||||
|
setRequestLogId(null);
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const message = getErrorMessage(err);
|
||||||
|
showNotification(
|
||||||
|
`${t('notification.download_failed')}${message ? `: ${message}` : ''}`,
|
||||||
|
'error'
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setRequestLogDownloading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (longPressRef.current?.timer) {
|
||||||
|
window.clearTimeout(longPressRef.current.timer);
|
||||||
|
longPressRef.current.timer = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.container}>
|
<div className={styles.container}>
|
||||||
<h1 className={styles.pageTitle}>{t('logs.title')}</h1>
|
<h1 className={styles.pageTitle}>{t('logs.title')}</h1>
|
||||||
@@ -748,6 +851,10 @@ export function LogsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="hint">
|
||||||
|
{requestLogEnabled ? t('logs.action_hint') : t('logs.action_hint_disabled')}
|
||||||
|
</div>
|
||||||
|
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="hint">{t('logs.loading')}</div>
|
<div className="hint">{t('logs.loading')}</div>
|
||||||
) : logState.buffer.length > 0 && parsedVisibleLines.length > 0 ? (
|
) : logState.buffer.length > 0 && parsedVisibleLines.length > 0 ? (
|
||||||
@@ -783,6 +890,11 @@ export function LogsPage() {
|
|||||||
onDoubleClick={() => {
|
onDoubleClick={() => {
|
||||||
void copyLogLine(line.raw);
|
void copyLogLine(line.raw);
|
||||||
}}
|
}}
|
||||||
|
onPointerDown={(event) => startLongPress(event, line.requestId)}
|
||||||
|
onPointerUp={cancelLongPress}
|
||||||
|
onPointerLeave={cancelLongPress}
|
||||||
|
onPointerCancel={cancelLongPress}
|
||||||
|
onPointerMove={handleLongPressMove}
|
||||||
title={t('logs.double_click_copy_hint', {
|
title={t('logs.double_click_copy_hint', {
|
||||||
defaultValue: 'Double-click to copy',
|
defaultValue: 'Double-click to copy',
|
||||||
})}
|
})}
|
||||||
@@ -877,40 +989,89 @@ export function LogsPage() {
|
|||||||
{activeTab === 'errors' && (
|
{activeTab === 'errors' && (
|
||||||
<Card
|
<Card
|
||||||
extra={
|
extra={
|
||||||
<Button variant="secondary" size="sm" onClick={loadErrorLogs} loading={loadingErrors}>
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
onClick={loadErrorLogs}
|
||||||
|
loading={loadingErrors}
|
||||||
|
disabled={disableControls}
|
||||||
|
>
|
||||||
{t('common.refresh')}
|
{t('common.refresh')}
|
||||||
</Button>
|
</Button>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{errorLogs.length === 0 ? (
|
<div className="stack">
|
||||||
<div className="hint">{t('logs.error_logs_empty')}</div>
|
<div className="hint">{t('logs.error_logs_description')}</div>
|
||||||
) : (
|
|
||||||
<div className="item-list">
|
{requestLogEnabled && (
|
||||||
{errorLogs.map((item) => (
|
<div>
|
||||||
<div key={item.name} className="item-row">
|
<div className="status-badge warning">{t('logs.error_logs_request_log_enabled')}</div>
|
||||||
<div className="item-meta">
|
</div>
|
||||||
<div className="item-title">{item.name}</div>
|
)}
|
||||||
<div className="item-subtitle">
|
|
||||||
{item.size ? `${(item.size / 1024).toFixed(1)} KB` : ''}{' '}
|
{errorLogsError && <div className="error-box">{errorLogsError}</div>}
|
||||||
{item.modified ? formatUnixTimestamp(item.modified) : ''}
|
|
||||||
|
<div className={styles.errorPanel}>
|
||||||
|
{loadingErrors ? (
|
||||||
|
<div className="hint">{t('common.loading')}</div>
|
||||||
|
) : errorLogs.length === 0 ? (
|
||||||
|
<div className="hint">{t('logs.error_logs_empty')}</div>
|
||||||
|
) : (
|
||||||
|
<div className="item-list">
|
||||||
|
{errorLogs.map((item) => (
|
||||||
|
<div key={item.name} className="item-row">
|
||||||
|
<div className="item-meta">
|
||||||
|
<div className="item-title">{item.name}</div>
|
||||||
|
<div className="item-subtitle">
|
||||||
|
{item.size ? `${(item.size / 1024).toFixed(1)} KB` : ''}{' '}
|
||||||
|
{item.modified ? formatUnixTimestamp(item.modified) : ''}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="item-actions">
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => downloadErrorLog(item.name)}
|
||||||
|
disabled={disableControls}
|
||||||
|
>
|
||||||
|
{t('logs.error_logs_download')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
))}
|
||||||
<div className="item-actions">
|
|
||||||
<Button
|
|
||||||
variant="secondary"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => downloadErrorLog(item.name)}
|
|
||||||
>
|
|
||||||
{t('logs.error_logs_download')}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
open={Boolean(requestLogId)}
|
||||||
|
onClose={closeRequestLogModal}
|
||||||
|
title={t('logs.request_log_download_title')}
|
||||||
|
footer={
|
||||||
|
<>
|
||||||
|
<Button variant="secondary" onClick={closeRequestLogModal} disabled={requestLogDownloading}>
|
||||||
|
{t('common.cancel')}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
if (requestLogId) {
|
||||||
|
void downloadRequestLog(requestLogId);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
loading={requestLogDownloading}
|
||||||
|
disabled={!requestLogId}
|
||||||
|
>
|
||||||
|
{t('common.confirm')}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{requestLogId ? t('logs.request_log_download_confirm', { id: requestLogId }) : null}
|
||||||
|
</Modal>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -114,3 +114,25 @@
|
|||||||
gap: $spacing-sm;
|
gap: $spacing-sm;
|
||||||
margin-top: $spacing-sm;
|
margin-top: $spacing-sm;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.filePicker {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: $spacing-sm;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fileName {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 220px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: $radius-md;
|
||||||
|
background: var(--bg-primary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fileNamePlaceholder {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import { useEffect, useRef, useState } from 'react';
|
import { useEffect, useRef, useState, type ChangeEvent } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Card } from '@/components/ui/Card';
|
import { Card } from '@/components/ui/Card';
|
||||||
import { Button } from '@/components/ui/Button';
|
import { Button } from '@/components/ui/Button';
|
||||||
import { Input } from '@/components/ui/Input';
|
import { Input } from '@/components/ui/Input';
|
||||||
import { useNotificationStore, useThemeStore } from '@/stores';
|
import { useNotificationStore, useThemeStore } from '@/stores';
|
||||||
import { oauthApi, type OAuthProvider, type IFlowCookieAuthResponse } from '@/services/api/oauth';
|
import { oauthApi, type OAuthProvider, type IFlowCookieAuthResponse } from '@/services/api/oauth';
|
||||||
|
import { vertexApi, type VertexImportResponse } from '@/services/api/vertex';
|
||||||
import styles from './OAuthPage.module.scss';
|
import styles from './OAuthPage.module.scss';
|
||||||
import iconOpenaiLight from '@/assets/icons/openai-light.svg';
|
import iconOpenaiLight from '@/assets/icons/openai-light.svg';
|
||||||
import iconOpenaiDark from '@/assets/icons/openai-dark.svg';
|
import iconOpenaiDark from '@/assets/icons/openai-dark.svg';
|
||||||
@@ -13,6 +14,7 @@ import iconAntigravity from '@/assets/icons/antigravity.svg';
|
|||||||
import iconGemini from '@/assets/icons/gemini.svg';
|
import iconGemini from '@/assets/icons/gemini.svg';
|
||||||
import iconQwen from '@/assets/icons/qwen.svg';
|
import iconQwen from '@/assets/icons/qwen.svg';
|
||||||
import iconIflow from '@/assets/icons/iflow.svg';
|
import iconIflow from '@/assets/icons/iflow.svg';
|
||||||
|
import iconVertex from '@/assets/icons/vertex.svg';
|
||||||
|
|
||||||
interface ProviderState {
|
interface ProviderState {
|
||||||
url?: string;
|
url?: string;
|
||||||
@@ -36,6 +38,22 @@ interface IFlowCookieState {
|
|||||||
errorType?: 'error' | 'warning';
|
errorType?: 'error' | 'warning';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface VertexImportResult {
|
||||||
|
projectId?: string;
|
||||||
|
email?: string;
|
||||||
|
location?: string;
|
||||||
|
authFile?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface VertexImportState {
|
||||||
|
file?: File;
|
||||||
|
fileName: string;
|
||||||
|
location: string;
|
||||||
|
loading: boolean;
|
||||||
|
error?: string;
|
||||||
|
result?: VertexImportResult;
|
||||||
|
}
|
||||||
|
|
||||||
const PROVIDERS: { id: OAuthProvider; titleKey: string; hintKey: string; urlLabelKey: string; icon: string | { light: string; dark: string } }[] = [
|
const PROVIDERS: { id: OAuthProvider; titleKey: string; hintKey: string; urlLabelKey: string; icon: string | { light: string; dark: string } }[] = [
|
||||||
{ id: 'codex', titleKey: 'auth_login.codex_oauth_title', hintKey: 'auth_login.codex_oauth_hint', urlLabelKey: 'auth_login.codex_oauth_url_label', icon: { light: iconOpenaiLight, dark: iconOpenaiDark } },
|
{ id: 'codex', titleKey: 'auth_login.codex_oauth_title', hintKey: 'auth_login.codex_oauth_hint', urlLabelKey: 'auth_login.codex_oauth_url_label', icon: { light: iconOpenaiLight, dark: iconOpenaiDark } },
|
||||||
{ 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 },
|
||||||
@@ -54,10 +72,16 @@ const getIcon = (icon: string | { light: string; dark: string }, theme: 'light'
|
|||||||
export function OAuthPage() {
|
export function OAuthPage() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { showNotification } = useNotificationStore();
|
const { showNotification } = useNotificationStore();
|
||||||
const { theme } = useThemeStore();
|
const resolvedTheme = useThemeStore((state) => state.resolvedTheme);
|
||||||
const [states, setStates] = useState<Record<OAuthProvider, ProviderState>>({} as Record<OAuthProvider, ProviderState>);
|
const [states, setStates] = useState<Record<OAuthProvider, ProviderState>>({} as Record<OAuthProvider, ProviderState>);
|
||||||
const [iflowCookie, setIflowCookie] = useState<IFlowCookieState>({ cookie: '', loading: false });
|
const [iflowCookie, setIflowCookie] = useState<IFlowCookieState>({ cookie: '', loading: false });
|
||||||
|
const [vertexState, setVertexState] = useState<VertexImportState>({
|
||||||
|
fileName: '',
|
||||||
|
location: '',
|
||||||
|
loading: false
|
||||||
|
});
|
||||||
const timers = useRef<Record<string, number>>({});
|
const timers = useRef<Record<string, number>>({});
|
||||||
|
const vertexFileInputRef = useRef<HTMLInputElement | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return () => {
|
return () => {
|
||||||
@@ -216,6 +240,64 @@ export function OAuthPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleVertexFilePick = () => {
|
||||||
|
vertexFileInputRef.current?.click();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleVertexFileChange = (event: ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = event.target.files?.[0];
|
||||||
|
if (!file) return;
|
||||||
|
if (!file.name.endsWith('.json')) {
|
||||||
|
showNotification(t('vertex_import.file_required'), 'warning');
|
||||||
|
event.target.value = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setVertexState((prev) => ({
|
||||||
|
...prev,
|
||||||
|
file,
|
||||||
|
fileName: file.name,
|
||||||
|
error: undefined,
|
||||||
|
result: undefined
|
||||||
|
}));
|
||||||
|
event.target.value = '';
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleVertexImport = async () => {
|
||||||
|
if (!vertexState.file) {
|
||||||
|
const message = t('vertex_import.file_required');
|
||||||
|
setVertexState((prev) => ({ ...prev, error: message }));
|
||||||
|
showNotification(message, 'warning');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const location = vertexState.location.trim();
|
||||||
|
setVertexState((prev) => ({ ...prev, loading: true, error: undefined, result: undefined }));
|
||||||
|
try {
|
||||||
|
const res: VertexImportResponse = await vertexApi.importCredential(
|
||||||
|
vertexState.file,
|
||||||
|
location || undefined
|
||||||
|
);
|
||||||
|
const result: VertexImportResult = {
|
||||||
|
projectId: res.project_id,
|
||||||
|
email: res.email,
|
||||||
|
location: res.location,
|
||||||
|
authFile: res['auth-file'] ?? res.auth_file
|
||||||
|
};
|
||||||
|
setVertexState((prev) => ({ ...prev, loading: false, result }));
|
||||||
|
showNotification(t('vertex_import.success'), 'success');
|
||||||
|
} catch (err: any) {
|
||||||
|
const message = err?.message || '';
|
||||||
|
setVertexState((prev) => ({
|
||||||
|
...prev,
|
||||||
|
loading: false,
|
||||||
|
error: message || t('notification.upload_failed')
|
||||||
|
}));
|
||||||
|
const notification = message
|
||||||
|
? `${t('notification.upload_failed')}: ${message}`
|
||||||
|
: t('notification.upload_failed');
|
||||||
|
showNotification(notification, 'error');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.container}>
|
<div className={styles.container}>
|
||||||
<h1 className={styles.pageTitle}>{t('nav.oauth', { defaultValue: 'OAuth' })}</h1>
|
<h1 className={styles.pageTitle}>{t('nav.oauth', { defaultValue: 'OAuth' })}</h1>
|
||||||
@@ -229,7 +311,11 @@ export function OAuthPage() {
|
|||||||
<Card
|
<Card
|
||||||
title={
|
title={
|
||||||
<span className={styles.cardTitle}>
|
<span className={styles.cardTitle}>
|
||||||
<img src={getIcon(provider.icon, theme)} alt="" className={styles.cardTitleIcon} />
|
<img
|
||||||
|
src={getIcon(provider.icon, resolvedTheme)}
|
||||||
|
alt=""
|
||||||
|
className={styles.cardTitleIcon}
|
||||||
|
/>
|
||||||
{t(provider.titleKey)}
|
{t(provider.titleKey)}
|
||||||
</span>
|
</span>
|
||||||
}
|
}
|
||||||
@@ -324,6 +410,94 @@ export function OAuthPage() {
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
||||||
|
{/* Vertex JSON 登录 */}
|
||||||
|
<Card
|
||||||
|
title={
|
||||||
|
<span className={styles.cardTitle}>
|
||||||
|
<img src={iconVertex} alt="" className={styles.cardTitleIcon} />
|
||||||
|
{t('vertex_import.title')}
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
extra={
|
||||||
|
<Button onClick={handleVertexImport} loading={vertexState.loading}>
|
||||||
|
{t('vertex_import.import_button')}
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="hint">{t('vertex_import.description')}</div>
|
||||||
|
<Input
|
||||||
|
label={t('vertex_import.location_label')}
|
||||||
|
hint={t('vertex_import.location_hint')}
|
||||||
|
value={vertexState.location}
|
||||||
|
onChange={(e) =>
|
||||||
|
setVertexState((prev) => ({
|
||||||
|
...prev,
|
||||||
|
location: e.target.value
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
placeholder={t('vertex_import.location_placeholder')}
|
||||||
|
/>
|
||||||
|
<div className="form-group">
|
||||||
|
<label>{t('vertex_import.file_label')}</label>
|
||||||
|
<div className={styles.filePicker}>
|
||||||
|
<Button variant="secondary" size="sm" onClick={handleVertexFilePick}>
|
||||||
|
{t('vertex_import.choose_file')}
|
||||||
|
</Button>
|
||||||
|
<div
|
||||||
|
className={`${styles.fileName} ${
|
||||||
|
vertexState.fileName ? '' : styles.fileNamePlaceholder
|
||||||
|
}`.trim()}
|
||||||
|
>
|
||||||
|
{vertexState.fileName || t('vertex_import.file_placeholder')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="hint">{t('vertex_import.file_hint')}</div>
|
||||||
|
<input
|
||||||
|
ref={vertexFileInputRef}
|
||||||
|
type="file"
|
||||||
|
accept=".json,application/json"
|
||||||
|
style={{ display: 'none' }}
|
||||||
|
onChange={handleVertexFileChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{vertexState.error && (
|
||||||
|
<div className="status-badge error" style={{ marginTop: 8 }}>
|
||||||
|
{vertexState.error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{vertexState.result && (
|
||||||
|
<div className="connection-box" style={{ marginTop: 12 }}>
|
||||||
|
<div className="label">{t('vertex_import.result_title')}</div>
|
||||||
|
<div className="key-value-list">
|
||||||
|
{vertexState.result.projectId && (
|
||||||
|
<div className="key-value-item">
|
||||||
|
<span className="key">{t('vertex_import.result_project')}</span>
|
||||||
|
<span className="value">{vertexState.result.projectId}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{vertexState.result.email && (
|
||||||
|
<div className="key-value-item">
|
||||||
|
<span className="key">{t('vertex_import.result_email')}</span>
|
||||||
|
<span className="value">{vertexState.result.email}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{vertexState.result.location && (
|
||||||
|
<div className="key-value-item">
|
||||||
|
<span className="key">{t('vertex_import.result_location')}</span>
|
||||||
|
<span className="value">{vertexState.result.location}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{vertexState.result.authFile && (
|
||||||
|
<div className="key-value-item">
|
||||||
|
<span className="key">{t('vertex_import.result_file')}</span>
|
||||||
|
<span className="value">{vertexState.result.authFile}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
|
||||||
{/* iFlow Cookie 登录 */}
|
{/* iFlow Cookie 登录 */}
|
||||||
<Card
|
<Card
|
||||||
title={
|
title={
|
||||||
|
|||||||
@@ -18,6 +18,13 @@
|
|||||||
gap: 10px;
|
gap: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.headerActions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
.pageTitle {
|
.pageTitle {
|
||||||
font-size: 28px;
|
font-size: 28px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useState, useCallback, useMemo, type CSSProperties } from 'react';
|
import { useEffect, useState, useCallback, useMemo, useRef, type CSSProperties } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import {
|
import {
|
||||||
Chart as ChartJS,
|
Chart as ChartJS,
|
||||||
@@ -19,7 +19,7 @@ import { Input } from '@/components/ui/Input';
|
|||||||
import { LoadingSpinner } from '@/components/ui/LoadingSpinner';
|
import { LoadingSpinner } from '@/components/ui/LoadingSpinner';
|
||||||
import { IconDiamond, IconDollarSign, IconSatellite, IconTimer, IconTrendingUp } from '@/components/ui/icons';
|
import { IconDiamond, IconDollarSign, IconSatellite, IconTimer, IconTrendingUp } from '@/components/ui/icons';
|
||||||
import { useMediaQuery } from '@/hooks/useMediaQuery';
|
import { useMediaQuery } from '@/hooks/useMediaQuery';
|
||||||
import { useThemeStore } from '@/stores';
|
import { useNotificationStore, useThemeStore } from '@/stores';
|
||||||
import { usageApi } from '@/services/api/usage';
|
import { usageApi } from '@/services/api/usage';
|
||||||
import {
|
import {
|
||||||
formatTokensInMillions,
|
formatTokensInMillions,
|
||||||
@@ -63,14 +63,18 @@ interface UsagePayload {
|
|||||||
|
|
||||||
export function UsagePage() {
|
export function UsagePage() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const { showNotification } = useNotificationStore();
|
||||||
const isMobile = useMediaQuery('(max-width: 768px)');
|
const isMobile = useMediaQuery('(max-width: 768px)');
|
||||||
const theme = useThemeStore((state) => state.theme);
|
const resolvedTheme = useThemeStore((state) => state.resolvedTheme);
|
||||||
const isDark = theme === 'dark';
|
const isDark = resolvedTheme === 'dark';
|
||||||
|
|
||||||
const [usage, setUsage] = useState<UsagePayload | null>(null);
|
const [usage, setUsage] = useState<UsagePayload | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
const [modelPrices, setModelPrices] = useState<Record<string, ModelPrice>>({});
|
const [modelPrices, setModelPrices] = useState<Record<string, ModelPrice>>({});
|
||||||
|
const [exporting, setExporting] = useState(false);
|
||||||
|
const [importing, setImporting] = useState(false);
|
||||||
|
const importInputRef = useRef<HTMLInputElement | null>(null);
|
||||||
|
|
||||||
// Model price form state
|
// Model price form state
|
||||||
const [selectedModel, setSelectedModel] = useState('');
|
const [selectedModel, setSelectedModel] = useState('');
|
||||||
@@ -107,6 +111,77 @@ export function UsagePage() {
|
|||||||
setModelPrices(loadModelPrices());
|
setModelPrices(loadModelPrices());
|
||||||
}, [loadUsage]);
|
}, [loadUsage]);
|
||||||
|
|
||||||
|
const handleExport = async () => {
|
||||||
|
setExporting(true);
|
||||||
|
try {
|
||||||
|
const data = await usageApi.exportUsage();
|
||||||
|
const exportedAt =
|
||||||
|
typeof data?.exported_at === 'string' ? new Date(data.exported_at) : new Date();
|
||||||
|
const safeTimestamp = Number.isNaN(exportedAt.getTime())
|
||||||
|
? new Date().toISOString()
|
||||||
|
: exportedAt.toISOString();
|
||||||
|
const filename = `usage-export-${safeTimestamp.replace(/[:.]/g, '-')}.json`;
|
||||||
|
const blob = new Blob([JSON.stringify(data ?? {}, null, 2)], { type: 'application/json' });
|
||||||
|
const url = window.URL.createObjectURL(blob);
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.href = url;
|
||||||
|
link.download = filename;
|
||||||
|
link.click();
|
||||||
|
window.URL.revokeObjectURL(url);
|
||||||
|
showNotification(t('usage_stats.export_success'), 'success');
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const message = err instanceof Error ? err.message : '';
|
||||||
|
showNotification(
|
||||||
|
`${t('notification.download_failed')}${message ? `: ${message}` : ''}`,
|
||||||
|
'error'
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setExporting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleImportClick = () => {
|
||||||
|
importInputRef.current?.click();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleImportChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = event.target.files?.[0];
|
||||||
|
event.target.value = '';
|
||||||
|
if (!file) return;
|
||||||
|
|
||||||
|
setImporting(true);
|
||||||
|
try {
|
||||||
|
const text = await file.text();
|
||||||
|
let payload: unknown;
|
||||||
|
try {
|
||||||
|
payload = JSON.parse(text);
|
||||||
|
} catch {
|
||||||
|
showNotification(t('usage_stats.import_invalid'), 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await usageApi.importUsage(payload);
|
||||||
|
showNotification(
|
||||||
|
t('usage_stats.import_success', {
|
||||||
|
added: result?.added ?? 0,
|
||||||
|
skipped: result?.skipped ?? 0,
|
||||||
|
total: result?.total_requests ?? 0,
|
||||||
|
failed: result?.failed_requests ?? 0
|
||||||
|
}),
|
||||||
|
'success'
|
||||||
|
);
|
||||||
|
await loadUsage();
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const message = err instanceof Error ? err.message : '';
|
||||||
|
showNotification(
|
||||||
|
`${t('notification.upload_failed')}${message ? `: ${message}` : ''}`,
|
||||||
|
'error'
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setImporting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// Calculate derived data
|
// Calculate derived data
|
||||||
const tokenBreakdown = usage ? calculateTokenBreakdown(usage) : { cachedTokens: 0, reasoningTokens: 0 };
|
const tokenBreakdown = usage ? calculateTokenBreakdown(usage) : { cachedTokens: 0, reasoningTokens: 0 };
|
||||||
const rateStats = usage
|
const rateStats = usage
|
||||||
@@ -527,14 +602,41 @@ export function UsagePage() {
|
|||||||
)}
|
)}
|
||||||
<div className={styles.header}>
|
<div className={styles.header}>
|
||||||
<h1 className={styles.pageTitle}>{t('usage_stats.title')}</h1>
|
<h1 className={styles.pageTitle}>{t('usage_stats.title')}</h1>
|
||||||
<Button
|
<div className={styles.headerActions}>
|
||||||
variant="secondary"
|
<Button
|
||||||
size="sm"
|
variant="secondary"
|
||||||
onClick={loadUsage}
|
size="sm"
|
||||||
disabled={loading}
|
onClick={handleExport}
|
||||||
>
|
loading={exporting}
|
||||||
{loading ? t('common.loading') : t('usage_stats.refresh')}
|
disabled={loading || importing}
|
||||||
</Button>
|
>
|
||||||
|
{t('usage_stats.export')}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleImportClick}
|
||||||
|
loading={importing}
|
||||||
|
disabled={loading || exporting}
|
||||||
|
>
|
||||||
|
{t('usage_stats.import')}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
onClick={loadUsage}
|
||||||
|
disabled={loading || exporting || importing}
|
||||||
|
>
|
||||||
|
{loading ? t('common.loading') : t('usage_stats.refresh')}
|
||||||
|
</Button>
|
||||||
|
<input
|
||||||
|
ref={importInputRef}
|
||||||
|
type="file"
|
||||||
|
accept=".json,application/json"
|
||||||
|
style={{ display: 'none' }}
|
||||||
|
onChange={handleImportChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error && <div className={styles.errorBox}>{error}</div>}
|
{error && <div className={styles.errorBox}>{error}</div>}
|
||||||
|
|||||||
@@ -11,3 +11,4 @@ export * from './logs';
|
|||||||
export * from './version';
|
export * from './version';
|
||||||
export * from './models';
|
export * from './models';
|
||||||
export * from './transformers';
|
export * from './transformers';
|
||||||
|
export * from './vertex';
|
||||||
|
|||||||
@@ -39,4 +39,10 @@ export const logsApi = {
|
|||||||
responseType: 'blob',
|
responseType: 'blob',
|
||||||
timeout: LOGS_TIMEOUT_MS
|
timeout: LOGS_TIMEOUT_MS
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
downloadRequestLogById: (id: string) =>
|
||||||
|
apiClient.getRaw(`/request-log-by-id/${encodeURIComponent(id)}`, {
|
||||||
|
responseType: 'blob',
|
||||||
|
timeout: LOGS_TIMEOUT_MS
|
||||||
|
}),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -7,12 +7,38 @@ import { computeKeyStats, KeyStats } from '@/utils/usage';
|
|||||||
|
|
||||||
const USAGE_TIMEOUT_MS = 60 * 1000;
|
const USAGE_TIMEOUT_MS = 60 * 1000;
|
||||||
|
|
||||||
|
export interface UsageExportPayload {
|
||||||
|
version?: number;
|
||||||
|
exported_at?: string;
|
||||||
|
usage?: Record<string, unknown>;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UsageImportResponse {
|
||||||
|
added?: number;
|
||||||
|
skipped?: number;
|
||||||
|
total_requests?: number;
|
||||||
|
failed_requests?: number;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
export const usageApi = {
|
export const usageApi = {
|
||||||
/**
|
/**
|
||||||
* 获取使用统计原始数据
|
* 获取使用统计原始数据
|
||||||
*/
|
*/
|
||||||
getUsage: () => apiClient.get('/usage', { timeout: USAGE_TIMEOUT_MS }),
|
getUsage: () => apiClient.get('/usage', { timeout: USAGE_TIMEOUT_MS }),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 导出使用统计快照
|
||||||
|
*/
|
||||||
|
exportUsage: () => apiClient.get<UsageExportPayload>('/usage/export', { timeout: USAGE_TIMEOUT_MS }),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 导入使用统计快照
|
||||||
|
*/
|
||||||
|
importUsage: (payload: unknown) =>
|
||||||
|
apiClient.post<UsageImportResponse>('/usage/import', payload, { timeout: USAGE_TIMEOUT_MS }),
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 计算密钥成功/失败统计,必要时会先获取 usage 数据
|
* 计算密钥成功/失败统计,必要时会先获取 usage 数据
|
||||||
*/
|
*/
|
||||||
|
|||||||
25
src/services/api/vertex.ts
Normal file
25
src/services/api/vertex.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
/**
|
||||||
|
* Vertex credential import API
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { apiClient } from './client';
|
||||||
|
|
||||||
|
export interface VertexImportResponse {
|
||||||
|
status: 'ok';
|
||||||
|
project_id?: string;
|
||||||
|
email?: string;
|
||||||
|
location?: string;
|
||||||
|
'auth-file'?: string;
|
||||||
|
auth_file?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const vertexApi = {
|
||||||
|
importCredential: (file: File, location?: string) => {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('file', file);
|
||||||
|
if (location) {
|
||||||
|
formData.append('location', location);
|
||||||
|
}
|
||||||
|
return apiClient.postForm<VertexImportResponse>('/vertex/import', formData);
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -8,63 +8,79 @@ import { persist } from 'zustand/middleware';
|
|||||||
import type { Theme } from '@/types';
|
import type { Theme } from '@/types';
|
||||||
import { STORAGE_KEY_THEME } from '@/utils/constants';
|
import { STORAGE_KEY_THEME } from '@/utils/constants';
|
||||||
|
|
||||||
|
type ResolvedTheme = 'light' | 'dark';
|
||||||
|
|
||||||
interface ThemeState {
|
interface ThemeState {
|
||||||
theme: Theme;
|
theme: Theme;
|
||||||
|
resolvedTheme: ResolvedTheme;
|
||||||
setTheme: (theme: Theme) => void;
|
setTheme: (theme: Theme) => void;
|
||||||
toggleTheme: () => void;
|
cycleTheme: () => void;
|
||||||
initializeTheme: () => void;
|
initializeTheme: () => () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const getSystemTheme = (): ResolvedTheme => {
|
||||||
|
if (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) {
|
||||||
|
return 'dark';
|
||||||
|
}
|
||||||
|
return 'light';
|
||||||
|
};
|
||||||
|
|
||||||
|
const applyTheme = (resolved: ResolvedTheme) => {
|
||||||
|
if (resolved === 'dark') {
|
||||||
|
document.documentElement.setAttribute('data-theme', 'dark');
|
||||||
|
} else {
|
||||||
|
document.documentElement.removeAttribute('data-theme');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
export const useThemeStore = create<ThemeState>()(
|
export const useThemeStore = create<ThemeState>()(
|
||||||
persist(
|
persist(
|
||||||
(set, get) => ({
|
(set, get) => ({
|
||||||
theme: 'light',
|
theme: 'auto',
|
||||||
|
resolvedTheme: 'light',
|
||||||
|
|
||||||
setTheme: (theme) => {
|
setTheme: (theme) => {
|
||||||
// 应用主题到 DOM
|
const resolved: ResolvedTheme = theme === 'auto' ? getSystemTheme() : theme;
|
||||||
if (theme === 'dark') {
|
applyTheme(resolved);
|
||||||
document.documentElement.setAttribute('data-theme', 'dark');
|
set({ theme, resolvedTheme: resolved });
|
||||||
} else {
|
|
||||||
document.documentElement.removeAttribute('data-theme');
|
|
||||||
}
|
|
||||||
|
|
||||||
set({ theme });
|
|
||||||
},
|
},
|
||||||
|
|
||||||
toggleTheme: () => {
|
cycleTheme: () => {
|
||||||
const { theme, setTheme } = get();
|
const { theme, setTheme } = get();
|
||||||
const newTheme: Theme = theme === 'light' ? 'dark' : 'light';
|
const order: Theme[] = ['light', 'dark', 'auto'];
|
||||||
setTheme(newTheme);
|
const currentIndex = order.indexOf(theme);
|
||||||
|
const nextTheme = order[(currentIndex + 1) % order.length];
|
||||||
|
setTheme(nextTheme);
|
||||||
},
|
},
|
||||||
|
|
||||||
initializeTheme: () => {
|
initializeTheme: () => {
|
||||||
const { theme, setTheme } = get();
|
const { theme, setTheme } = get();
|
||||||
|
|
||||||
// 检查系统偏好
|
|
||||||
if (
|
|
||||||
!localStorage.getItem(STORAGE_KEY_THEME) &&
|
|
||||||
window.matchMedia &&
|
|
||||||
window.matchMedia('(prefers-color-scheme: dark)').matches
|
|
||||||
) {
|
|
||||||
setTheme('dark');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 应用已保存的主题
|
// 应用已保存的主题
|
||||||
setTheme(theme);
|
setTheme(theme);
|
||||||
|
|
||||||
// 监听系统主题变化(仅在用户未手动设置时)
|
// 监听系统主题变化(仅在 auto 模式下生效)
|
||||||
if (window.matchMedia) {
|
if (!window.matchMedia) {
|
||||||
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', (e) => {
|
return () => {};
|
||||||
if (!localStorage.getItem(STORAGE_KEY_THEME)) {
|
|
||||||
setTheme(e.matches ? 'dark' : 'light');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
|
||||||
|
const listener = () => {
|
||||||
|
const { theme: currentTheme } = get();
|
||||||
|
if (currentTheme === 'auto') {
|
||||||
|
const resolved = getSystemTheme();
|
||||||
|
applyTheme(resolved);
|
||||||
|
set({ resolvedTheme: resolved });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
mediaQuery.addEventListener('change', listener);
|
||||||
|
|
||||||
|
return () => mediaQuery.removeEventListener('change', listener);
|
||||||
|
},
|
||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
name: STORAGE_KEY_THEME
|
name: STORAGE_KEY_THEME,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -12,6 +12,13 @@
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
background: var(--bg-secondary);
|
background: var(--bg-secondary);
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
|
|
||||||
|
@media (max-width: $breakpoint-mobile) {
|
||||||
|
height: auto;
|
||||||
|
min-height: 100vh;
|
||||||
|
overflow: visible;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.main-header {
|
.main-header {
|
||||||
@@ -230,6 +237,16 @@
|
|||||||
@supports (height: 100dvh) {
|
@supports (height: 100dvh) {
|
||||||
height: calc(100dvh - var(--header-height));
|
height: calc(100dvh - var(--header-height));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (max-width: $breakpoint-mobile) {
|
||||||
|
height: auto;
|
||||||
|
min-height: calc(100vh - var(--header-height));
|
||||||
|
overflow: visible;
|
||||||
|
|
||||||
|
@supports (min-height: 100dvh) {
|
||||||
|
min-height: calc(100dvh - var(--header-height));
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar {
|
.sidebar {
|
||||||
@@ -328,18 +345,38 @@
|
|||||||
min-width: 0;
|
min-width: 0;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
|
|
||||||
|
&.content-logs {
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
@media (max-width: $breakpoint-mobile) {
|
||||||
|
overflow: visible;
|
||||||
|
overflow-y: auto;
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.main-content {
|
.main-content {
|
||||||
flex: 1;
|
flex: 1 0 auto;
|
||||||
padding: $spacing-lg;
|
padding: $spacing-lg;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: $spacing-lg;
|
gap: $spacing-lg;
|
||||||
min-height: 0;
|
|
||||||
overflow-y: auto;
|
|
||||||
overflow-x: hidden;
|
overflow-x: hidden;
|
||||||
|
|
||||||
|
&.main-content-logs {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
@media (max-width: $breakpoint-mobile) {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
min-height: auto;
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: $breakpoint-mobile) {
|
@media (max-width: $breakpoint-mobile) {
|
||||||
padding: $spacing-md;
|
padding: $spacing-md;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
* 通用类型定义
|
* 通用类型定义
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export type Theme = 'light' | 'dark';
|
export type Theme = 'light' | 'dark' | 'auto';
|
||||||
|
|
||||||
export type Language = 'zh-CN' | 'en';
|
export type Language = 'zh-CN' | 'en';
|
||||||
|
|
||||||
|
|||||||
@@ -754,6 +754,103 @@ export function buildChartData(
|
|||||||
/**
|
/**
|
||||||
* 依据 usage 数据计算密钥使用统计
|
* 依据 usage 数据计算密钥使用统计
|
||||||
*/
|
*/
|
||||||
|
/**
|
||||||
|
* 状态栏单个格子的状态
|
||||||
|
*/
|
||||||
|
export type StatusBlockState = 'success' | 'failure' | 'mixed' | 'idle';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 状态栏数据
|
||||||
|
*/
|
||||||
|
export interface StatusBarData {
|
||||||
|
blocks: StatusBlockState[];
|
||||||
|
successRate: number;
|
||||||
|
totalSuccess: number;
|
||||||
|
totalFailure: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 计算状态栏数据(最近1小时,分为20个5分钟的时间块)
|
||||||
|
* 注意:20个块 × 5分钟 = 100分钟,但我们只使用最近60分钟的数据
|
||||||
|
* 所以实际只有最后12个块可能有数据,前8个块将始终为 idle
|
||||||
|
*/
|
||||||
|
export function calculateStatusBarData(
|
||||||
|
usageDetails: UsageDetail[],
|
||||||
|
sourceFilter?: string,
|
||||||
|
authIndexFilter?: number
|
||||||
|
): StatusBarData {
|
||||||
|
const BLOCK_COUNT = 20;
|
||||||
|
const BLOCK_DURATION_MS = 5 * 60 * 1000; // 5 minutes
|
||||||
|
const HOUR_MS = 60 * 60 * 1000;
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
const hourAgo = now - HOUR_MS;
|
||||||
|
|
||||||
|
// Initialize blocks
|
||||||
|
const blockStats: Array<{ success: number; failure: number }> = Array.from(
|
||||||
|
{ length: BLOCK_COUNT },
|
||||||
|
() => ({ success: 0, failure: 0 })
|
||||||
|
);
|
||||||
|
|
||||||
|
let totalSuccess = 0;
|
||||||
|
let totalFailure = 0;
|
||||||
|
|
||||||
|
// Filter and bucket the usage details
|
||||||
|
usageDetails.forEach((detail) => {
|
||||||
|
const timestamp = Date.parse(detail.timestamp);
|
||||||
|
if (Number.isNaN(timestamp) || timestamp < hourAgo || timestamp > now) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply filters if provided
|
||||||
|
if (sourceFilter !== undefined && detail.source !== sourceFilter) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (authIndexFilter !== undefined && detail.auth_index !== authIndexFilter) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate which block this falls into (0 = oldest, 19 = newest)
|
||||||
|
const ageMs = now - timestamp;
|
||||||
|
const blockIndex = BLOCK_COUNT - 1 - Math.floor(ageMs / BLOCK_DURATION_MS);
|
||||||
|
|
||||||
|
if (blockIndex >= 0 && blockIndex < BLOCK_COUNT) {
|
||||||
|
if (detail.failed) {
|
||||||
|
blockStats[blockIndex].failure += 1;
|
||||||
|
totalFailure += 1;
|
||||||
|
} else {
|
||||||
|
blockStats[blockIndex].success += 1;
|
||||||
|
totalSuccess += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Convert stats to block states
|
||||||
|
const blocks: StatusBlockState[] = blockStats.map((stat) => {
|
||||||
|
if (stat.success === 0 && stat.failure === 0) {
|
||||||
|
return 'idle';
|
||||||
|
}
|
||||||
|
if (stat.failure === 0) {
|
||||||
|
return 'success';
|
||||||
|
}
|
||||||
|
if (stat.success === 0) {
|
||||||
|
return 'failure';
|
||||||
|
}
|
||||||
|
return 'mixed';
|
||||||
|
});
|
||||||
|
|
||||||
|
// Calculate success rate
|
||||||
|
const total = totalSuccess + totalFailure;
|
||||||
|
const successRate = total > 0 ? (totalSuccess / total) * 100 : 100;
|
||||||
|
|
||||||
|
return {
|
||||||
|
blocks,
|
||||||
|
successRate,
|
||||||
|
totalSuccess,
|
||||||
|
totalFailure
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export function computeKeyStats(usageData: any, masker: (val: string) => string = maskApiKey): KeyStats {
|
export function computeKeyStats(usageData: any, masker: (val: string) => string = maskApiKey): KeyStats {
|
||||||
if (!usageData) {
|
if (!usageData) {
|
||||||
return { bySource: {}, byAuthIndex: {} };
|
return { bySource: {}, byAuthIndex: {} };
|
||||||
|
|||||||
Reference in New Issue
Block a user