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
24 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
be3f58f0a8 | ||
|
|
c299e403cc | ||
|
|
769c05e459 | ||
|
|
5ef3406068 | ||
|
|
95cbfb8c59 | ||
|
|
c17217875c | ||
|
|
981f7ac9b2 | ||
|
|
762db81252 | ||
|
|
79f6d87d7b | ||
|
|
c5d4356d6c | ||
|
|
c989dbf1b6 | ||
|
|
3cffa19319 | ||
|
|
2367f122a8 | ||
|
|
69a8e1657e | ||
|
|
987ce0ec4b | ||
|
|
03bf58671e | ||
|
|
cb6b810d6d | ||
|
|
408e6e5872 | ||
|
|
b3808add0f | ||
|
|
0b2e6efe28 | ||
|
|
8ca6d31a26 | ||
|
|
66c6073bbc | ||
|
|
2dd3f233d3 | ||
|
|
7a65e03ad3 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -10,6 +10,7 @@ api.md
|
||||
usage.json
|
||||
CLAUDE.md
|
||||
AGENTS.md
|
||||
antigravity_usage.json
|
||||
|
||||
node_modules
|
||||
dist
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20xmlns%3Axlink%3D%22http%3A%2F%2Fwww.w3.org%2F1999%2Fxlink%22%20aria-hidden%3D%22true%22%20role%3D%22img%22%20class%3D%22iconify%20iconify--logos%22%20width%3D%2231.88%22%20height%3D%2232%22%20preserveAspectRatio%3D%22xMidYMid%20meet%22%20viewBox%3D%220%200%20256%20257%22%3E%3Cdefs%3E%3ClinearGradient%20id%3D%22IconifyId1813088fe1fbc01fb466%22%20x1%3D%22-.828%25%22%20x2%3D%2257.636%25%22%20y1%3D%227.652%25%22%20y2%3D%2278.411%25%22%3E%3Cstop%20offset%3D%220%25%22%20stop-color%3D%22%2341D1FF%22%3E%3C%2Fstop%3E%3Cstop%20offset%3D%22100%25%22%20stop-color%3D%22%23BD34FE%22%3E%3C%2Fstop%3E%3C%2FlinearGradient%3E%3ClinearGradient%20id%3D%22IconifyId1813088fe1fbc01fb467%22%20x1%3D%2243.376%25%22%20x2%3D%2250.316%25%22%20y1%3D%222.242%25%22%20y2%3D%2289.03%25%22%3E%3Cstop%20offset%3D%220%25%22%20stop-color%3D%22%23FFEA83%22%3E%3C%2Fstop%3E%3Cstop%20offset%3D%228.333%25%22%20stop-color%3D%22%23FFDD35%22%3E%3C%2Fstop%3E%3Cstop%20offset%3D%22100%25%22%20stop-color%3D%22%23FFA800%22%3E%3C%2Fstop%3E%3C%2FlinearGradient%3E%3C%2Fdefs%3E%3Cpath%20fill%3D%22url(%23IconifyId1813088fe1fbc01fb466)%22%20d%3D%22M255.153%2037.938L134.897%20252.976c-2.483%204.44-8.862%204.466-11.382.048L.875%2037.958c-2.746-4.814%201.371-10.646%206.827-9.67l120.385%2021.517a6.537%206.537%200%200%200%202.322-.004l117.867-21.483c5.438-.991%209.574%204.796%206.877%209.62Z%22%3E%3C%2Fpath%3E%3Cpath%20fill%3D%22url(%23IconifyId1813088fe1fbc01fb467)%22%20d%3D%22M185.432.063L96.44%2017.501a3.268%203.268%200%200%200-2.634%203.014l-5.474%2092.456a3.268%203.268%200%200%200%203.997%203.378l24.777-5.718c2.318-.535%204.413%201.507%203.936%203.838l-7.361%2036.047c-.495%202.426%201.782%204.5%204.151%203.78l15.304-4.649c2.372-.72%204.652%201.36%204.15%203.788l-11.698%2056.621c-.732%203.542%203.979%205.473%205.943%202.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505%204.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z%22%3E%3C%2Fpath%3E%3C%2Fsvg%3E" />
|
||||
|
||||
@@ -43,6 +43,10 @@ function App() {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []); // 仅用于首屏同步 i18n 语言
|
||||
|
||||
useEffect(() => {
|
||||
document.documentElement.lang = language;
|
||||
}, [language]);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
setSplashReadyToFade(true);
|
||||
|
||||
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 |
@@ -7,7 +7,7 @@ import {
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { NavLink, Outlet } from 'react-router-dom';
|
||||
import { NavLink, Outlet, useLocation } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Modal } from '@/components/ui/Modal';
|
||||
@@ -173,6 +173,7 @@ const compareVersions = (latest?: string | null, current?: string | null) => {
|
||||
export function MainLayout() {
|
||||
const { t, i18n } = useTranslation();
|
||||
const { showNotification } = useNotificationStore();
|
||||
const location = useLocation();
|
||||
|
||||
const apiBase = useAuthStore((state) => state.apiBase);
|
||||
const serverVersion = useAuthStore((state) => state.serverVersion);
|
||||
@@ -207,6 +208,7 @@ export function MainLayout() {
|
||||
const requestLogEnabled = config?.requestLog ?? false;
|
||||
const requestLogDirty = requestLogDraft !== requestLogEnabled;
|
||||
const canEditRequestLog = connectionStatus === 'connected' && Boolean(config);
|
||||
const isLogsPage = location.pathname.startsWith('/logs');
|
||||
|
||||
// 将顶栏高度写入 CSS 变量,确保侧栏/内容区计算一致,防止滚动时抖动
|
||||
useLayoutEffect(() => {
|
||||
@@ -503,8 +505,8 @@ export function MainLayout() {
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<div className="content">
|
||||
<main className="main-content">
|
||||
<div className={`content${isLogsPage ? ' content-logs' : ''}`}>
|
||||
<main className={`main-content${isLogsPage ? ' main-content-logs' : ''}`}>
|
||||
<Outlet />
|
||||
</main>
|
||||
|
||||
@@ -512,7 +514,7 @@ export function MainLayout() {
|
||||
<span>
|
||||
{t('footer.api_version')}: {serverVersion || t('system_info.version_unknown')}
|
||||
</span>
|
||||
<span onClick={handleVersionTap}>
|
||||
<span className="footer-version" onClick={handleVersionTap}>
|
||||
{t('footer.version')}: {__APP_VERSION__ || t('system_info.version_unknown')}
|
||||
</span>
|
||||
<span>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { PropsWithChildren, ReactNode } from 'react';
|
||||
import { useState, useEffect, useCallback, useRef, type PropsWithChildren, type ReactNode } from 'react';
|
||||
import { IconX } from './icons';
|
||||
|
||||
interface ModalProps {
|
||||
@@ -9,23 +9,70 @@ interface ModalProps {
|
||||
width?: number | string;
|
||||
}
|
||||
|
||||
export function Modal({ open, title, onClose, footer, width = 520, children }: PropsWithChildren<ModalProps>) {
|
||||
if (!open) return null;
|
||||
const CLOSE_ANIMATION_DURATION = 350;
|
||||
|
||||
const handleMaskClick = (event: React.MouseEvent<HTMLDivElement>) => {
|
||||
if (event.target === event.currentTarget) {
|
||||
export function Modal({ open, title, onClose, footer, width = 520, children }: PropsWithChildren<ModalProps>) {
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
const [isClosing, setIsClosing] = useState(false);
|
||||
const closeTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const startClose = useCallback(
|
||||
(notifyParent: boolean) => {
|
||||
if (closeTimerRef.current !== null) return;
|
||||
setIsClosing(true);
|
||||
closeTimerRef.current = window.setTimeout(() => {
|
||||
setIsVisible(false);
|
||||
setIsClosing(false);
|
||||
closeTimerRef.current = null;
|
||||
if (notifyParent) {
|
||||
onClose();
|
||||
}
|
||||
}, CLOSE_ANIMATION_DURATION);
|
||||
},
|
||||
[onClose]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
if (closeTimerRef.current !== null) {
|
||||
window.clearTimeout(closeTimerRef.current);
|
||||
closeTimerRef.current = null;
|
||||
}
|
||||
setIsVisible(true);
|
||||
setIsClosing(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (isVisible) {
|
||||
startClose(false);
|
||||
}
|
||||
}, [open, isVisible, startClose]);
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
startClose(true);
|
||||
}, [startClose]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (closeTimerRef.current !== null) {
|
||||
window.clearTimeout(closeTimerRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
if (!open && !isVisible) return null;
|
||||
|
||||
const overlayClass = `modal-overlay ${isClosing ? 'modal-overlay-closing' : 'modal-overlay-entering'}`;
|
||||
const modalClass = `modal ${isClosing ? 'modal-closing' : 'modal-entering'}`;
|
||||
|
||||
return (
|
||||
<div className="modal-overlay" onClick={handleMaskClick}>
|
||||
<div className="modal" style={{ width }} role="dialog" aria-modal="true">
|
||||
<div className={overlayClass}>
|
||||
<div className={modalClass} style={{ width }} role="dialog" aria-modal="true">
|
||||
<button className="modal-close-floating" onClick={handleClose} aria-label="Close">
|
||||
<IconX size={20} />
|
||||
</button>
|
||||
<div className="modal-header">
|
||||
<div className="modal-title">{title}</div>
|
||||
<button className="modal-close" onClick={onClose} aria-label="Close">
|
||||
<IconX size={18} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="modal-body">{children}</div>
|
||||
{footer && <div className="modal-footer">{footer}</div>}
|
||||
|
||||
@@ -6,14 +6,14 @@ import i18n from 'i18next';
|
||||
import { initReactI18next } from 'react-i18next';
|
||||
import zhCN from './locales/zh-CN.json';
|
||||
import en from './locales/en.json';
|
||||
import { STORAGE_KEY_LANGUAGE } from '@/utils/constants';
|
||||
import { getInitialLanguage } from '@/utils/language';
|
||||
|
||||
i18n.use(initReactI18next).init({
|
||||
resources: {
|
||||
'zh-CN': { translation: zhCN },
|
||||
en: { translation: en }
|
||||
},
|
||||
lng: localStorage.getItem(STORAGE_KEY_LANGUAGE) || 'zh-CN',
|
||||
lng: getInitialLanguage(),
|
||||
fallbackLng: 'zh-CN',
|
||||
interpolation: {
|
||||
escapeValue: false // React 已经转义
|
||||
|
||||
@@ -357,8 +357,18 @@
|
||||
"models_excluded_badge": "Excluded",
|
||||
"models_excluded_hint": "This model is excluded by OAuth"
|
||||
},
|
||||
"antigravity_quota": {
|
||||
"title": "Antigravity Quota",
|
||||
"empty_title": "No Antigravity Auth Files",
|
||||
"empty_desc": "Upload an Antigravity credential to view remaining quota.",
|
||||
"idle": "Not loaded. Click Refresh.",
|
||||
"loading": "Loading quota...",
|
||||
"load_failed": "Failed to load quota: {{message}}",
|
||||
"missing_auth_index": "Auth file missing auth_index",
|
||||
"empty_models": "No quota data available"
|
||||
},
|
||||
"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.",
|
||||
"location_label": "Region (optional)",
|
||||
"location_placeholder": "us-central1",
|
||||
@@ -534,6 +544,11 @@
|
||||
"by_hour": "By Hour",
|
||||
"by_day": "By Day",
|
||||
"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_2": "Line 2",
|
||||
"chart_line_label_3": "Line 3",
|
||||
@@ -596,6 +611,9 @@
|
||||
"error_logs_modified": "Last modified",
|
||||
"error_logs_download": "Download",
|
||||
"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",
|
||||
"empty_title": "No Logs Available",
|
||||
"empty_desc": "When \"Enable logging to file\" is enabled, logs will be displayed here",
|
||||
"log_content": "Log Content",
|
||||
|
||||
@@ -357,8 +357,18 @@
|
||||
"models_excluded_badge": "已排除",
|
||||
"models_excluded_hint": "此模型已被 OAuth 排除"
|
||||
},
|
||||
"antigravity_quota": {
|
||||
"title": "Antigravity 额度",
|
||||
"empty_title": "暂无 Antigravity 认证",
|
||||
"empty_desc": "上传 Antigravity 认证文件后即可查看额度。",
|
||||
"idle": "尚未加载额度,请点击刷新。",
|
||||
"loading": "正在加载额度...",
|
||||
"load_failed": "额度获取失败:{{message}}",
|
||||
"missing_auth_index": "认证文件缺少 auth_index",
|
||||
"empty_models": "暂无额度数据"
|
||||
},
|
||||
"vertex_import": {
|
||||
"title": "Vertex AI 凭证导入",
|
||||
"title": "Vertex JSON 登录",
|
||||
"description": "上传 Google 服务账号 JSON,使用 CLI vertex-import 同步规则写入 auth-dir/vertex-<project>.json。",
|
||||
"location_label": "目标区域 (可选)",
|
||||
"location_placeholder": "us-central1",
|
||||
@@ -534,6 +544,11 @@
|
||||
"by_hour": "按小时",
|
||||
"by_day": "按天",
|
||||
"refresh": "刷新",
|
||||
"export": "导出数据",
|
||||
"import": "导入数据",
|
||||
"export_success": "使用统计已导出",
|
||||
"import_success": "导入完成:新增 {{added}},跳过 {{skipped}},总请求 {{total}},失败 {{failed}}",
|
||||
"import_invalid": "导入文件格式不正确",
|
||||
"chart_line_label_1": "曲线 1",
|
||||
"chart_line_label_2": "曲线 2",
|
||||
"chart_line_label_3": "曲线 3",
|
||||
@@ -596,6 +611,9 @@
|
||||
"error_logs_modified": "最后修改",
|
||||
"error_logs_download": "下载",
|
||||
"error_log_download_success": "错误日志下载成功",
|
||||
"request_log_download_title": "下载报文",
|
||||
"request_log_download_confirm": "是否要下载id为{{id}}的报文?",
|
||||
"request_log_download_success": "报文下载成功",
|
||||
"empty_title": "暂无日志记录",
|
||||
"empty_desc": "当启用\"日志记录到文件\"功能后,日志将显示在这里",
|
||||
"log_content": "日志内容",
|
||||
|
||||
@@ -397,6 +397,79 @@
|
||||
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']) {
|
||||
.headerBadge {
|
||||
@@ -436,4 +509,23 @@
|
||||
.apiKeyEntryIndex {
|
||||
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 { useInterval } from '@/hooks/useInterval';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
@@ -10,7 +11,14 @@ import { ModelInputList, modelsToEntries, entriesToModels } from '@/components/u
|
||||
import { ToggleSwitch } from '@/components/ui/ToggleSwitch';
|
||||
import { IconCheck, IconX } from '@/components/ui/icons';
|
||||
import { useAuthStore, useConfigStore, useNotificationStore, useThemeStore } from '@/stores';
|
||||
import { ampcodeApi, modelsApi, providersApi, usageApi } from '@/services/api';
|
||||
import {
|
||||
ampcodeApi,
|
||||
apiCallApi,
|
||||
getApiCallErrorMessage,
|
||||
modelsApi,
|
||||
providersApi,
|
||||
usageApi
|
||||
} from '@/services/api';
|
||||
import iconGemini from '@/assets/icons/gemini.svg';
|
||||
import iconOpenaiLight from '@/assets/icons/openai-light.svg';
|
||||
import iconOpenaiDark from '@/assets/icons/openai-dark.svg';
|
||||
@@ -24,7 +32,8 @@ import type {
|
||||
AmpcodeConfig,
|
||||
AmpcodeModelMapping,
|
||||
} 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 { headersToEntries, buildHeaderObject, type HeaderEntry } from '@/utils/headers';
|
||||
import { maskApiKey } from '@/utils/format';
|
||||
@@ -89,18 +98,25 @@ const parseExcludedModels = (text: string): string[] =>
|
||||
const excludedModelsToText = (models?: string[]) =>
|
||||
Array.isArray(models) ? models.join('\n') : '';
|
||||
|
||||
const normalizeOpenAIBaseUrl = (baseUrl: string): string => {
|
||||
let trimmed = String(baseUrl || '').trim();
|
||||
if (!trimmed) return '';
|
||||
trimmed = trimmed.replace(/\/?v0\/management\/?$/i, '');
|
||||
trimmed = trimmed.replace(/\/+$/g, '');
|
||||
if (!/^https?:\/\//i.test(trimmed)) {
|
||||
trimmed = `http://${trimmed}`;
|
||||
}
|
||||
return trimmed;
|
||||
};
|
||||
|
||||
const buildOpenAIModelsEndpoint = (baseUrl: string): string => {
|
||||
const trimmed = String(baseUrl || '')
|
||||
.trim()
|
||||
.replace(/\/+$/g, '');
|
||||
const trimmed = normalizeOpenAIBaseUrl(baseUrl);
|
||||
if (!trimmed) return '';
|
||||
return trimmed.endsWith('/v1') ? `${trimmed}/models` : `${trimmed}/v1/models`;
|
||||
};
|
||||
|
||||
const buildOpenAIChatCompletionsEndpoint = (baseUrl: string): string => {
|
||||
const trimmed = String(baseUrl || '')
|
||||
.trim()
|
||||
.replace(/\/+$/g, '');
|
||||
const trimmed = normalizeOpenAIBaseUrl(baseUrl);
|
||||
if (!trimmed) return '';
|
||||
if (trimmed.endsWith('/chat/completions')) {
|
||||
return trimmed;
|
||||
@@ -202,6 +218,8 @@ export function AiProvidersPage() {
|
||||
const [claudeConfigs, setClaudeConfigs] = useState<ProviderKeyConfig[]>([]);
|
||||
const [openaiProviders, setOpenaiProviders] = useState<OpenAIProviderConfig[]>([]);
|
||||
const [keyStats, setKeyStats] = useState<KeyStats>({ bySource: {}, byAuthIndex: {} });
|
||||
const [usageDetails, setUsageDetails] = useState<UsageDetail[]>([]);
|
||||
const loadingKeyStatsRef = useRef(false);
|
||||
|
||||
const [modal, setModal] = useState<ProviderModal | null>(null);
|
||||
|
||||
@@ -273,13 +291,23 @@ export function AiProvidersPage() {
|
||||
[openaiForm.modelEntries]
|
||||
);
|
||||
|
||||
// 加载 key 统计
|
||||
// 加载 key 统计和 usage 明细(API 层已有60秒超时)
|
||||
const loadKeyStats = useCallback(async () => {
|
||||
// 防止重复请求
|
||||
if (loadingKeyStatsRef.current) return;
|
||||
loadingKeyStatsRef.current = true;
|
||||
try {
|
||||
const stats = await usageApi.getKeyStats();
|
||||
const usageResponse = await usageApi.getUsage();
|
||||
const usageData = usageResponse?.usage ?? usageResponse;
|
||||
const stats = await usageApi.getKeyStats(usageData);
|
||||
setKeyStats(stats);
|
||||
// 收集 usage 明细用于状态栏
|
||||
const details = collectUsageDetails(usageData);
|
||||
setUsageDetails(details);
|
||||
} catch {
|
||||
// 静默失败
|
||||
} finally {
|
||||
loadingKeyStatsRef.current = false;
|
||||
}
|
||||
}, []);
|
||||
|
||||
@@ -311,6 +339,9 @@ export function AiProvidersPage() {
|
||||
loadKeyStats();
|
||||
}, [loadKeyStats]);
|
||||
|
||||
// 定时刷新状态数据(每240秒)
|
||||
useInterval(loadKeyStats, 240_000);
|
||||
|
||||
useEffect(() => {
|
||||
if (config?.geminiApiKeys) setGeminiKeys(config.geminiApiKeys);
|
||||
if (config?.codexApiKeys) setCodexConfigs(config.codexApiKeys);
|
||||
@@ -466,7 +497,7 @@ export function AiProvidersPage() {
|
||||
.find((entry) => entry.apiKey?.trim())
|
||||
?.apiKey?.trim();
|
||||
const hasAuthHeader = Boolean(headers.Authorization || headers['authorization']);
|
||||
const list = await modelsApi.fetchModels(
|
||||
const list = await modelsApi.fetchModelsViaApiCall(
|
||||
baseUrl,
|
||||
hasAuthHeader ? undefined : firstKey,
|
||||
headers
|
||||
@@ -475,7 +506,7 @@ export function AiProvidersPage() {
|
||||
} catch (err: any) {
|
||||
if (allowFallback) {
|
||||
try {
|
||||
const list = await modelsApi.fetchModels(baseUrl);
|
||||
const list = await modelsApi.fetchModelsViaApiCall(baseUrl);
|
||||
setOpenaiDiscoveryModels(list);
|
||||
return;
|
||||
} catch (fallbackErr: any) {
|
||||
@@ -628,48 +659,40 @@ export function AiProvidersPage() {
|
||||
setOpenaiTestStatus('loading');
|
||||
setOpenaiTestMessage(t('ai_providers.openai_test_running'));
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeoutId = window.setTimeout(() => controller.abort(), OPENAI_TEST_TIMEOUT_MS);
|
||||
try {
|
||||
const response = await fetch(endpoint, {
|
||||
const result = await apiCallApi.request(
|
||||
{
|
||||
method: 'POST',
|
||||
headers,
|
||||
signal: controller.signal,
|
||||
body: JSON.stringify({
|
||||
url: endpoint,
|
||||
header: Object.keys(headers).length ? headers : undefined,
|
||||
data: JSON.stringify({
|
||||
model: modelName,
|
||||
messages: [{ role: 'user', content: 'Hi' }],
|
||||
stream: false,
|
||||
max_tokens: 5,
|
||||
}),
|
||||
});
|
||||
const rawText = await response.text();
|
||||
},
|
||||
{ timeout: OPENAI_TEST_TIMEOUT_MS }
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
let errorMessage = `${response.status} ${response.statusText}`;
|
||||
try {
|
||||
const parsed = rawText ? JSON.parse(rawText) : null;
|
||||
errorMessage = parsed?.error?.message || parsed?.message || errorMessage;
|
||||
} catch {
|
||||
if (rawText) {
|
||||
errorMessage = rawText;
|
||||
}
|
||||
}
|
||||
throw new Error(errorMessage);
|
||||
if (result.statusCode < 200 || result.statusCode >= 300) {
|
||||
throw new Error(getApiCallErrorMessage(result));
|
||||
}
|
||||
|
||||
setOpenaiTestStatus('success');
|
||||
setOpenaiTestMessage(t('ai_providers.openai_test_success'));
|
||||
} catch (err: any) {
|
||||
setOpenaiTestStatus('error');
|
||||
if (err?.name === 'AbortError') {
|
||||
const isTimeout =
|
||||
err?.code === 'ECONNABORTED' ||
|
||||
String(err?.message || '').toLowerCase().includes('timeout');
|
||||
if (isTimeout) {
|
||||
setOpenaiTestMessage(
|
||||
t('ai_providers.openai_test_timeout', { seconds: OPENAI_TEST_TIMEOUT_MS / 1000 })
|
||||
);
|
||||
} else {
|
||||
setOpenaiTestMessage(`${t('ai_providers.openai_test_failed')}: ${err?.message || ''}`);
|
||||
}
|
||||
} finally {
|
||||
window.clearTimeout(timeoutId);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1090,6 +1113,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,>(
|
||||
items: T[],
|
||||
keyField: (item: T) => string,
|
||||
@@ -1097,6 +1222,8 @@ export function AiProvidersPage() {
|
||||
onEdit: (index: number) => void,
|
||||
onDelete: (item: T) => void,
|
||||
addLabel: string,
|
||||
emptyTitle: string,
|
||||
emptyDescription: string,
|
||||
deleteLabel?: string,
|
||||
options?: {
|
||||
getRowDisabled?: (item: T, index: number) => boolean;
|
||||
@@ -1110,8 +1237,8 @@ export function AiProvidersPage() {
|
||||
if (!items.length) {
|
||||
return (
|
||||
<EmptyState
|
||||
title={t('common.info')}
|
||||
description={t('ai_providers.gemini_empty_desc')}
|
||||
title={emptyTitle}
|
||||
description={emptyDescription}
|
||||
action={
|
||||
<Button onClick={() => onEdit(-1)} disabled={disableControls}>
|
||||
{addLabel}
|
||||
@@ -1254,12 +1381,16 @@ export function AiProvidersPage() {
|
||||
{t('stats.failure')}: {stats.failure}
|
||||
</span>
|
||||
</div>
|
||||
{/* 状态监测栏 */}
|
||||
{renderStatusBar(item.apiKey)}
|
||||
</Fragment>
|
||||
);
|
||||
},
|
||||
(index) => openGeminiModal(index),
|
||||
(item) => deleteGemini(item.apiKey),
|
||||
t('ai_providers.gemini_add_button'),
|
||||
t('ai_providers.gemini_empty_title'),
|
||||
t('ai_providers.gemini_empty_desc'),
|
||||
undefined,
|
||||
{
|
||||
getRowDisabled: (item) => hasDisableAllModelsRule(item.excludedModels),
|
||||
@@ -1370,12 +1501,16 @@ export function AiProvidersPage() {
|
||||
{t('stats.failure')}: {stats.failure}
|
||||
</span>
|
||||
</div>
|
||||
{/* 状态监测栏 */}
|
||||
{renderStatusBar(item.apiKey)}
|
||||
</Fragment>
|
||||
);
|
||||
},
|
||||
(index) => openProviderModal('codex', index),
|
||||
(item) => deleteProviderEntry('codex', item.apiKey),
|
||||
t('ai_providers.codex_add_button'),
|
||||
t('ai_providers.codex_empty_title'),
|
||||
t('ai_providers.codex_empty_desc'),
|
||||
undefined,
|
||||
{
|
||||
getRowDisabled: (item) => hasDisableAllModelsRule(item.excludedModels),
|
||||
@@ -1502,12 +1637,16 @@ export function AiProvidersPage() {
|
||||
{t('stats.failure')}: {stats.failure}
|
||||
</span>
|
||||
</div>
|
||||
{/* 状态监测栏 */}
|
||||
{renderStatusBar(item.apiKey)}
|
||||
</Fragment>
|
||||
);
|
||||
},
|
||||
(index) => openProviderModal('claude', index),
|
||||
(item) => deleteProviderEntry('claude', item.apiKey),
|
||||
t('ai_providers.claude_add_button'),
|
||||
t('ai_providers.claude_empty_title'),
|
||||
t('ai_providers.claude_empty_desc'),
|
||||
undefined,
|
||||
{
|
||||
getRowDisabled: (item) => hasDisableAllModelsRule(item.excludedModels),
|
||||
@@ -1721,12 +1860,16 @@ export function AiProvidersPage() {
|
||||
{t('stats.failure')}: {stats.failure}
|
||||
</span>
|
||||
</div>
|
||||
{/* 状态监测栏(汇总) */}
|
||||
{renderOpenAIStatusBar(item.name)}
|
||||
</Fragment>
|
||||
);
|
||||
},
|
||||
(index) => openOpenaiModal(index),
|
||||
(item) => deleteOpenai(item.name),
|
||||
t('ai_providers.openai_add_button')
|
||||
t('ai_providers.openai_add_button'),
|
||||
t('ai_providers.openai_empty_title'),
|
||||
t('ai_providers.openai_empty_desc')
|
||||
)}
|
||||
</Card>
|
||||
|
||||
|
||||
@@ -162,6 +162,155 @@
|
||||
}
|
||||
}
|
||||
|
||||
.antigravityGrid {
|
||||
display: grid;
|
||||
gap: $spacing-md;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
|
||||
@include tablet {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
@include mobile {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.antigravityControls {
|
||||
display: flex;
|
||||
gap: $spacing-md;
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-end;
|
||||
margin-bottom: $spacing-md;
|
||||
}
|
||||
|
||||
.antigravityControl {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
|
||||
label {
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
.antigravityCard {
|
||||
background-image: linear-gradient(
|
||||
180deg,
|
||||
rgba(224, 247, 250, 0.12),
|
||||
rgba(224, 247, 250, 0)
|
||||
);
|
||||
}
|
||||
|
||||
.quotaSection {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $spacing-sm;
|
||||
padding-top: $spacing-sm;
|
||||
margin-top: $spacing-xs;
|
||||
border-top: 1px dashed var(--border-color);
|
||||
}
|
||||
|
||||
.quotaRow {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $spacing-xs;
|
||||
}
|
||||
|
||||
.quotaRowHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: $spacing-sm;
|
||||
min-width: 0;
|
||||
|
||||
@include mobile {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
}
|
||||
|
||||
.quotaModel {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
|
||||
@include mobile {
|
||||
white-space: normal;
|
||||
}
|
||||
}
|
||||
|
||||
.quotaBar {
|
||||
height: 8px;
|
||||
background-color: var(--bg-tertiary);
|
||||
border-radius: 999px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.quotaBarFill {
|
||||
height: 100%;
|
||||
background-color: var(--success-color, #22c55e);
|
||||
transition: width 0.2s ease;
|
||||
}
|
||||
|
||||
.quotaBarFillHigh {
|
||||
background-color: var(--success-color, #22c55e);
|
||||
}
|
||||
|
||||
.quotaBarFillMedium {
|
||||
background-color: var(--warning-color, #f59e0b);
|
||||
}
|
||||
|
||||
.quotaBarFillLow {
|
||||
background-color: var(--danger-color, #ef4444);
|
||||
}
|
||||
|
||||
.quotaMeta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
white-space: nowrap;
|
||||
|
||||
@include mobile {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
}
|
||||
|
||||
.quotaPercent {
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.quotaReset {
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.quotaMessage {
|
||||
font-size: 12px;
|
||||
color: var(--text-tertiary);
|
||||
text-align: center;
|
||||
padding: $spacing-sm 0;
|
||||
}
|
||||
|
||||
.quotaError {
|
||||
font-size: 12px;
|
||||
color: var(--danger-color);
|
||||
background-color: rgba(239, 68, 68, 0.08);
|
||||
border: 1px solid var(--danger-color);
|
||||
border-radius: $radius-sm;
|
||||
padding: $spacing-xs $spacing-sm;
|
||||
}
|
||||
|
||||
// 单个认证文件卡片
|
||||
.fileCard {
|
||||
background-color: var(--bg-primary);
|
||||
@@ -250,6 +399,78 @@
|
||||
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 {
|
||||
display: flex;
|
||||
gap: $spacing-xs;
|
||||
@@ -350,6 +571,60 @@
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
// OAuth 排除列表表单:提供商快捷标签
|
||||
.providerField {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $spacing-xs;
|
||||
|
||||
:global(.form-group) {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.providerTagList {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: $spacing-xs;
|
||||
}
|
||||
|
||||
.providerTag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 4px 10px;
|
||||
border-radius: $radius-full;
|
||||
border: 1px solid var(--border-color);
|
||||
background-color: var(--bg-secondary);
|
||||
color: var(--text-secondary);
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
transition: all $transition-fast;
|
||||
|
||||
&:hover {
|
||||
border-color: var(--primary-color);
|
||||
color: var(--text-primary);
|
||||
background-color: var(--bg-hover);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
.providerTagActive {
|
||||
background-color: var(--primary-color);
|
||||
border-color: var(--primary-color);
|
||||
color: #fff;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--primary-color);
|
||||
border-color: var(--primary-color);
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
|
||||
// 详情弹窗
|
||||
.detailContent {
|
||||
max-height: 400px;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useMemo, useRef, useState, useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useInterval } from '@/hooks/useInterval';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { LoadingSpinner } from '@/components/ui/LoadingSpinner';
|
||||
@@ -8,10 +9,11 @@ import { Modal } from '@/components/ui/Modal';
|
||||
import { EmptyState } from '@/components/ui/EmptyState';
|
||||
import { IconBot, IconDownload, IconInfo, IconTrash2 } from '@/components/ui/icons';
|
||||
import { useAuthStore, useNotificationStore, useThemeStore } from '@/stores';
|
||||
import { authFilesApi, usageApi } from '@/services/api';
|
||||
import { apiCallApi, authFilesApi, getApiCallErrorMessage, usageApi } from '@/services/api';
|
||||
import { apiClient } from '@/services/api/client';
|
||||
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 styles from './AuthFilesPage.module.scss';
|
||||
|
||||
@@ -63,11 +65,116 @@ const TYPE_COLORS: Record<string, TypeColorSet> = {
|
||||
}
|
||||
};
|
||||
|
||||
const OAUTH_PROVIDER_PRESETS = [
|
||||
'gemini',
|
||||
'gemini-cli',
|
||||
'vertex',
|
||||
'aistudio',
|
||||
'antigravity',
|
||||
'claude',
|
||||
'codex',
|
||||
'qwen',
|
||||
'iflow'
|
||||
];
|
||||
|
||||
const OAUTH_PROVIDER_EXCLUDES = new Set(['all', 'unknown', 'empty']);
|
||||
|
||||
interface ExcludedFormState {
|
||||
provider: string;
|
||||
modelsText: string;
|
||||
}
|
||||
|
||||
interface AntigravityQuotaGroup {
|
||||
id: string;
|
||||
label: string;
|
||||
models: string[];
|
||||
remainingFraction: number;
|
||||
resetTime?: string;
|
||||
}
|
||||
|
||||
interface AntigravityQuotaState {
|
||||
status: 'idle' | 'loading' | 'success' | 'error';
|
||||
groups: AntigravityQuotaGroup[];
|
||||
error?: string;
|
||||
}
|
||||
|
||||
interface AntigravityQuotaInfo {
|
||||
displayName?: string;
|
||||
quotaInfo?: {
|
||||
remainingFraction?: number | string;
|
||||
remaining_fraction?: number | string;
|
||||
remaining?: number | string;
|
||||
resetTime?: string;
|
||||
reset_time?: string;
|
||||
};
|
||||
quota_info?: {
|
||||
remainingFraction?: number | string;
|
||||
remaining_fraction?: number | string;
|
||||
remaining?: number | string;
|
||||
resetTime?: string;
|
||||
reset_time?: string;
|
||||
};
|
||||
}
|
||||
|
||||
type AntigravityModelsPayload = Record<string, AntigravityQuotaInfo>;
|
||||
|
||||
interface AntigravityQuotaGroupDefinition {
|
||||
id: string;
|
||||
label: string;
|
||||
identifiers: string[];
|
||||
labelFromModel?: boolean;
|
||||
}
|
||||
|
||||
const ANTIGRAVITY_QUOTA_URLS = [
|
||||
'https://cloudcode-pa-pa.sandbox.googleapis.com/v1internal:fetchAvailableModels',
|
||||
'https://daily-cloudcode-pa.sandbox.googleapis.com/v1internal:fetchAvailableModels',
|
||||
'https://cloudcode-pa.googleapis.com/v1internal:fetchAvailableModels'
|
||||
];
|
||||
|
||||
const ANTIGRAVITY_REQUEST_HEADERS = {
|
||||
Authorization: 'Bearer $TOKEN$',
|
||||
'Content-Type': 'application/json',
|
||||
'User-Agent': 'antigravity/1.11.5 windows/amd64'
|
||||
};
|
||||
|
||||
const ANTIGRAVITY_QUOTA_GROUPS: AntigravityQuotaGroupDefinition[] = [
|
||||
{
|
||||
id: 'claude-gpt',
|
||||
label: 'Claude/GPT',
|
||||
identifiers: [
|
||||
'claude-sonnet-4-5-thinking',
|
||||
'claude-opus-4-5-thinking',
|
||||
'claude-sonnet-4-5',
|
||||
'gpt-oss-120b-medium'
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'gemini',
|
||||
label: 'Gemini',
|
||||
identifiers: [
|
||||
'gemini-3-pro-high',
|
||||
'gemini-3-pro-low',
|
||||
'gemini-2.5-flash',
|
||||
'gemini-2.5-flash-lite',
|
||||
'rev19-uic3-1p'
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'gemini-3-flash',
|
||||
label: 'Gemini 3 Flash',
|
||||
identifiers: ['gemini-3-flash']
|
||||
},
|
||||
{
|
||||
id: 'gemini-image',
|
||||
label: 'gemini-3-pro-image',
|
||||
identifiers: ['gemini-3-pro-image'],
|
||||
labelFromModel: true
|
||||
}
|
||||
];
|
||||
|
||||
let antigravityQuotaCache: Record<string, AntigravityQuotaState> = {};
|
||||
let antigravityQuotaCacheLoaded = false;
|
||||
|
||||
// 标准化 auth_index 值(与 usage.ts 中的 normalizeAuthIndex 保持一致)
|
||||
function normalizeAuthIndexValue(value: unknown): string | null {
|
||||
if (typeof value === 'number' && Number.isFinite(value)) {
|
||||
@@ -80,6 +187,155 @@ function normalizeAuthIndexValue(value: unknown): string | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
function parseAntigravityPayload(payload: unknown): Record<string, unknown> | null {
|
||||
if (payload === undefined || payload === null) return null;
|
||||
if (typeof payload === 'string') {
|
||||
const trimmed = payload.trim();
|
||||
if (!trimmed) return null;
|
||||
try {
|
||||
return JSON.parse(trimmed) as Record<string, unknown>;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
if (typeof payload === 'object') {
|
||||
return payload as Record<string, unknown>;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function getAntigravityQuotaInfo(entry?: AntigravityQuotaInfo): {
|
||||
remainingFraction: number | null;
|
||||
resetTime?: string;
|
||||
displayName?: string;
|
||||
} {
|
||||
if (!entry) {
|
||||
return { remainingFraction: null };
|
||||
}
|
||||
const quotaInfo = entry.quotaInfo ?? entry.quota_info ?? {};
|
||||
const remainingValue =
|
||||
quotaInfo.remainingFraction ?? quotaInfo.remaining_fraction ?? quotaInfo.remaining;
|
||||
const remainingFraction = Number(remainingValue);
|
||||
const resetValue = quotaInfo.resetTime ?? quotaInfo.reset_time;
|
||||
const resetTime = typeof resetValue === 'string' ? resetValue : undefined;
|
||||
const displayName = typeof entry.displayName === 'string' ? entry.displayName : undefined;
|
||||
|
||||
return {
|
||||
remainingFraction: Number.isFinite(remainingFraction) ? remainingFraction : null,
|
||||
resetTime,
|
||||
displayName
|
||||
};
|
||||
}
|
||||
|
||||
function findAntigravityModel(
|
||||
models: AntigravityModelsPayload,
|
||||
identifier: string
|
||||
): { id: string; entry: AntigravityQuotaInfo } | null {
|
||||
const direct = models[identifier];
|
||||
if (direct) {
|
||||
return { id: identifier, entry: direct };
|
||||
}
|
||||
|
||||
const match = Object.entries(models).find(([, entry]) => {
|
||||
const name = typeof entry?.displayName === 'string' ? entry.displayName : '';
|
||||
return name.toLowerCase() === identifier.toLowerCase();
|
||||
});
|
||||
if (match) {
|
||||
return { id: match[0], entry: match[1] };
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function buildAntigravityQuotaGroups(models: AntigravityModelsPayload): AntigravityQuotaGroup[] {
|
||||
const groups: AntigravityQuotaGroup[] = [];
|
||||
let geminiResetTime: string | undefined;
|
||||
const [claudeDef, geminiDef, flashDef, imageDef] = ANTIGRAVITY_QUOTA_GROUPS;
|
||||
|
||||
const buildGroup = (
|
||||
def: AntigravityQuotaGroupDefinition,
|
||||
overrideResetTime?: string
|
||||
): AntigravityQuotaGroup | null => {
|
||||
const matches = def.identifiers
|
||||
.map((identifier) => findAntigravityModel(models, identifier))
|
||||
.filter((entry): entry is { id: string; entry: AntigravityQuotaInfo } => Boolean(entry));
|
||||
|
||||
const quotaEntries = matches
|
||||
.map(({ id, entry }) => {
|
||||
const info = getAntigravityQuotaInfo(entry);
|
||||
if (info.remainingFraction === null) return null;
|
||||
return {
|
||||
id,
|
||||
remainingFraction: info.remainingFraction,
|
||||
resetTime: info.resetTime,
|
||||
displayName: info.displayName
|
||||
};
|
||||
})
|
||||
.filter((entry): entry is NonNullable<typeof entry> => entry !== null);
|
||||
|
||||
if (quotaEntries.length === 0) return null;
|
||||
|
||||
const remainingFraction = Math.min(...quotaEntries.map((entry) => entry.remainingFraction));
|
||||
const resetTime =
|
||||
overrideResetTime ?? quotaEntries.map((entry) => entry.resetTime).find(Boolean);
|
||||
const displayName = quotaEntries.map((entry) => entry.displayName).find(Boolean);
|
||||
const label = def.labelFromModel && displayName ? displayName : def.label;
|
||||
|
||||
return {
|
||||
id: def.id,
|
||||
label,
|
||||
models: quotaEntries.map((entry) => entry.id),
|
||||
remainingFraction,
|
||||
resetTime
|
||||
};
|
||||
};
|
||||
|
||||
const claudeGroup = buildGroup(claudeDef);
|
||||
if (claudeGroup) {
|
||||
groups.push(claudeGroup);
|
||||
}
|
||||
|
||||
const geminiGroup = buildGroup(geminiDef);
|
||||
if (geminiGroup) {
|
||||
geminiResetTime = geminiGroup.resetTime;
|
||||
groups.push(geminiGroup);
|
||||
}
|
||||
|
||||
const flashGroup = buildGroup(flashDef);
|
||||
if (flashGroup) {
|
||||
groups.push(flashGroup);
|
||||
}
|
||||
|
||||
const imageGroup = buildGroup(imageDef, geminiResetTime);
|
||||
if (imageGroup) {
|
||||
groups.push(imageGroup);
|
||||
}
|
||||
|
||||
return groups;
|
||||
}
|
||||
|
||||
function formatQuotaResetTime(value?: string): string {
|
||||
if (!value) return '-';
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return '-';
|
||||
return date.toLocaleString(undefined, {
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: false
|
||||
});
|
||||
}
|
||||
|
||||
function resolveAuthProvider(file: AuthFileItem): string {
|
||||
const raw = file.provider ?? file.type ?? '';
|
||||
return String(raw).trim().toLowerCase();
|
||||
}
|
||||
|
||||
function isAntigravityFile(file: AuthFileItem): boolean {
|
||||
return resolveAuthProvider(file) === 'antigravity';
|
||||
}
|
||||
|
||||
function isRuntimeOnlyAuthFile(file: AuthFileItem): boolean {
|
||||
const raw = file['runtime_only'] ?? file.runtimeOnly;
|
||||
if (typeof raw === 'boolean') return raw;
|
||||
@@ -139,10 +395,17 @@ export function AuthFilesPage() {
|
||||
const [search, setSearch] = useState('');
|
||||
const [page, setPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(9);
|
||||
const [antigravityPage, setAntigravityPage] = useState(1);
|
||||
const [antigravityPageSize, setAntigravityPageSize] = useState(6);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [deleting, setDeleting] = useState<string | null>(null);
|
||||
const [deletingAll, setDeletingAll] = useState(false);
|
||||
const [keyStats, setKeyStats] = useState<KeyStats>({ bySource: {}, byAuthIndex: {} });
|
||||
const [usageDetails, setUsageDetails] = useState<UsageDetail[]>([]);
|
||||
const [antigravityQuota, setAntigravityQuota] = useState<Record<string, AntigravityQuotaState>>(
|
||||
{}
|
||||
);
|
||||
const [antigravityLoading, setAntigravityLoading] = useState(false);
|
||||
|
||||
// 详情弹窗相关
|
||||
const [detailModalOpen, setDetailModalOpen] = useState(false);
|
||||
@@ -164,6 +427,9 @@ export function AuthFilesPage() {
|
||||
const [savingExcluded, setSavingExcluded] = useState(false);
|
||||
|
||||
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const loadingKeyStatsRef = useRef(false);
|
||||
const antigravityLoadingRef = useRef(false);
|
||||
const antigravityRequestIdRef = useRef(0);
|
||||
const excludedUnsupportedRef = useRef(false);
|
||||
|
||||
const disableControls = connectionStatus !== 'connected';
|
||||
@@ -195,13 +461,23 @@ export function AuthFilesPage() {
|
||||
}
|
||||
}, [t]);
|
||||
|
||||
// 加载 key 统计
|
||||
// 加载 key 统计和 usage 明细(API 层已有60秒超时)
|
||||
const loadKeyStats = useCallback(async () => {
|
||||
// 防止重复请求
|
||||
if (loadingKeyStatsRef.current) return;
|
||||
loadingKeyStatsRef.current = true;
|
||||
try {
|
||||
const stats = await usageApi.getKeyStats();
|
||||
const usageResponse = await usageApi.getUsage();
|
||||
const usageData = usageResponse?.usage ?? usageResponse;
|
||||
const stats = await usageApi.getKeyStats(usageData);
|
||||
setKeyStats(stats);
|
||||
// 收集 usage 明细用于状态栏
|
||||
const details = collectUsageDetails(usageData);
|
||||
setUsageDetails(details);
|
||||
} catch {
|
||||
// 静默失败
|
||||
} finally {
|
||||
loadingKeyStatsRef.current = false;
|
||||
}
|
||||
}, []);
|
||||
|
||||
@@ -231,12 +507,165 @@ export function AuthFilesPage() {
|
||||
}
|
||||
}, [showNotification, t]);
|
||||
|
||||
const antigravityFiles = useMemo(
|
||||
() => files.filter((file) => isAntigravityFile(file)),
|
||||
[files]
|
||||
);
|
||||
|
||||
const antigravityTotalPages = Math.max(
|
||||
1,
|
||||
Math.ceil(antigravityFiles.length / antigravityPageSize)
|
||||
);
|
||||
const antigravityCurrentPage = Math.min(antigravityPage, antigravityTotalPages);
|
||||
const antigravityStart = (antigravityCurrentPage - 1) * antigravityPageSize;
|
||||
const antigravityPageItems = antigravityFiles.slice(
|
||||
antigravityStart,
|
||||
antigravityStart + antigravityPageSize
|
||||
);
|
||||
|
||||
const fetchAntigravityQuota = useCallback(
|
||||
async (authIndex: string): Promise<AntigravityQuotaGroup[]> => {
|
||||
let lastError = '';
|
||||
let hadSuccess = false;
|
||||
|
||||
for (const url of ANTIGRAVITY_QUOTA_URLS) {
|
||||
try {
|
||||
const result = await apiCallApi.request({
|
||||
authIndex,
|
||||
method: 'POST',
|
||||
url,
|
||||
header: { ...ANTIGRAVITY_REQUEST_HEADERS },
|
||||
data: '{}'
|
||||
});
|
||||
|
||||
if (result.statusCode < 200 || result.statusCode >= 300) {
|
||||
lastError = getApiCallErrorMessage(result);
|
||||
continue;
|
||||
}
|
||||
|
||||
hadSuccess = true;
|
||||
const payload = parseAntigravityPayload(result.body ?? result.bodyText);
|
||||
const models = payload?.models;
|
||||
if (!models || typeof models !== 'object' || Array.isArray(models)) {
|
||||
lastError = t('antigravity_quota.empty_models');
|
||||
continue;
|
||||
}
|
||||
|
||||
const groups = buildAntigravityQuotaGroups(models as AntigravityModelsPayload);
|
||||
if (groups.length === 0) {
|
||||
lastError = t('antigravity_quota.empty_models');
|
||||
continue;
|
||||
}
|
||||
|
||||
return groups;
|
||||
} catch (err: unknown) {
|
||||
lastError = err instanceof Error ? err.message : t('common.unknown_error');
|
||||
}
|
||||
}
|
||||
|
||||
if (hadSuccess) {
|
||||
return [];
|
||||
}
|
||||
|
||||
throw new Error(lastError || t('common.unknown_error'));
|
||||
},
|
||||
[t]
|
||||
);
|
||||
|
||||
const loadAntigravityQuota = useCallback(async () => {
|
||||
if (antigravityLoadingRef.current) return;
|
||||
antigravityLoadingRef.current = true;
|
||||
const requestId = ++antigravityRequestIdRef.current;
|
||||
setAntigravityLoading(true);
|
||||
|
||||
try {
|
||||
if (antigravityFiles.length === 0) {
|
||||
setAntigravityQuota({});
|
||||
return;
|
||||
}
|
||||
|
||||
const loadingState: Record<string, AntigravityQuotaState> = {};
|
||||
antigravityFiles.forEach((file) => {
|
||||
loadingState[file.name] = { status: 'loading', groups: [] };
|
||||
});
|
||||
setAntigravityQuota(loadingState);
|
||||
|
||||
const results = await Promise.all(
|
||||
antigravityFiles.map(async (file) => {
|
||||
const rawAuthIndex = file['auth_index'] ?? file.authIndex;
|
||||
const authIndex = normalizeAuthIndexValue(rawAuthIndex);
|
||||
if (!authIndex) {
|
||||
return {
|
||||
name: file.name,
|
||||
status: 'error' as const,
|
||||
error: t('antigravity_quota.missing_auth_index')
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const groups = await fetchAntigravityQuota(authIndex);
|
||||
return { name: file.name, status: 'success' as const, groups };
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof Error ? err.message : t('common.unknown_error');
|
||||
return { name: file.name, status: 'error' as const, error: message };
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
if (requestId !== antigravityRequestIdRef.current) return;
|
||||
|
||||
const nextState: Record<string, AntigravityQuotaState> = {};
|
||||
results.forEach((result) => {
|
||||
if (result.status === 'success') {
|
||||
nextState[result.name] = {
|
||||
status: 'success',
|
||||
groups: result.groups
|
||||
};
|
||||
} else {
|
||||
nextState[result.name] = {
|
||||
status: 'error',
|
||||
groups: [],
|
||||
error: result.error
|
||||
};
|
||||
}
|
||||
});
|
||||
setAntigravityQuota(nextState);
|
||||
antigravityQuotaCache = nextState;
|
||||
antigravityQuotaCacheLoaded = true;
|
||||
} finally {
|
||||
if (requestId === antigravityRequestIdRef.current) {
|
||||
setAntigravityLoading(false);
|
||||
antigravityLoadingRef.current = false;
|
||||
}
|
||||
}
|
||||
}, [antigravityFiles, fetchAntigravityQuota, t]);
|
||||
|
||||
useEffect(() => {
|
||||
loadFiles();
|
||||
loadKeyStats();
|
||||
loadExcluded();
|
||||
}, [loadFiles, loadKeyStats, loadExcluded]);
|
||||
|
||||
useEffect(() => {
|
||||
if (antigravityFiles.length === 0) {
|
||||
setAntigravityQuota({});
|
||||
return;
|
||||
}
|
||||
if (antigravityQuotaCacheLoaded) {
|
||||
setAntigravityQuota(antigravityQuotaCache);
|
||||
return;
|
||||
}
|
||||
loadAntigravityQuota();
|
||||
}, [
|
||||
antigravityFiles,
|
||||
loadAntigravityQuota,
|
||||
antigravityQuotaCacheLoaded,
|
||||
antigravityQuotaCache
|
||||
]);
|
||||
|
||||
// 定时刷新状态数据(每240秒)
|
||||
useInterval(loadKeyStats, 240_000);
|
||||
|
||||
// 提取所有存在的类型
|
||||
const existingTypes = useMemo(() => {
|
||||
const types = new Set<string>(['all']);
|
||||
@@ -248,6 +677,45 @@ export function AuthFilesPage() {
|
||||
return Array.from(types);
|
||||
}, [files]);
|
||||
|
||||
|
||||
const excludedProviderLookup = useMemo(() => {
|
||||
const lookup = new Map<string, string>();
|
||||
Object.keys(excluded).forEach((provider) => {
|
||||
const key = provider.trim().toLowerCase();
|
||||
if (key && !lookup.has(key)) {
|
||||
lookup.set(key, provider);
|
||||
}
|
||||
});
|
||||
return lookup;
|
||||
}, [excluded]);
|
||||
|
||||
const providerOptions = useMemo(() => {
|
||||
const extraProviders = new Set<string>();
|
||||
|
||||
Object.keys(excluded).forEach((provider) => {
|
||||
extraProviders.add(provider);
|
||||
});
|
||||
files.forEach((file) => {
|
||||
if (typeof file.type === 'string') {
|
||||
extraProviders.add(file.type);
|
||||
}
|
||||
if (typeof file.provider === 'string') {
|
||||
extraProviders.add(file.provider);
|
||||
}
|
||||
});
|
||||
|
||||
const normalizedExtras = Array.from(extraProviders)
|
||||
.map((value) => value.trim())
|
||||
.filter((value) => value && !OAUTH_PROVIDER_EXCLUDES.has(value.toLowerCase()));
|
||||
|
||||
const baseSet = new Set(OAUTH_PROVIDER_PRESETS.map((value) => value.toLowerCase()));
|
||||
const extraList = normalizedExtras
|
||||
.filter((value) => !baseSet.has(value.toLowerCase()))
|
||||
.sort((a, b) => a.localeCompare(b));
|
||||
|
||||
return [...OAUTH_PROVIDER_PRESETS, ...extraList];
|
||||
}, [excluded, files]);
|
||||
|
||||
// 过滤和搜索
|
||||
const filtered = useMemo(() => {
|
||||
return files.filter((item) => {
|
||||
@@ -494,9 +962,14 @@ export function AuthFilesPage() {
|
||||
|
||||
// OAuth 排除相关方法
|
||||
const openExcludedModal = (provider?: string) => {
|
||||
const models = provider ? excluded[provider] : [];
|
||||
const normalizedProvider = (provider || '').trim();
|
||||
const fallbackProvider = normalizedProvider || (filter !== 'all' ? String(filter) : '');
|
||||
const lookupKey = fallbackProvider
|
||||
? excludedProviderLookup.get(fallbackProvider.toLowerCase())
|
||||
: undefined;
|
||||
const models = lookupKey ? excluded[lookupKey] : [];
|
||||
setExcludedForm({
|
||||
provider: provider || '',
|
||||
provider: lookupKey || fallbackProvider,
|
||||
modelsText: Array.isArray(models) ? models.join('\n') : ''
|
||||
});
|
||||
setExcludedModalOpen(true);
|
||||
@@ -570,6 +1043,65 @@ export function AuthFilesPage() {
|
||||
</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 fileStats = resolveAuthFileStats(item, keyStats);
|
||||
@@ -606,6 +1138,9 @@ export function AuthFilesPage() {
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 状态监测栏 */}
|
||||
{renderStatusBar(item)}
|
||||
|
||||
<div className={styles.cardActions}>
|
||||
{isRuntimeOnly ? (
|
||||
<div className={styles.virtualBadge}>{t('auth_files.type_virtual') || '虚拟认证文件'}</div>
|
||||
@@ -662,6 +1197,81 @@ export function AuthFilesPage() {
|
||||
);
|
||||
};
|
||||
|
||||
const renderAntigravityCard = (item: AuthFileItem) => {
|
||||
const displayType = item.type || item.provider || 'antigravity';
|
||||
const typeColor = getTypeColor(displayType);
|
||||
const quotaState = antigravityQuota[item.name];
|
||||
const quotaStatus =
|
||||
quotaState?.status ??
|
||||
(antigravityLoading || !antigravityQuotaCacheLoaded ? 'loading' : 'idle');
|
||||
const quotaGroups = quotaState?.groups ?? [];
|
||||
|
||||
return (
|
||||
<div key={item.name} className={`${styles.fileCard} ${styles.antigravityCard}`}>
|
||||
<div className={styles.cardHeader}>
|
||||
<span
|
||||
className={styles.typeBadge}
|
||||
style={{
|
||||
backgroundColor: typeColor.bg,
|
||||
color: typeColor.text,
|
||||
...(typeColor.border ? { border: typeColor.border } : {})
|
||||
}}
|
||||
>
|
||||
{getTypeLabel(displayType)}
|
||||
</span>
|
||||
<span className={styles.fileName}>{item.name}</span>
|
||||
</div>
|
||||
|
||||
<div className={styles.quotaSection}>
|
||||
{quotaStatus === 'loading' ? (
|
||||
<div className={styles.quotaMessage}>{t('antigravity_quota.loading')}</div>
|
||||
) : quotaStatus === 'idle' ? (
|
||||
<div className={styles.quotaMessage}>{t('antigravity_quota.idle')}</div>
|
||||
) : quotaStatus === 'error' ? (
|
||||
<div className={styles.quotaError}>
|
||||
{t('antigravity_quota.load_failed', {
|
||||
message: quotaState?.error || t('common.unknown_error')
|
||||
})}
|
||||
</div>
|
||||
) : quotaGroups.length === 0 ? (
|
||||
<div className={styles.quotaMessage}>{t('antigravity_quota.empty_models')}</div>
|
||||
) : (
|
||||
quotaGroups.map((group) => {
|
||||
const clamped = Math.max(0, Math.min(1, group.remainingFraction));
|
||||
const percent = Math.round(clamped * 100);
|
||||
const resetLabel = formatQuotaResetTime(group.resetTime);
|
||||
const quotaBarClass =
|
||||
percent >= 60
|
||||
? styles.quotaBarFillHigh
|
||||
: percent >= 20
|
||||
? styles.quotaBarFillMedium
|
||||
: styles.quotaBarFillLow;
|
||||
return (
|
||||
<div key={group.id} className={styles.quotaRow}>
|
||||
<div className={styles.quotaRowHeader}>
|
||||
<span className={styles.quotaModel} title={group.models.join(', ')}>
|
||||
{group.label}
|
||||
</span>
|
||||
<div className={styles.quotaMeta}>
|
||||
<span className={styles.quotaPercent}>{percent}%</span>
|
||||
<span className={styles.quotaReset}>{resetLabel}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.quotaBar}>
|
||||
<div
|
||||
className={`${styles.quotaBarFill} ${quotaBarClass}`}
|
||||
style={{ width: `${percent}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<div className={styles.pageHeader}>
|
||||
@@ -784,6 +1394,88 @@ export function AuthFilesPage() {
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<Card
|
||||
title={t('antigravity_quota.title')}
|
||||
extra={
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={loadAntigravityQuota}
|
||||
disabled={disableControls || antigravityLoading || antigravityFiles.length === 0}
|
||||
loading={antigravityLoading}
|
||||
>
|
||||
{t('common.refresh')}
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
{antigravityFiles.length === 0 ? (
|
||||
<EmptyState
|
||||
title={t('antigravity_quota.empty_title')}
|
||||
description={t('antigravity_quota.empty_desc')}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<div className={styles.antigravityControls}>
|
||||
<div className={styles.antigravityControl}>
|
||||
<label>{t('auth_files.page_size_label')}</label>
|
||||
<select
|
||||
className={styles.pageSizeSelect}
|
||||
value={antigravityPageSize}
|
||||
onChange={(e) => {
|
||||
setAntigravityPageSize(Number(e.target.value) || 6);
|
||||
setAntigravityPage(1);
|
||||
}}
|
||||
>
|
||||
<option value={6}>6</option>
|
||||
<option value={9}>9</option>
|
||||
<option value={12}>12</option>
|
||||
<option value={18}>18</option>
|
||||
<option value={24}>24</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className={styles.antigravityControl}>
|
||||
<label>{t('common.info')}</label>
|
||||
<div className={styles.statsInfo}>
|
||||
{antigravityFiles.length} {t('auth_files.files_count')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.antigravityGrid}>
|
||||
{antigravityPageItems.map(renderAntigravityCard)}
|
||||
</div>
|
||||
{antigravityFiles.length > antigravityPageSize && (
|
||||
<div className={styles.pagination}>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => setAntigravityPage(Math.max(1, antigravityCurrentPage - 1))}
|
||||
disabled={antigravityCurrentPage <= 1}
|
||||
>
|
||||
{t('auth_files.pagination_prev')}
|
||||
</Button>
|
||||
<div className={styles.pageInfo}>
|
||||
{t('auth_files.pagination_info', {
|
||||
current: antigravityCurrentPage,
|
||||
total: antigravityTotalPages,
|
||||
count: antigravityFiles.length
|
||||
})}
|
||||
</div>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
setAntigravityPage(Math.min(antigravityTotalPages, antigravityCurrentPage + 1))
|
||||
}
|
||||
disabled={antigravityCurrentPage >= antigravityTotalPages}
|
||||
>
|
||||
{t('auth_files.pagination_next')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* OAuth 排除列表卡片 */}
|
||||
<Card
|
||||
title={t('oauth_excluded.title')}
|
||||
@@ -932,12 +1624,41 @@ export function AuthFilesPage() {
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className={styles.providerField}>
|
||||
<Input
|
||||
id="oauth-excluded-provider"
|
||||
list="oauth-excluded-provider-options"
|
||||
label={t('oauth_excluded.provider_label')}
|
||||
hint={t('oauth_excluded.provider_hint')}
|
||||
placeholder={t('oauth_excluded.provider_placeholder')}
|
||||
value={excludedForm.provider}
|
||||
onChange={(e) => setExcludedForm((prev) => ({ ...prev, provider: e.target.value }))}
|
||||
/>
|
||||
<datalist id="oauth-excluded-provider-options">
|
||||
{providerOptions.map((provider) => (
|
||||
<option key={provider} value={provider} />
|
||||
))}
|
||||
</datalist>
|
||||
{providerOptions.length > 0 && (
|
||||
<div className={styles.providerTagList}>
|
||||
{providerOptions.map((provider) => {
|
||||
const isActive =
|
||||
excludedForm.provider.trim().toLowerCase() === provider.toLowerCase();
|
||||
return (
|
||||
<button
|
||||
key={provider}
|
||||
type="button"
|
||||
className={`${styles.providerTag} ${isActive ? styles.providerTagActive : ''}`}
|
||||
onClick={() => setExcludedForm((prev) => ({ ...prev, provider }))}
|
||||
disabled={savingExcluded}
|
||||
>
|
||||
{getTypeLabel(provider)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className={styles.formGroup}>
|
||||
<label>{t('oauth_excluded.models_label')}</label>
|
||||
<textarea
|
||||
|
||||
@@ -2,11 +2,10 @@
|
||||
|
||||
.container {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
flex: 1;
|
||||
min-height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.pageTitle {
|
||||
@@ -134,8 +133,8 @@
|
||||
|
||||
.editorWrapper {
|
||||
width: 100%;
|
||||
flex: 0 0 auto;
|
||||
height: 480px;
|
||||
flex: 1;
|
||||
min-height: 400px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: $radius-lg;
|
||||
overflow: hidden;
|
||||
@@ -220,9 +219,9 @@
|
||||
.configCard {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
height: 560px;
|
||||
flex-shrink: 0;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.actions {
|
||||
@@ -254,10 +253,11 @@
|
||||
}
|
||||
|
||||
.configCard {
|
||||
height: 440px;
|
||||
padding: $spacing-md;
|
||||
}
|
||||
|
||||
.editorWrapper {
|
||||
height: 360px;
|
||||
min-height: 300px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -242,7 +242,11 @@ export function DashboardPage() {
|
||||
</div>
|
||||
<div className={styles.connectionInfo}>
|
||||
<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 && (
|
||||
<span className={styles.buildDate}>
|
||||
{new Date(serverBuildDate).toLocaleDateString(i18n.language)}
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useTranslation } from 'react-i18next';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
import { IconEye, IconEyeOff } from '@/components/ui/icons';
|
||||
import { useAuthStore, useNotificationStore } from '@/stores';
|
||||
import { useAuthStore, useLanguageStore, useNotificationStore } from '@/stores';
|
||||
import { detectApiBaseFromLocation, normalizeApiBase } from '@/utils/connection';
|
||||
|
||||
export function LoginPage() {
|
||||
@@ -12,6 +12,8 @@ export function LoginPage() {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const { showNotification } = useNotificationStore();
|
||||
const language = useLanguageStore((state) => state.language);
|
||||
const toggleLanguage = useLanguageStore((state) => state.toggleLanguage);
|
||||
const isAuthenticated = useAuthStore((state) => state.isAuthenticated);
|
||||
const login = useAuthStore((state) => state.login);
|
||||
const restoreSession = useAuthStore((state) => state.restoreSession);
|
||||
@@ -27,6 +29,7 @@ export function LoginPage() {
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const detectedBase = useMemo(() => detectApiBaseFromLocation(), []);
|
||||
const nextLanguageLabel = language === 'zh-CN' ? t('language.english') : t('language.chinese');
|
||||
|
||||
useEffect(() => {
|
||||
const init = async () => {
|
||||
@@ -49,10 +52,6 @@ export function LoginPage() {
|
||||
return <Navigate to={redirect} replace />;
|
||||
}
|
||||
|
||||
const handleUseCurrent = () => {
|
||||
setApiBase(detectedBase);
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!managementKey.trim()) {
|
||||
setError(t('login.error_required'));
|
||||
@@ -79,7 +78,20 @@ export function LoginPage() {
|
||||
<div className="login-page">
|
||||
<div className="login-card">
|
||||
<div className="login-header">
|
||||
<div className="login-title-row">
|
||||
<div className="title">{t('title.login')}</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="login-language-btn"
|
||||
onClick={toggleLanguage}
|
||||
title={t('language.switch')}
|
||||
aria-label={t('language.switch')}
|
||||
>
|
||||
{nextLanguageLabel}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="subtitle">{t('login.subtitle')}</div>
|
||||
</div>
|
||||
|
||||
@@ -136,14 +148,9 @@ export function LoginPage() {
|
||||
}
|
||||
/>
|
||||
|
||||
<div style={{ display: 'flex', gap: 12, alignItems: 'center' }}>
|
||||
<Button variant="secondary" onClick={handleUseCurrent}>
|
||||
{t('login.use_current_address')}
|
||||
</Button>
|
||||
<Button fullWidth onClick={handleSubmit} loading={loading}>
|
||||
{loading ? t('login.submitting') : t('login.submit_button')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{error && <div className="error-box">{error}</div>}
|
||||
|
||||
|
||||
@@ -2,11 +2,15 @@
|
||||
|
||||
.container {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
|
||||
@include mobile {
|
||||
min-height: auto;
|
||||
overflow: visible;
|
||||
}
|
||||
}
|
||||
|
||||
.pageTitle {
|
||||
@@ -53,6 +57,11 @@
|
||||
gap: $spacing-lg;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
|
||||
@include mobile {
|
||||
gap: $spacing-md;
|
||||
min-height: auto;
|
||||
}
|
||||
}
|
||||
|
||||
.logCard {
|
||||
@@ -61,6 +70,12 @@
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
|
||||
@include mobile {
|
||||
flex: 0 0 auto;
|
||||
min-height: auto;
|
||||
overflow: visible;
|
||||
}
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
@@ -87,6 +102,11 @@
|
||||
:global(.form-group) {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
@include mobile {
|
||||
gap: $spacing-sm;
|
||||
margin-bottom: $spacing-sm;
|
||||
}
|
||||
}
|
||||
|
||||
.searchWrapper {
|
||||
@@ -161,13 +181,26 @@
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: $radius-md;
|
||||
flex: 0 0 auto;
|
||||
height: 480px;
|
||||
flex: 1 1 auto;
|
||||
min-height: 280px;
|
||||
max-height: calc(100vh - 320px);
|
||||
overflow: auto;
|
||||
position: relative;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
touch-action: pan-y;
|
||||
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 {
|
||||
@@ -190,6 +223,17 @@
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-secondary);
|
||||
font-size: 12px;
|
||||
|
||||
@include mobile {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
justify-content: flex-start;
|
||||
gap: $spacing-xs;
|
||||
|
||||
> span {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.loadMoreCount {
|
||||
@@ -201,6 +245,16 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: $spacing-md;
|
||||
|
||||
@include mobile {
|
||||
width: 100%;
|
||||
flex-wrap: wrap;
|
||||
gap: $spacing-sm;
|
||||
|
||||
> span {
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.logList {
|
||||
@@ -227,9 +281,18 @@
|
||||
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 {
|
||||
grid-template-columns: 1fr;
|
||||
gap: $spacing-xs;
|
||||
padding: 8px 10px;
|
||||
font-size: 11.5px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -371,12 +434,17 @@
|
||||
|
||||
@include mobile {
|
||||
max-width: 100%;
|
||||
flex-basis: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.message {
|
||||
color: var(--text-secondary);
|
||||
word-break: break-word;
|
||||
|
||||
@include mobile {
|
||||
flex-basis: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-height: 820px) {
|
||||
@@ -402,10 +470,65 @@
|
||||
}
|
||||
|
||||
.logPanel {
|
||||
height: 360px;
|
||||
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 type { PointerEvent as ReactPointerEvent } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { EmptyState } from '@/components/ui/EmptyState';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
import { Modal } from '@/components/ui/Modal';
|
||||
import { ToggleSwitch } from '@/components/ui/ToggleSwitch';
|
||||
import {
|
||||
IconDownload,
|
||||
@@ -38,6 +40,8 @@ const INITIAL_DISPLAY_LINES = 100;
|
||||
const LOAD_MORE_LINES = 200;
|
||||
const MAX_BUFFER_LINES = 10000;
|
||||
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;
|
||||
type HttpMethod = (typeof HTTP_METHODS)[number];
|
||||
@@ -370,14 +374,22 @@ export function LogsPage() {
|
||||
const [autoRefresh, setAutoRefresh] = useState(false);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const deferredSearchQuery = useDeferredValue(searchQuery);
|
||||
const [hideManagementLogs, setHideManagementLogs] = useState(false);
|
||||
const [hideManagementLogs, setHideManagementLogs] = useState(true);
|
||||
const [errorLogs, setErrorLogs] = useState<ErrorLogItem[]>([]);
|
||||
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 pendingScrollToBottomRef = useRef(false);
|
||||
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);
|
||||
@@ -647,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 (
|
||||
<div className={styles.container}>
|
||||
<h1 className={styles.pageTitle}>{t('logs.title')}</h1>
|
||||
@@ -795,6 +886,11 @@ export function LogsPage() {
|
||||
onDoubleClick={() => {
|
||||
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', {
|
||||
defaultValue: 'Double-click to copy',
|
||||
})}
|
||||
@@ -946,6 +1042,32 @@ export function LogsPage() {
|
||||
</Card>
|
||||
)}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -114,3 +114,25 @@
|
||||
gap: $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 { Card } from '@/components/ui/Card';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
import { useNotificationStore, useThemeStore } from '@/stores';
|
||||
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 iconOpenaiLight from '@/assets/icons/openai-light.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 iconQwen from '@/assets/icons/qwen.svg';
|
||||
import iconIflow from '@/assets/icons/iflow.svg';
|
||||
import iconVertex from '@/assets/icons/vertex.svg';
|
||||
|
||||
interface ProviderState {
|
||||
url?: string;
|
||||
@@ -36,6 +38,22 @@ interface IFlowCookieState {
|
||||
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 } }[] = [
|
||||
{ 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 },
|
||||
@@ -46,6 +64,9 @@ const PROVIDERS: { id: OAuthProvider; titleKey: string; hintKey: string; urlLabe
|
||||
];
|
||||
|
||||
const CALLBACK_SUPPORTED: OAuthProvider[] = ['codex', 'anthropic', 'antigravity', 'gemini-cli', 'iflow'];
|
||||
const getProviderI18nPrefix = (provider: OAuthProvider) => provider.replace('-', '_');
|
||||
const getAuthKey = (provider: OAuthProvider, suffix: string) =>
|
||||
`auth_login.${getProviderI18nPrefix(provider)}_${suffix}`;
|
||||
|
||||
const getIcon = (icon: string | { light: string; dark: string }, theme: 'light' | 'dark') => {
|
||||
return typeof icon === 'string' ? icon : icon[theme];
|
||||
@@ -57,7 +78,13 @@ export function OAuthPage() {
|
||||
const resolvedTheme = useThemeStore((state) => state.resolvedTheme);
|
||||
const [states, setStates] = useState<Record<OAuthProvider, ProviderState>>({} as Record<OAuthProvider, ProviderState>);
|
||||
const [iflowCookie, setIflowCookie] = useState<IFlowCookieState>({ cookie: '', loading: false });
|
||||
const [vertexState, setVertexState] = useState<VertexImportState>({
|
||||
fileName: '',
|
||||
location: '',
|
||||
loading: false
|
||||
});
|
||||
const timers = useRef<Record<string, number>>({});
|
||||
const vertexFileInputRef = useRef<HTMLInputElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
@@ -81,12 +108,15 @@ export function OAuthPage() {
|
||||
const res = await oauthApi.getAuthStatus(state);
|
||||
if (res.status === 'ok') {
|
||||
updateProviderState(provider, { status: 'success', polling: false });
|
||||
showNotification(t('auth_login.codex_oauth_status_success'), 'success');
|
||||
showNotification(t(getAuthKey(provider, 'oauth_status_success')), 'success');
|
||||
window.clearInterval(timer);
|
||||
delete timers.current[provider];
|
||||
} else if (res.status === 'error') {
|
||||
updateProviderState(provider, { status: 'error', error: res.error, polling: false });
|
||||
showNotification(`${t('auth_login.codex_oauth_status_error')} ${res.error || ''}`, 'error');
|
||||
showNotification(
|
||||
`${t(getAuthKey(provider, 'oauth_status_error'))} ${res.error || ''}`,
|
||||
'error'
|
||||
);
|
||||
window.clearInterval(timer);
|
||||
delete timers.current[provider];
|
||||
}
|
||||
@@ -129,7 +159,7 @@ export function OAuthPage() {
|
||||
}
|
||||
} catch (err: any) {
|
||||
updateProviderState(provider, { status: 'error', error: err?.message, polling: false });
|
||||
showNotification(`${t('auth_login.codex_oauth_start_error')} ${err?.message || ''}`, 'error');
|
||||
showNotification(`${t(getAuthKey(provider, 'oauth_start_error'))} ${err?.message || ''}`, 'error');
|
||||
}
|
||||
};
|
||||
|
||||
@@ -216,6 +246,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 (
|
||||
<div className={styles.container}>
|
||||
<h1 className={styles.pageTitle}>{t('nav.oauth', { defaultValue: 'OAuth' })}</h1>
|
||||
@@ -265,14 +353,14 @@ export function OAuthPage() {
|
||||
<div className={styles.authUrlValue}>{state.url}</div>
|
||||
<div className={styles.authUrlActions}>
|
||||
<Button variant="secondary" size="sm" onClick={() => copyLink(state.url!)}>
|
||||
{t('auth_login.codex_copy_link')}
|
||||
{t(getAuthKey(provider.id, 'copy_link'))}
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => window.open(state.url, '_blank', 'noopener,noreferrer')}
|
||||
>
|
||||
{t('auth_login.codex_open_link')}
|
||||
{t(getAuthKey(provider.id, 'open_link'))}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -317,10 +405,10 @@ export function OAuthPage() {
|
||||
{state.status && state.status !== 'idle' && (
|
||||
<div className="status-badge" style={{ marginTop: 8 }}>
|
||||
{state.status === 'success'
|
||||
? t('auth_login.codex_oauth_status_success')
|
||||
? t(getAuthKey(provider.id, 'oauth_status_success'))
|
||||
: state.status === 'error'
|
||||
? `${t('auth_login.codex_oauth_status_error')} ${state.error || ''}`
|
||||
: t('auth_login.codex_oauth_status_waiting')}
|
||||
? `${t(getAuthKey(provider.id, 'oauth_status_error'))} ${state.error || ''}`
|
||||
: t(getAuthKey(provider.id, 'oauth_status_waiting'))}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
@@ -328,6 +416,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 登录 */}
|
||||
<Card
|
||||
title={
|
||||
|
||||
@@ -18,6 +18,13 @@
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.headerActions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.pageTitle {
|
||||
font-size: 28px;
|
||||
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 {
|
||||
Chart as ChartJS,
|
||||
@@ -19,7 +19,7 @@ import { Input } from '@/components/ui/Input';
|
||||
import { LoadingSpinner } from '@/components/ui/LoadingSpinner';
|
||||
import { IconDiamond, IconDollarSign, IconSatellite, IconTimer, IconTrendingUp } from '@/components/ui/icons';
|
||||
import { useMediaQuery } from '@/hooks/useMediaQuery';
|
||||
import { useThemeStore } from '@/stores';
|
||||
import { useNotificationStore, useThemeStore } from '@/stores';
|
||||
import { usageApi } from '@/services/api/usage';
|
||||
import {
|
||||
formatTokensInMillions,
|
||||
@@ -63,6 +63,7 @@ interface UsagePayload {
|
||||
|
||||
export function UsagePage() {
|
||||
const { t } = useTranslation();
|
||||
const { showNotification } = useNotificationStore();
|
||||
const isMobile = useMediaQuery('(max-width: 768px)');
|
||||
const resolvedTheme = useThemeStore((state) => state.resolvedTheme);
|
||||
const isDark = resolvedTheme === 'dark';
|
||||
@@ -71,6 +72,9 @@ export function UsagePage() {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
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
|
||||
const [selectedModel, setSelectedModel] = useState('');
|
||||
@@ -107,6 +111,77 @@ export function UsagePage() {
|
||||
setModelPrices(loadModelPrices());
|
||||
}, [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
|
||||
const tokenBreakdown = usage ? calculateTokenBreakdown(usage) : { cachedTokens: 0, reasoningTokens: 0 };
|
||||
const rateStats = usage
|
||||
@@ -527,14 +602,41 @@ export function UsagePage() {
|
||||
)}
|
||||
<div className={styles.header}>
|
||||
<h1 className={styles.pageTitle}>{t('usage_stats.title')}</h1>
|
||||
<div className={styles.headerActions}>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={handleExport}
|
||||
loading={exporting}
|
||||
disabled={loading || importing}
|
||||
>
|
||||
{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}
|
||||
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>
|
||||
|
||||
{error && <div className={styles.errorBox}>{error}</div>}
|
||||
|
||||
86
src/services/api/apiCall.ts
Normal file
86
src/services/api/apiCall.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
/**
|
||||
* Generic API call helper (proxied via management API).
|
||||
*/
|
||||
|
||||
import type { AxiosRequestConfig } from 'axios';
|
||||
import { apiClient } from './client';
|
||||
|
||||
export interface ApiCallRequest {
|
||||
authIndex?: string;
|
||||
method: string;
|
||||
url: string;
|
||||
header?: Record<string, string>;
|
||||
data?: string;
|
||||
}
|
||||
|
||||
export interface ApiCallResult<T = any> {
|
||||
statusCode: number;
|
||||
header: Record<string, string[]>;
|
||||
bodyText: string;
|
||||
body: T | null;
|
||||
}
|
||||
|
||||
const normalizeBody = (input: unknown): { bodyText: string; body: any | null } => {
|
||||
if (input === undefined || input === null) {
|
||||
return { bodyText: '', body: null };
|
||||
}
|
||||
|
||||
if (typeof input === 'string') {
|
||||
const text = input;
|
||||
const trimmed = text.trim();
|
||||
if (!trimmed) {
|
||||
return { bodyText: text, body: null };
|
||||
}
|
||||
try {
|
||||
return { bodyText: text, body: JSON.parse(trimmed) };
|
||||
} catch {
|
||||
return { bodyText: text, body: text };
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
return { bodyText: JSON.stringify(input), body: input };
|
||||
} catch {
|
||||
return { bodyText: String(input), body: input };
|
||||
}
|
||||
};
|
||||
|
||||
export const getApiCallErrorMessage = (result: ApiCallResult): string => {
|
||||
const status = result.statusCode;
|
||||
const body = result.body;
|
||||
const bodyText = result.bodyText;
|
||||
let message = '';
|
||||
|
||||
if (body && typeof body === 'object') {
|
||||
message = body?.error?.message || body?.error || body?.message || '';
|
||||
} else if (typeof body === 'string') {
|
||||
message = body;
|
||||
}
|
||||
|
||||
if (!message && bodyText) {
|
||||
message = bodyText;
|
||||
}
|
||||
|
||||
if (status && message) return `${status} ${message}`.trim();
|
||||
if (status) return `HTTP ${status}`;
|
||||
return message || 'Request failed';
|
||||
};
|
||||
|
||||
export const apiCallApi = {
|
||||
request: async (
|
||||
payload: ApiCallRequest,
|
||||
config?: AxiosRequestConfig
|
||||
): Promise<ApiCallResult> => {
|
||||
const response = await apiClient.post('/api-call', payload, config);
|
||||
const statusCode = Number(response?.status_code ?? response?.statusCode ?? 0);
|
||||
const header = (response?.header ?? response?.headers ?? {}) as Record<string, string[]>;
|
||||
const { bodyText, body } = normalizeBody(response?.body);
|
||||
|
||||
return {
|
||||
statusCode,
|
||||
header,
|
||||
bodyText,
|
||||
body
|
||||
};
|
||||
}
|
||||
};
|
||||
@@ -62,12 +62,37 @@ class ApiClient {
|
||||
return `${normalized}${MANAGEMENT_API_PREFIX}`;
|
||||
}
|
||||
|
||||
private readHeader(headers: Record<string, any>, keys: string[]): string | null {
|
||||
private readHeader(headers: Record<string, any> | undefined, keys: string[]): string | null {
|
||||
if (!headers) return null;
|
||||
|
||||
const normalizeValue = (value: unknown): string | null => {
|
||||
if (value === undefined || value === null) return null;
|
||||
if (Array.isArray(value)) {
|
||||
const first = value.find((entry) => entry !== undefined && entry !== null && String(entry).trim());
|
||||
return first !== undefined ? String(first) : null;
|
||||
}
|
||||
const text = String(value);
|
||||
return text ? text : null;
|
||||
};
|
||||
|
||||
const headerGetter = (headers as { get?: (name: string) => any }).get;
|
||||
if (typeof headerGetter === 'function') {
|
||||
for (const key of keys) {
|
||||
const match = normalizeValue(headerGetter.call(headers, key));
|
||||
if (match) return match;
|
||||
}
|
||||
}
|
||||
|
||||
const entries =
|
||||
typeof (headers as { entries?: () => Iterable<[string, any]> }).entries === 'function'
|
||||
? Array.from((headers as { entries: () => Iterable<[string, any]> }).entries())
|
||||
: Object.entries(headers);
|
||||
|
||||
const normalized = Object.fromEntries(
|
||||
Object.entries(headers || {}).map(([key, value]) => [key.toLowerCase(), value as string | undefined])
|
||||
entries.map(([key, value]) => [String(key).toLowerCase(), value])
|
||||
);
|
||||
for (const key of keys) {
|
||||
const match = normalized[key.toLowerCase()];
|
||||
const match = normalizeValue(normalized[key.toLowerCase()]);
|
||||
if (match) return match;
|
||||
}
|
||||
return null;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
export * from './client';
|
||||
export * from './apiCall';
|
||||
export * from './config';
|
||||
export * from './configFile';
|
||||
export * from './apiKeys';
|
||||
@@ -11,3 +12,4 @@ export * from './logs';
|
||||
export * from './version';
|
||||
export * from './models';
|
||||
export * from './transformers';
|
||||
export * from './vertex';
|
||||
|
||||
@@ -39,4 +39,10 @@ export const logsApi = {
|
||||
responseType: 'blob',
|
||||
timeout: LOGS_TIMEOUT_MS
|
||||
}),
|
||||
|
||||
downloadRequestLogById: (id: string) =>
|
||||
apiClient.getRaw(`/request-log-by-id/${encodeURIComponent(id)}`, {
|
||||
responseType: 'blob',
|
||||
timeout: LOGS_TIMEOUT_MS
|
||||
}),
|
||||
};
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
|
||||
import axios from 'axios';
|
||||
import { normalizeModelList } from '@/utils/models';
|
||||
import { apiCallApi, getApiCallErrorMessage } from './apiCall';
|
||||
|
||||
const normalizeBaseUrl = (baseUrl: string): string => {
|
||||
let normalized = String(baseUrl || '').trim();
|
||||
@@ -39,5 +40,35 @@ export const modelsApi = {
|
||||
});
|
||||
const payload = response.data?.data ?? response.data?.models ?? response.data;
|
||||
return normalizeModelList(payload, { dedupe: true });
|
||||
},
|
||||
|
||||
async fetchModelsViaApiCall(
|
||||
baseUrl: string,
|
||||
apiKey?: string,
|
||||
headers: Record<string, string> = {}
|
||||
) {
|
||||
const endpoint = buildModelsEndpoint(baseUrl);
|
||||
if (!endpoint) {
|
||||
throw new Error('Invalid base url');
|
||||
}
|
||||
|
||||
const resolvedHeaders = { ...headers };
|
||||
const hasAuthHeader = Boolean(resolvedHeaders.Authorization || resolvedHeaders.authorization);
|
||||
if (apiKey && !hasAuthHeader) {
|
||||
resolvedHeaders.Authorization = `Bearer ${apiKey}`;
|
||||
}
|
||||
|
||||
const result = await apiCallApi.request({
|
||||
method: 'GET',
|
||||
url: endpoint,
|
||||
header: Object.keys(resolvedHeaders).length ? resolvedHeaders : undefined
|
||||
});
|
||||
|
||||
if (result.statusCode < 200 || result.statusCode >= 300) {
|
||||
throw new Error(getApiCallErrorMessage(result));
|
||||
}
|
||||
|
||||
const payload = result.body ?? result.bodyText;
|
||||
return normalizeModelList(payload, { dedupe: true });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -7,12 +7,38 @@ import { computeKeyStats, KeyStats } from '@/utils/usage';
|
||||
|
||||
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 = {
|
||||
/**
|
||||
* 获取使用统计原始数据
|
||||
*/
|
||||
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 数据
|
||||
*/
|
||||
|
||||
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,6 +8,7 @@ import { persist } from 'zustand/middleware';
|
||||
import type { Language } from '@/types';
|
||||
import { STORAGE_KEY_LANGUAGE } from '@/utils/constants';
|
||||
import i18n from '@/i18n';
|
||||
import { getInitialLanguage } from '@/utils/language';
|
||||
|
||||
interface LanguageState {
|
||||
language: Language;
|
||||
@@ -18,7 +19,7 @@ interface LanguageState {
|
||||
export const useLanguageStore = create<LanguageState>()(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
language: 'zh-CN',
|
||||
language: getInitialLanguage(),
|
||||
|
||||
setLanguage: (language) => {
|
||||
// 切换 i18next 语言
|
||||
|
||||
@@ -350,6 +350,32 @@ textarea {
|
||||
justify-content: center;
|
||||
z-index: $z-modal;
|
||||
padding: $spacing-lg;
|
||||
|
||||
&.modal-overlay-entering {
|
||||
animation: modal-overlay-fade-in 0.25s ease-out forwards;
|
||||
}
|
||||
|
||||
&.modal-overlay-closing {
|
||||
animation: modal-overlay-fade-out 0.35s ease-in forwards;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes modal-overlay-fade-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes modal-overlay-fade-out {
|
||||
from {
|
||||
opacity: 1;
|
||||
}
|
||||
to {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.modal {
|
||||
@@ -361,34 +387,58 @@ textarea {
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
position: relative;
|
||||
// 关闭按钮中心位置: right 12px + 16px = 28px, top 12px + 16px = 28px
|
||||
transform-origin: calc(100% - 28px) 28px;
|
||||
|
||||
&.modal-entering {
|
||||
animation: modal-scale-in 0.3s cubic-bezier(0.34, 1.56, 0.64, 1) forwards;
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: $spacing-md $spacing-lg;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
|
||||
.modal-title {
|
||||
font-weight: 700;
|
||||
font-size: 18px;
|
||||
color: var(--text-primary);
|
||||
&.modal-closing {
|
||||
animation: modal-collapse-to-close 0.35s cubic-bezier(0.4, 0, 0.2, 1) forwards;
|
||||
}
|
||||
}
|
||||
|
||||
.modal-close {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
@keyframes modal-scale-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.85) translateY(20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale(1) translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes modal-collapse-to-close {
|
||||
0% {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
100% {
|
||||
opacity: 0;
|
||||
transform: scale(0);
|
||||
}
|
||||
}
|
||||
|
||||
.modal-close-floating {
|
||||
position: absolute;
|
||||
top: 12px;
|
||||
right: 12px;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
border: none;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
border-radius: $radius-md;
|
||||
transition: color 0.15s ease, background-color 0.15s ease;
|
||||
border-radius: $radius-full;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: color 0.15s ease, background-color 0.15s ease, transform 0.15s ease;
|
||||
z-index: 10;
|
||||
|
||||
svg {
|
||||
display: block;
|
||||
@@ -396,8 +446,25 @@ textarea {
|
||||
|
||||
&:hover {
|
||||
color: var(--text-primary);
|
||||
background: var(--bg-secondary);
|
||||
background: var(--bg-tertiary);
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
&:active {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: $spacing-md $spacing-lg;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
|
||||
.modal-title {
|
||||
font-weight: 700;
|
||||
font-size: 18px;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -12,6 +12,13 @@
|
||||
overflow: hidden;
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text-primary);
|
||||
|
||||
@media (max-width: $breakpoint-mobile) {
|
||||
height: auto;
|
||||
min-height: 100vh;
|
||||
overflow: visible;
|
||||
overflow-y: auto;
|
||||
}
|
||||
}
|
||||
|
||||
.main-header {
|
||||
@@ -28,6 +35,9 @@
|
||||
width: 100%;
|
||||
|
||||
@media (max-width: $breakpoint-mobile) {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
right: 0;
|
||||
padding: $spacing-sm $spacing-md;
|
||||
gap: $spacing-sm;
|
||||
}
|
||||
@@ -230,6 +240,17 @@
|
||||
@supports (height: 100dvh) {
|
||||
height: calc(100dvh - var(--header-height));
|
||||
}
|
||||
|
||||
@media (max-width: $breakpoint-mobile) {
|
||||
height: auto;
|
||||
min-height: calc(100vh - var(--header-height));
|
||||
overflow: visible;
|
||||
padding-top: var(--header-height);
|
||||
|
||||
@supports (min-height: 100dvh) {
|
||||
min-height: calc(100dvh - var(--header-height));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
@@ -328,16 +349,38 @@
|
||||
min-width: 0;
|
||||
overflow-y: auto;
|
||||
height: 100%;
|
||||
|
||||
&.content-logs {
|
||||
overflow: hidden;
|
||||
|
||||
@media (max-width: $breakpoint-mobile) {
|
||||
overflow: visible;
|
||||
overflow-y: auto;
|
||||
height: auto;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.main-content {
|
||||
flex: 0 0 auto;
|
||||
flex: 1 0 auto;
|
||||
padding: $spacing-lg;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $spacing-lg;
|
||||
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) {
|
||||
padding: $spacing-md;
|
||||
}
|
||||
@@ -354,6 +397,13 @@
|
||||
font-size: 14px;
|
||||
flex-wrap: wrap;
|
||||
gap: $spacing-sm;
|
||||
|
||||
.footer-version {
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
-ms-user-select: none;
|
||||
-webkit-touch-callout: none;
|
||||
}
|
||||
}
|
||||
|
||||
.login-page {
|
||||
@@ -381,6 +431,7 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $spacing-sm;
|
||||
text-align: center;
|
||||
|
||||
.title {
|
||||
font-size: 22px;
|
||||
@@ -393,6 +444,18 @@
|
||||
}
|
||||
}
|
||||
|
||||
.login-title-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: $spacing-sm;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.login-language-btn {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.connection-box {
|
||||
background: var(--bg-secondary);
|
||||
border: 1px dashed var(--border-color);
|
||||
|
||||
42
src/utils/language.ts
Normal file
42
src/utils/language.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import type { Language } from '@/types';
|
||||
import { STORAGE_KEY_LANGUAGE } from '@/utils/constants';
|
||||
|
||||
const parseStoredLanguage = (value: string): Language | null => {
|
||||
try {
|
||||
const parsed = JSON.parse(value);
|
||||
const candidate = parsed?.state?.language ?? parsed?.language ?? parsed;
|
||||
if (candidate === 'zh-CN' || candidate === 'en') {
|
||||
return candidate;
|
||||
}
|
||||
} catch {
|
||||
if (value === 'zh-CN' || value === 'en') {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const getStoredLanguage = (): Language | null => {
|
||||
if (typeof window === 'undefined') {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const stored = localStorage.getItem(STORAGE_KEY_LANGUAGE);
|
||||
if (!stored) {
|
||||
return null;
|
||||
}
|
||||
return parseStoredLanguage(stored);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const getBrowserLanguage = (): Language => {
|
||||
if (typeof navigator === 'undefined') {
|
||||
return 'zh-CN';
|
||||
}
|
||||
const raw = navigator.languages?.[0] || navigator.language || 'zh-CN';
|
||||
return raw.toLowerCase().startsWith('zh') ? 'zh-CN' : 'en';
|
||||
};
|
||||
|
||||
export const getInitialLanguage = (): Language => getStoredLanguage() ?? getBrowserLanguage();
|
||||
@@ -754,6 +754,103 @@ export function buildChartData(
|
||||
/**
|
||||
* 依据 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 {
|
||||
if (!usageData) {
|
||||
return { bySource: {}, byAuthIndex: {} };
|
||||
|
||||
Reference in New Issue
Block a user