Compare commits

..

23 Commits

71 changed files with 4415 additions and 2771 deletions
+1
View File
@@ -10,6 +10,7 @@ api.md
usage.json
CLAUDE.md
AGENTS.md
management-api*
antigravity_usage.json
codex_usage.json
style.md
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Router-For.ME
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+40 -11
View File
@@ -1,23 +1,23 @@
# CLI Proxy API Management Center
A single-file WebUI (React + TypeScript) for operating and troubleshooting the **CLI Proxy API** via its **Management API** (config, credentials, logs, and usage).
A single-file Web UI (React + TypeScript) for operating and troubleshooting the **CLI Proxy API** via its **Management API** (config, credentials, logs, and usage).
[中文文档](README_CN.md)
**Main Project**: https://github.com/router-for-me/CLIProxyAPI
**Example URL**: https://remote.router-for.me/
**Minimum Required Version**: ≥ 6.3.0 (recommended ≥ 6.5.0)
**Minimum Required Version**: ≥ 6.8.0 (recommended ≥ 6.8.15)
Since version 6.0.19, the WebUI ships with the main program; access it via `/management.html` on the API port once the service is running.
Since version 6.0.19, the Web UI ships with the main program; access it via `/management.html` on the API port once the service is running.
## What this is (and isnt)
- This repository is the WebUI only. It talks to the CLI Proxy API **Management API** (`/v0/management`) to read/update config, upload credentials, view logs, and inspect usage.
- This repository is the Web UI only. It talks to the CLI Proxy API **Management API** (`/v0/management`) to read/update config, upload credentials, view logs, and inspect usage.
- It is **not** a proxy and does not forward traffic.
## Quick start
### Option A: Use the WebUI bundled in CLIProxyAPI (recommended)
### Option A: Use the Web UI bundled in CLI Proxy API (recommended)
1. Start your CLI Proxy API service.
2. Open: `http://<host>:<api_port>/management.html`
@@ -32,7 +32,7 @@ npm install
npm run dev
```
Open `http://localhost:5173`, then connect to your CLI Proxy API instance.
Open `http://localhost:5173`, then connect to your CLI Proxy API backend instance.
### Option C: Build a single HTML file
@@ -42,7 +42,7 @@ npm run build
```
- Output: `dist/index.html` (all assets are inlined).
- For CLIProxyAPI bundling, the release workflow renames it to `management.html`.
- For CLI Proxy API bundling, the release workflow renames it to `management.html`.
- To preview locally: `npm run preview`
Tip: opening `dist/index.html` via `file://` may be blocked by browser CORS; serving it (preview/static server) is more reliable.
@@ -74,19 +74,48 @@ See `api.md` for the full authentication rules, server-side limits, and edge cas
## What you can manage (mapped to the UI pages)
- **Dashboard**: connection status, server version/build date, quick counts, model availability snapshot.
- **Basic Settings**: debug, proxy URL, request retry, quota fallback (switch project/preview models), usage statistics, request logging, file logging, WebSocket auth.
- **Basic Settings**: debug, proxy URL, request retry, quota fallback (switch project or preview models when limits reached), usage statistics, request logging, file logging, WebSocket auth.
- **API Keys**: manage proxy `api-keys` (add/edit/delete).
- **AI Providers**:
- Gemini/Codex/Claude key entries (base URL, headers, proxy, model aliases, excluded models, prefix).
- OpenAI-compatible providers (multiple API keys, custom headers, model alias import via `/v1/models`, optional browser-side chat/completions test).
- Gemini/Codex/Claude/Vertex key entries (base URL, headers, proxy, model aliases, excluded models, prefix).
- OpenAI-compatible providers (multiple API keys, custom headers, model alias import via `/v1/models`, optional browser-side "chat/completions" test).
- Ampcode integration (upstream URL/key, force mappings, model mapping table).
- **Auth Files**: upload/download/delete JSON credentials, filter/search/pagination, runtime-only indicators, view supported models per credential (when the server supports it), manage OAuth excluded models (supports `*` wildcards).
- **Auth Files**: upload/download/delete JSON credentials, filter/search/pagination, runtime-only indicators, view supported models per credential (when the server supports it), manage OAuth excluded models (supports `*` wildcards), configure OAuth model alias mappings.
- **OAuth**: start OAuth/device flows for supported providers, poll status, optionally submit callback `redirect_url`; includes iFlow cookie import.
- **Quota Management**: manage quota limits and usage for Claude, Antigravity, Codex, Gemini CLI, and other providers.
- **Usage**: requests/tokens charts (hour/day), per-API & per-model breakdown, cached/reasoning token breakdown, RPM/TPM window, optional cost estimation with locally-saved model pricing.
- **Config**: edit `/config.yaml` in-browser with YAML highlighting + search, then save/reload.
- **Logs**: tail logs with incremental polling, auto-refresh, search, hide management traffic, clear logs; download request error log files.
- **System**: quick links + fetch `/v1/models` (grouped view). Requires at least one proxy API key to query models.
## Tech Stack
- React 19 + TypeScript 5.9
- Vite 7 (single-file build)
- Zustand (state management)
- Axios (HTTP client)
- react-router-dom v7 (HashRouter)
- Chart.js (data visualization)
- CodeMirror 6 (YAML editor)
- SCSS Modules (styling)
- i18next (internationalization)
## Internationalization
Currently supports three languages:
- English (en)
- Simplified Chinese (zh-CN)
- Russian (ru)
The UI language is automatically detected from browser settings and can be manually switched at the bottom of the page.
## Browser Compatibility
- Build target: `ES2020`
- Supports modern browsers (Chrome, Firefox, Safari, Edge)
- Responsive layout for mobile and tablet access
## Build & release notes
- Vite produces a **single HTML** output (`dist/index.html`) with all assets inlined (via `vite-plugin-singlefile`).
+40 -12
View File
@@ -1,14 +1,14 @@
# CLI Proxy API 管理中心
用于管理与排障 **CLI Proxy API** 的单文件 WebUIReact + TypeScript),通过 **Management API** 完成配置、凭据、日志与统计等运维工作。
用于管理与故障排查 **CLI Proxy API** 的单文件 Web UIReact + TypeScript),通过 **Management API** 完成配置、凭据、日志与统计等管理操作。
[English](README.md)
**主项目**: https://github.com/router-for-me/CLIProxyAPI
**示例地址**: https://remote.router-for.me/
**最低版本要求**: ≥ 6.3.0(推荐 ≥ 6.5.0
**最低版本要求**: ≥ 6.8.0(推荐 ≥ 6.8.15
Since version 6.0.19, the WebUI ships with the main program; access it via `/management.html` on the API port once the service is running.
从6.0.19版本开始,Web UI 随主程序一起提供;服务运行后,通过 API 端口上的"/management.html"访问它。
## 这是什么(以及不是什么)
@@ -17,7 +17,7 @@ Since version 6.0.19, the WebUI ships with the main program; access it via `/man
## 快速开始
### 方式 A:使用 CLIProxyAPI 自带的 WebUI(推荐)
### 方式 A:使用 CLI Proxy API 自带的 Web UI(推荐)
1. 启动 CLI Proxy API 服务。
2. 打开:`http://<host>:<api_port>/management.html`
@@ -32,7 +32,7 @@ npm install
npm run dev
```
打开 `http://localhost:5173`,然后连接到你的 CLI Proxy API 实例。
打开 `http://localhost:5173`,然后连接到你的 CLI Proxy API 后端实例。
### 方式 C:构建单文件 HTML
@@ -42,7 +42,7 @@ npm run build
```
- 构建产物:`dist/index.html`(资源已全部内联)。
- 在 CLIProxyAPI 的发布流程里会重命名为 `management.html`
- 在 CLI Proxy API 的发布流程里会重命名为 `management.html`
- 本地预览:`npm run preview`
提示:直接用 `file://` 打开 `dist/index.html` 可能遇到浏览器 CORS 限制;更稳妥的方式是用预览/静态服务器打开。
@@ -51,7 +51,7 @@ npm run build
### API 地址怎么填
以下格式均可,WebUI 会自动归一化:
以下格式均可,Web UI 会自动归一化:
- `localhost:8317`
- `http://192.168.1.10:8317`
@@ -64,29 +64,57 @@ npm run build
- `Authorization: Bearer <MANAGEMENT_KEY>`(默认)
这与 WebUI 中API Keys页面管理的 `api-keys` 不同:后者是代理对外接口(如 OpenAI 兼容接口)给客户端使用的鉴权 key。
这与 Web UI 中"API Keys"页面管理的 `api-keys` 不同:后者是代理对外接口(如 OpenAI 兼容接口)给客户端使用的鉴权 key。
### 远程管理
当你从非 localhost 的浏览器访问时,服务端通常需要开启远程管理(例如 `allow-remote-management: true`)。
完整鉴权规则、限制与边界情况请查看 `api.md`
## 功能一览(按页面对应)
- **仪表盘**:连接状态、服务版本/构建时间、关键数量概览、可用模型概览。
- **基础设置**:调试开关、代理 URL、请求重试、配额回退(切项目/切预览模型)、使用统计、请求日志、文件日志、WebSocket 鉴权。
- **基础设置**:调试开关、代理 URL、请求重试、配额回退(达到上限时切换项目或预览模型)、使用统计、请求日志、文件日志、WebSocket 鉴权。
- **API Keys**:管理代理 `api-keys`(增/改/删)。
- **AI 提供商**
- Gemini/Codex/Claude 配置(Base URL、Headers、代理、模型别名、排除模型、Prefix)。
- Gemini/Codex/Claude/Vertex 配置(Base URL、Headers、代理、模型别名、排除模型、Prefix)。
- OpenAI 兼容提供商(多 Key、Header、自助从 `/v1/models` 拉取并导入模型别名、可选浏览器侧 `chat/completions` 测试)。
- Ampcode 集成(上游地址/密钥、强制映射、模型映射表)。
- **认证文件**:上传/下载/删除 JSON 凭据,筛选/搜索/分页,标记 runtime-only;查看单个凭据可用模型(依赖后端支持);管理 OAuth 排除模型(支持 `*` 通配符)。
- **认证文件**:上传/下载/删除 JSON 凭据,筛选/搜索/分页,标记 runtime-only;查看单个凭据可用模型(依赖后端支持);管理 OAuth 排除模型(支持 `*` 通配符);配置 OAuth 模型别名映射
- **OAuth**:对支持的提供商发起 OAuth/设备码流程,轮询状态;可选提交回调 `redirect_url`;包含 iFlow Cookie 导入。
- **配额管理**:管理 Claude、Antigravity、Codex、Gemini CLI 等提供商的配额上限与使用情况。
- **使用统计**:按小时/天图表、按 API 与按模型统计、缓存/推理 Token 拆分、RPM/TPM 时间窗、可选本地保存的模型价格用于费用估算。
- **配置文件**:浏览器内编辑 `/config.yaml`(YAML 高亮 + 搜索),保存/重载。
- **日志**:增量拉取日志、自动刷新、搜索、隐藏管理端流量、清空日志;下载请求错误日志文件。
- **系统信息**:快捷链接 + 拉取 `/v1/models` 并分组展示(需要至少一个代理 API Key 才能查询模型)。
## 技术栈
- React 19 + TypeScript 5.9
- Vite 7(单文件构建)
- Zustand(状态管理)
- AxiosHTTP 客户端)
- react-router-dom v7HashRouter
- Chart.js(数据可视化)
- CodeMirror 6YAML 编辑器)
- SCSS Modules(样式)
- i18next(国际化)
## 多语言支持
目前支持三种语言:
- 英文 (en)
- 简体中文 (zh-CN)
- 俄文 (ru)
界面语言会根据浏览器设置自动切换,也可在页面底部手动切换。
## 浏览器兼容性
- 构建目标:`ES2020`
- 支持 Chrome、Firefox、Safari、Edge 等现代浏览器
- 支持移动端响应式布局,可通过手机/平板访问
## 构建与发布说明
- 使用 Vite 输出 **单文件 HTML**`dist/index.html`),资源全部内联(`vite-plugin-singlefile`)。
+5 -2
View File
@@ -1,4 +1,5 @@
import { useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { INLINE_LOGO_JPEG } from '@/assets/logoInline';
import './SplashScreen.scss';
@@ -10,6 +11,8 @@ interface SplashScreenProps {
const FADE_OUT_DURATION = 400;
export function SplashScreen({ onFinish, fadeOut = false }: SplashScreenProps) {
const { t } = useTranslation();
useEffect(() => {
if (!fadeOut) return;
const finishTimer = setTimeout(() => {
@@ -25,8 +28,8 @@ export function SplashScreen({ onFinish, fadeOut = false }: SplashScreenProps) {
<div className={`splash-screen ${fadeOut ? 'fade-out' : ''}`}>
<div className="splash-content">
<img src={INLINE_LOGO_JPEG} alt="CPAMC" className="splash-logo" />
<h1 className="splash-title">CLI Proxy API</h1>
<p className="splash-subtitle">Management Center</p>
<h1 className="splash-title">{t('splash.title')}</h1>
<p className="splash-subtitle">{t('splash.subtitle')}</p>
<div className="splash-loader">
<div className="splash-loader-bar" />
</div>
+18 -149
View File
@@ -1,13 +1,14 @@
import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react';
import { useMemo, useState, type ReactNode } from 'react';
import { useTranslation } from 'react-i18next';
import { Button } from '@/components/ui/Button';
import { Input } from '@/components/ui/Input';
import { Modal } from '@/components/ui/Modal';
import { Select } from '@/components/ui/Select';
import { ToggleSwitch } from '@/components/ui/ToggleSwitch';
import { IconChevronDown } from '@/components/ui/icons';
import { ConfigSection } from '@/components/config/ConfigSection';
import { useNotificationStore } from '@/stores';
import styles from './VisualConfigEditor.module.scss';
import { copyToClipboard } from '@/utils/clipboard';
import type {
PayloadFilterRule,
PayloadModelEntry,
@@ -80,118 +81,6 @@ function Divider() {
return <div style={{ height: 1, background: 'var(--border-color)', margin: '16px 0' }} />;
}
type ToastSelectOption = { value: string; label: string };
function ToastSelect({
value,
options,
disabled,
ariaLabel,
onChange,
}: {
value: string;
options: ReadonlyArray<ToastSelectOption>;
disabled?: boolean;
ariaLabel: string;
onChange: (value: string) => void;
}) {
const [open, setOpen] = useState(false);
const containerRef = useRef<HTMLDivElement | null>(null);
const selectedOption = options.find((opt) => opt.value === value) ?? options[0];
useEffect(() => {
if (!open) return;
const handleClickOutside = (event: MouseEvent) => {
if (!containerRef.current) return;
if (!containerRef.current.contains(event.target as Node)) setOpen(false);
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, [open]);
return (
<div ref={containerRef} style={{ position: 'relative' }}>
<button
type="button"
className="input"
disabled={disabled}
onClick={() => setOpen((prev) => !prev)}
aria-label={ariaLabel}
aria-haspopup="listbox"
aria-expanded={open}
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: 8,
cursor: disabled ? 'not-allowed' : 'pointer',
textAlign: 'left',
width: '100%',
appearance: 'none',
}}
>
<span style={{ color: 'var(--text-primary)', fontWeight: 500 }}>
{selectedOption?.label ?? ''}
</span>
<IconChevronDown size={16} style={{ opacity: 0.6, flex: '0 0 auto' }} />
</button>
{open && !disabled && (
<div
role="listbox"
aria-label={ariaLabel}
style={{
position: 'absolute',
top: 'calc(100% + 6px)',
left: 0,
right: 0,
zIndex: 1000,
background: 'var(--bg-primary)',
border: '1px solid var(--border-color)',
borderRadius: 12,
padding: 6,
boxShadow: 'var(--shadow)',
display: 'flex',
flexDirection: 'column',
gap: 6,
maxHeight: 260,
overflowY: 'auto',
}}
>
{options.map((opt) => {
const active = opt.value === value;
return (
<button
key={opt.value}
type="button"
role="option"
aria-selected={active}
onClick={() => {
onChange(opt.value);
setOpen(false);
}}
style={{
padding: '10px 12px',
borderRadius: 10,
border: active ? '1px solid rgba(59, 130, 246, 0.5)' : '1px solid var(--border-color)',
background: active ? 'rgba(59, 130, 246, 0.10)' : 'var(--bg-primary)',
color: 'var(--text-primary)',
cursor: 'pointer',
textAlign: 'left',
fontWeight: 600,
}}
>
{opt.label}
</button>
);
})}
</div>
)}
</div>
);
}
function ApiKeysCardEditor({
value,
disabled,
@@ -266,31 +155,11 @@ function ApiKeysCardEditor({
};
const handleCopy = async (apiKey: string) => {
const copyByExecCommand = () => {
const textarea = document.createElement('textarea');
textarea.value = apiKey;
textarea.setAttribute('readonly', '');
textarea.style.position = 'fixed';
textarea.style.opacity = '0';
textarea.style.pointerEvents = 'none';
document.body.appendChild(textarea);
textarea.select();
textarea.setSelectionRange(0, textarea.value.length);
const copied = document.execCommand('copy');
document.body.removeChild(textarea);
if (!copied) throw new Error('copy_failed');
};
try {
if (navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(apiKey);
} else {
copyByExecCommand();
}
showNotification(t('notification.link_copied'), 'success');
} catch {
showNotification(t('notification.copy_failed'), 'error');
}
const copied = await copyToClipboard(apiKey);
showNotification(
t(copied ? 'notification.link_copied' : 'notification.copy_failed'),
copied ? 'success' : 'error'
);
};
return (
@@ -426,7 +295,7 @@ function PayloadRulesEditor({
protocolFirst?: boolean;
onChange: (next: PayloadRule[]) => void;
}) {
const { t, i18n } = useTranslation();
const { t } = useTranslation();
const rules = value.length ? value : [];
const protocolOptions = useMemo(
() =>
@@ -434,7 +303,7 @@ function PayloadRulesEditor({
value: option.value,
label: t(option.labelKey, { defaultValue: option.defaultLabel }),
})),
[t, i18n.resolvedLanguage]
[t]
);
const payloadValueTypeOptions = useMemo(
() =>
@@ -442,7 +311,7 @@ function PayloadRulesEditor({
value: option.value,
label: t(option.labelKey, { defaultValue: option.defaultLabel }),
})),
[t, i18n.resolvedLanguage]
[t]
);
const addRule = () => onChange([...rules, { id: makeClientId(), models: [], params: [] }]);
@@ -547,7 +416,7 @@ function PayloadRulesEditor({
>
{protocolFirst ? (
<>
<ToastSelect
<Select
value={model.protocol ?? ''}
options={protocolOptions}
disabled={disabled}
@@ -575,7 +444,7 @@ function PayloadRulesEditor({
onChange={(e) => updateModel(ruleIndex, modelIndex, { name: e.target.value })}
disabled={disabled}
/>
<ToastSelect
<Select
value={model.protocol ?? ''}
options={protocolOptions}
disabled={disabled}
@@ -617,7 +486,7 @@ function PayloadRulesEditor({
onChange={(e) => updateParam(ruleIndex, paramIndex, { path: e.target.value })}
disabled={disabled}
/>
<ToastSelect
<Select
value={param.valueType}
options={payloadValueTypeOptions}
disabled={disabled}
@@ -685,7 +554,7 @@ function PayloadFilterRulesEditor({
disabled?: boolean;
onChange: (next: PayloadFilterRule[]) => void;
}) {
const { t, i18n } = useTranslation();
const { t } = useTranslation();
const rules = value.length ? value : [];
const protocolOptions = useMemo(
() =>
@@ -693,7 +562,7 @@ function PayloadFilterRulesEditor({
value: option.value,
label: t(option.labelKey, { defaultValue: option.defaultLabel }),
})),
[t, i18n.resolvedLanguage]
[t]
);
const addRule = () => onChange([...rules, { id: makeClientId(), models: [], params: [] }]);
@@ -760,7 +629,7 @@ function PayloadFilterRulesEditor({
onChange={(e) => updateModel(ruleIndex, modelIndex, { name: e.target.value })}
disabled={disabled}
/>
<ToastSelect
<Select
value={model.protocol ?? ''}
options={protocolOptions}
disabled={disabled}
@@ -1013,7 +882,7 @@ export function VisualConfigEditor({ values, disabled = false, onChange }: Visua
/>
<div className="form-group">
<label>{t('config_management.visual.sections.network.routing_strategy')}</label>
<ToastSelect
<Select
value={values.routingStrategy}
options={[
{ value: 'round-robin', label: t('config_management.visual.sections.network.strategy_round_robin') },
@@ -114,7 +114,7 @@
&.selected {
border-color: var(--primary-color);
background-color: var(--bg-secondary);
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.15);
box-shadow: 0 0 0 2px rgba($primary-color, 0.18);
}
}
@@ -33,7 +33,7 @@ export interface ModelMappingDiagramProps {
}
const PROVIDER_COLORS = [
'#3b82f6', '#10b981', '#f59e0b', '#ef4444',
'#8b8680', '#10b981', '#f59e0b', '#c65746',
'#8b5cf6', '#ec4899', '#06b6d4', '#84cc16'
];
@@ -125,6 +125,12 @@ export function GeminiSection({
<span className={styles.fieldValue}>{item.baseUrl}</span>
</div>
)}
{item.proxyUrl && (
<div className={styles.fieldRow}>
<span className={styles.fieldLabel}>{t('common.proxy_url')}:</span>
<span className={styles.fieldValue}>{item.proxyUrl}</span>
</div>
)}
{headerEntries.length > 0 && (
<div className={styles.headerBadgeList}>
{headerEntries.map(([key, value]) => (
@@ -17,12 +17,12 @@
flex-direction: row;
gap: 6px;
padding: 10px 12px;
background: rgba(255, 255, 255, 0.7);
background: color-mix(in srgb, var(--bg-primary) 82%, transparent);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border: 1px solid rgba(255, 255, 255, 0.3);
border: 1px solid color-mix(in srgb, var(--border-color) 60%, transparent);
border-radius: 999px;
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.1);
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.08);
overflow-x: auto;
scrollbar-width: none;
max-width: inherit;
@@ -39,7 +39,7 @@
pointer-events: none;
opacity: 0;
border-radius: 999px;
background: rgba(59, 130, 246, 0.15);
background: rgba($primary-color, 0.16);
box-shadow: inset 0 0 0 2px var(--primary-color);
transition: transform 220ms cubic-bezier(0.22, 1, 0.36, 1),
width 220ms cubic-bezier(0.22, 1, 0.36, 1),
@@ -73,7 +73,7 @@
flex: 0 0 auto;
&:hover {
background: rgba(0, 0, 0, 0.06);
background: color-mix(in srgb, var(--text-primary) 10%, transparent);
transform: scale(1.08);
}
@@ -104,19 +104,13 @@
// 暗色主题适配
:global([data-theme='dark']) {
.navList {
background: rgba(30, 30, 30, 0.7);
border-color: rgba(255, 255, 255, 0.1);
background: color-mix(in srgb, var(--bg-primary) 82%, transparent);
border-color: color-mix(in srgb, var(--border-color) 55%, transparent);
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.4);
}
.navItem {
&:hover {
background: rgba(255, 255, 255, 0.1);
}
}
.indicator {
background: rgba(59, 130, 246, 0.25);
background: rgba($primary-color, 0.28);
}
}
+128 -21
View File
@@ -1,36 +1,143 @@
import { calculateStatusBarData } from '@/utils/usage';
import styles from '@/pages/AiProvidersPage.module.scss';
import { useState, useCallback, useRef, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import type { StatusBarData, StatusBlockDetail } from '@/utils/usage';
import defaultStyles from '@/pages/AiProvidersPage.module.scss';
interface ProviderStatusBarProps {
statusData: ReturnType<typeof calculateStatusBarData>;
/**
* 根据成功率 (0–1) 在三个色标之间做 RGB 线性插值
* 0 → 红 (#ef4444) → 0.5 → 金黄 (#facc15) → 1 → 绿 (#22c55e)
*/
const COLOR_STOPS = [
{ r: 239, g: 68, b: 68 }, // #ef4444
{ r: 250, g: 204, b: 21 }, // #facc15
{ r: 34, g: 197, b: 94 }, // #22c55e
] as const;
function rateToColor(rate: number): string {
const t = Math.max(0, Math.min(1, rate));
const segment = t < 0.5 ? 0 : 1;
const localT = segment === 0 ? t * 2 : (t - 0.5) * 2;
const from = COLOR_STOPS[segment];
const to = COLOR_STOPS[segment + 1];
const r = Math.round(from.r + (to.r - from.r) * localT);
const g = Math.round(from.g + (to.g - from.g) * localT);
const b = Math.round(from.b + (to.b - from.b) * localT);
return `rgb(${r}, ${g}, ${b})`;
}
export function ProviderStatusBar({ statusData }: ProviderStatusBarProps) {
function formatTime(timestamp: number): string {
const date = new Date(timestamp);
const h = date.getHours().toString().padStart(2, '0');
const m = date.getMinutes().toString().padStart(2, '0');
return `${h}:${m}`;
}
type StylesModule = Record<string, string>;
interface ProviderStatusBarProps {
statusData: StatusBarData;
styles?: StylesModule;
}
export function ProviderStatusBar({ statusData, styles: stylesProp }: ProviderStatusBarProps) {
const { t } = useTranslation();
const s = (stylesProp || defaultStyles) as StylesModule;
const [activeTooltip, setActiveTooltip] = useState<number | null>(null);
const blocksRef = useRef<HTMLDivElement>(null);
const hasData = statusData.totalSuccess + statusData.totalFailure > 0;
const rateClass = !hasData
? ''
: statusData.successRate >= 90
? styles.statusRateHigh
? s.statusRateHigh
: statusData.successRate >= 50
? styles.statusRateMedium
: styles.statusRateLow;
? s.statusRateMedium
: s.statusRateLow;
// 点击外部关闭 tooltip(移动端)
useEffect(() => {
if (activeTooltip === null) return;
const handler = (e: PointerEvent) => {
if (blocksRef.current && !blocksRef.current.contains(e.target as Node)) {
setActiveTooltip(null);
}
};
document.addEventListener('pointerdown', handler);
return () => document.removeEventListener('pointerdown', handler);
}, [activeTooltip]);
const handlePointerEnter = useCallback((e: React.PointerEvent, idx: number) => {
if (e.pointerType === 'mouse') {
setActiveTooltip(idx);
}
}, []);
const handlePointerLeave = useCallback((e: React.PointerEvent) => {
if (e.pointerType === 'mouse') {
setActiveTooltip(null);
}
}, []);
const handlePointerDown = useCallback((e: React.PointerEvent, idx: number) => {
if (e.pointerType === 'touch') {
e.preventDefault();
setActiveTooltip((prev) => (prev === idx ? null : idx));
}
}, []);
const getTooltipPositionClass = (idx: number, total: number): string => {
if (idx <= 2) return s.statusTooltipLeft;
if (idx >= total - 3) return s.statusTooltipRight;
return '';
};
const renderTooltip = (detail: StatusBlockDetail, idx: number) => {
const total = detail.success + detail.failure;
const posClass = getTooltipPositionClass(idx, statusData.blockDetails.length);
const timeRange = `${formatTime(detail.startTime)} ${formatTime(detail.endTime)}`;
return (
<div className={`${s.statusTooltip} ${posClass}`}>
<span className={s.tooltipTime}>{timeRange}</span>
{total > 0 ? (
<span className={s.tooltipStats}>
<span className={s.tooltipSuccess}>{t('status_bar.success_short')} {detail.success}</span>
<span className={s.tooltipFailure}>{t('status_bar.failure_short')} {detail.failure}</span>
<span className={s.tooltipRate}>({(detail.rate * 100).toFixed(1)}%)</span>
</span>
) : (
<span className={s.tooltipStats}>{t('status_bar.no_requests')}</span>
)}
</div>
);
};
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 className={s.statusBar}>
<div className={s.statusBlocks} ref={blocksRef}>
{statusData.blockDetails.map((detail, idx) => {
const isIdle = detail.rate === -1;
const blockStyle = isIdle ? undefined : { backgroundColor: rateToColor(detail.rate) };
const isActive = activeTooltip === idx;
return (
<div
key={idx}
className={`${s.statusBlockWrapper} ${isActive ? s.statusBlockActive : ''}`}
onPointerEnter={(e) => handlePointerEnter(e, idx)}
onPointerLeave={handlePointerLeave}
onPointerDown={(e) => handlePointerDown(e, idx)}
>
<div
className={`${s.statusBlock} ${isIdle ? s.statusBlockIdle : ''}`}
style={blockStyle}
/>
{isActive && renderTooltip(detail, idx)}
</div>
);
})}
</div>
<span className={`${styles.statusRate} ${rateClass}`}>
<span className={`${s.statusRate} ${rateClass}`}>
{hasData ? `${statusData.successRate.toFixed(1)}%` : '--'}
</span>
</div>
+77 -8
View File
@@ -213,11 +213,14 @@ const buildCodexQuotaWindows = (payload: CodexUsagePayload, t: TFunction): Codex
const rateLimit = payload.rate_limit ?? payload.rateLimit ?? undefined;
const codeReviewLimit = payload.code_review_rate_limit ?? payload.codeReviewRateLimit ?? undefined;
const additionalRateLimits = payload.additional_rate_limits ?? payload.additionalRateLimits ?? [];
const windows: CodexQuotaWindow[] = [];
const addWindow = (
id: string,
labelKey: string,
label: string,
labelKey: string | undefined,
labelParams: Record<string, string | number> | undefined,
window?: CodexUsageWindow | null,
limitReached?: boolean,
allowed?: boolean
@@ -229,8 +232,9 @@ const buildCodexQuotaWindows = (payload: CodexUsagePayload, t: TFunction): Codex
const usedPercent = usedPercentRaw ?? (isLimitReached && resetLabel !== '-' ? 100 : null);
windows.push({
id,
label: t(labelKey),
label,
labelKey,
labelParams,
usedPercent,
resetLabel,
});
@@ -245,12 +249,13 @@ const buildCodexQuotaWindows = (payload: CodexUsagePayload, t: TFunction): Codex
const rawAllowed = rateLimit?.allowed;
const pickClassifiedWindows = (
limitInfo?: CodexRateLimitInfo | null
limitInfo?: CodexRateLimitInfo | null,
options?: { allowOrderFallback?: boolean }
): { fiveHourWindow: CodexUsageWindow | null; weeklyWindow: CodexUsageWindow | null } => {
const rawWindows = [
limitInfo?.primary_window ?? limitInfo?.primaryWindow ?? null,
limitInfo?.secondary_window ?? limitInfo?.secondaryWindow ?? null,
];
const allowOrderFallback = options?.allowOrderFallback ?? true;
const primaryWindow = limitInfo?.primary_window ?? limitInfo?.primaryWindow ?? null;
const secondaryWindow = limitInfo?.secondary_window ?? limitInfo?.secondaryWindow ?? null;
const rawWindows = [primaryWindow, secondaryWindow];
let fiveHourWindow: CodexUsageWindow | null = null;
let weeklyWindow: CodexUsageWindow | null = null;
@@ -265,20 +270,34 @@ const buildCodexQuotaWindows = (payload: CodexUsagePayload, t: TFunction): Codex
}
}
// For legacy payloads without window duration, fallback to primary/secondary ordering.
if (allowOrderFallback) {
if (!fiveHourWindow) {
fiveHourWindow = primaryWindow && primaryWindow !== weeklyWindow ? primaryWindow : null;
}
if (!weeklyWindow) {
weeklyWindow = secondaryWindow && secondaryWindow !== fiveHourWindow ? secondaryWindow : null;
}
}
return { fiveHourWindow, weeklyWindow };
};
const rateWindows = pickClassifiedWindows(rateLimit);
addWindow(
WINDOW_META.codeFiveHour.id,
t(WINDOW_META.codeFiveHour.labelKey),
WINDOW_META.codeFiveHour.labelKey,
undefined,
rateWindows.fiveHourWindow,
rawLimitReached,
rawAllowed
);
addWindow(
WINDOW_META.codeWeekly.id,
t(WINDOW_META.codeWeekly.labelKey),
WINDOW_META.codeWeekly.labelKey,
undefined,
rateWindows.weeklyWindow,
rawLimitReached,
rawAllowed
@@ -289,19 +308,67 @@ const buildCodexQuotaWindows = (payload: CodexUsagePayload, t: TFunction): Codex
const codeReviewAllowed = codeReviewLimit?.allowed;
addWindow(
WINDOW_META.codeReviewFiveHour.id,
t(WINDOW_META.codeReviewFiveHour.labelKey),
WINDOW_META.codeReviewFiveHour.labelKey,
undefined,
codeReviewWindows.fiveHourWindow,
codeReviewLimitReached,
codeReviewAllowed
);
addWindow(
WINDOW_META.codeReviewWeekly.id,
t(WINDOW_META.codeReviewWeekly.labelKey),
WINDOW_META.codeReviewWeekly.labelKey,
undefined,
codeReviewWindows.weeklyWindow,
codeReviewLimitReached,
codeReviewAllowed
);
const normalizeWindowId = (raw: string) =>
raw
.trim()
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '');
if (Array.isArray(additionalRateLimits)) {
additionalRateLimits.forEach((limitItem, index) => {
const rateInfo = limitItem?.rate_limit ?? limitItem?.rateLimit ?? null;
if (!rateInfo) return;
const limitName =
normalizeStringValue(limitItem?.limit_name ?? limitItem?.limitName) ??
normalizeStringValue(limitItem?.metered_feature ?? limitItem?.meteredFeature) ??
`additional-${index + 1}`;
const idPrefix = normalizeWindowId(limitName) || `additional-${index + 1}`;
const additionalPrimaryWindow = rateInfo.primary_window ?? rateInfo.primaryWindow ?? null;
const additionalSecondaryWindow = rateInfo.secondary_window ?? rateInfo.secondaryWindow ?? null;
const additionalLimitReached = rateInfo.limit_reached ?? rateInfo.limitReached;
const additionalAllowed = rateInfo.allowed;
addWindow(
`${idPrefix}-five-hour-${index}`,
t('codex_quota.additional_primary_window', { name: limitName }),
'codex_quota.additional_primary_window',
{ name: limitName },
additionalPrimaryWindow,
additionalLimitReached,
additionalAllowed
);
addWindow(
`${idPrefix}-weekly-${index}`,
t('codex_quota.additional_secondary_window', { name: limitName }),
'codex_quota.additional_secondary_window',
{ name: limitName },
additionalSecondaryWindow,
additionalLimitReached,
additionalAllowed
);
});
}
return windows;
};
@@ -493,7 +560,9 @@ const renderCodexItems = (
const clampedUsed = used === null ? null : Math.max(0, Math.min(100, used));
const remaining = clampedUsed === null ? null : Math.max(0, Math.min(100, 100 - clampedUsed));
const percentLabel = remaining === null ? '--' : `${Math.round(remaining)}%`;
const windowLabel = window.labelKey ? t(window.labelKey) : window.label;
const windowLabel = window.labelKey
? t(window.labelKey, window.labelParams as Record<string, string | number>)
: window.label;
return h(
'div',
+110
View File
@@ -0,0 +1,110 @@
@use '../../styles/mixins' as *;
.wrap {
position: relative;
display: inline-flex;
align-items: center;
}
.wrapFullWidth {
width: 100%;
}
.trigger {
display: inline-flex;
align-items: center;
justify-content: space-between;
gap: 8px;
width: 100%;
height: 40px;
padding: 0 12px;
border: 1px solid var(--border-color);
border-radius: $radius-md;
background-color: var(--bg-primary);
box-shadow: var(--shadow);
color: var(--text-primary);
font-size: 13px;
font-weight: 500;
cursor: pointer;
appearance: none;
text-align: left;
box-sizing: border-box;
&:hover {
border-color: var(--border-hover);
}
&:focus {
outline: none;
box-shadow: var(--shadow), 0 0 0 3px rgba($primary-color, 0.18);
}
&[aria-expanded='true'] {
border-color: var(--primary-color);
box-shadow: var(--shadow), 0 0 0 3px rgba($primary-color, 0.18);
}
}
.triggerText {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.placeholder {
color: var(--text-tertiary);
}
.triggerIcon {
display: inline-flex;
color: var(--text-secondary);
flex-shrink: 0;
transition: transform 0.2s ease;
[aria-expanded='true'] > & {
transform: rotate(180deg);
}
}
.dropdown {
position: absolute;
top: calc(100% + 6px);
left: 0;
right: 0;
z-index: 1000;
background: var(--bg-primary);
border: 1px solid var(--border-color);
border-radius: $radius-lg;
padding: 6px;
box-shadow: var(--shadow-lg);
display: flex;
flex-direction: column;
gap: 4px;
max-height: 240px;
overflow-y: auto;
overscroll-behavior: contain;
}
.option {
padding: 8px 12px;
border-radius: $radius-md;
border: 1px solid transparent;
background: transparent;
color: var(--text-primary);
cursor: pointer;
text-align: left;
font-size: 13px;
font-weight: 500;
transition: background-color 0.15s ease, border-color 0.15s ease;
flex-shrink: 0;
&:hover {
background: var(--bg-secondary);
}
}
.optionActive {
border-color: rgba($primary-color, 0.5);
background: rgba($primary-color, 0.1);
font-weight: 600;
}
+94
View File
@@ -0,0 +1,94 @@
import { useState, useEffect, useRef } from 'react';
import { IconChevronDown } from './icons';
import styles from './Select.module.scss';
export interface SelectOption {
value: string;
label: string;
}
interface SelectProps {
value: string;
options: ReadonlyArray<SelectOption>;
onChange: (value: string) => void;
placeholder?: string;
className?: string;
disabled?: boolean;
ariaLabel?: string;
fullWidth?: boolean;
}
export function Select({
value,
options,
onChange,
placeholder,
className,
disabled = false,
ariaLabel,
fullWidth = true
}: SelectProps) {
const [open, setOpen] = useState(false);
const wrapRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
if (!open || disabled) return;
const handleClickOutside = (event: MouseEvent) => {
if (!wrapRef.current?.contains(event.target as Node)) setOpen(false);
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, [disabled, open]);
const isOpen = open && !disabled;
const selected = options.find((o) => o.value === value);
const displayText = selected?.label ?? placeholder ?? '';
const isPlaceholder = !selected && placeholder;
return (
<div
className={`${styles.wrap} ${fullWidth ? styles.wrapFullWidth : ''} ${className ?? ''}`}
ref={wrapRef}
>
<button
type="button"
className={styles.trigger}
onClick={disabled ? undefined : () => setOpen((prev) => !prev)}
aria-haspopup="listbox"
aria-expanded={isOpen}
aria-label={ariaLabel}
disabled={disabled}
>
<span className={`${styles.triggerText} ${isPlaceholder ? styles.placeholder : ''}`}>
{displayText}
</span>
<span className={styles.triggerIcon} aria-hidden="true">
<IconChevronDown size={14} />
</span>
</button>
{isOpen && (
<div className={styles.dropdown} role="listbox" aria-label={ariaLabel}>
{options.map((opt) => {
const active = opt.value === value;
return (
<button
key={opt.value}
type="button"
role="option"
aria-selected={active}
className={`${styles.option} ${active ? styles.optionActive : ''}`}
onClick={() => {
onChange(opt.value);
setOpen(false);
}}
>
{opt.label}
</button>
);
})}
</div>
)}
</div>
);
}
+14 -11
View File
@@ -1,6 +1,8 @@
import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { Card } from '@/components/ui/Card';
import { Button } from '@/components/ui/Button';
import { Select } from '@/components/ui/Select';
import styles from '@/pages/UsagePage.module.scss';
export interface ChartLineSelectorProps {
@@ -41,6 +43,14 @@ export function ChartLineSelector({
onChange(newLines);
};
const options = useMemo(
() => [
{ value: 'all', label: t('usage_stats.chart_line_all') },
...modelNames.map((name) => ({ value: name, label: name }))
],
[modelNames, t]
);
return (
<Card
title={t('usage_stats.chart_line_actions_label')}
@@ -66,18 +76,11 @@ export function ChartLineSelector({
<span className={styles.chartLineLabel}>
{t(`usage_stats.chart_line_label_${index + 1}`)}
</span>
<select
<Select
value={line}
onChange={(e) => handleChange(index, e.target.value)}
className={styles.select}
>
<option value="all">{t('usage_stats.chart_line_all')}</option>
{modelNames.map((name) => (
<option key={name} value={name}>
{name}
</option>
))}
</select>
options={options}
onChange={(value) => handleChange(index, value)}
/>
{chartLines.length > 1 && (
<Button variant="danger" size="sm" onClick={() => handleRemove(index)}>
{t('usage_stats.chart_line_delete')}
+3 -1
View File
@@ -277,10 +277,11 @@ export function CredentialStatsCard({
}, [usage, geminiKeys, claudeConfigs, codexConfigs, vertexConfigs, openaiProviders, authFileMap]);
return (
<Card title={t('usage_stats.credential_stats')}>
<Card title={t('usage_stats.credential_stats')} className={styles.detailsFixedCard}>
{loading ? (
<div className={styles.hint}>{t('common.loading')}</div>
) : rows.length > 0 ? (
<div className={styles.detailsScroll}>
<div className={styles.tableWrapper}>
<table className={styles.table}>
<thead>
@@ -326,6 +327,7 @@ export function CredentialStatsCard({
</tbody>
</table>
</div>
</div>
) : (
<div className={styles.hint}>{t('usage_stats.no_data')}</div>
)}
+90 -18
View File
@@ -1,8 +1,10 @@
import { useState } from 'react';
import { useState, useMemo } 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 { Modal } from '@/components/ui/Modal';
import { Select } from '@/components/ui/Select';
import type { ModelPrice } from '@/utils/usage';
import styles from '@/pages/UsagePage.module.scss';
@@ -19,11 +21,18 @@ export function PriceSettingsCard({
}: PriceSettingsCardProps) {
const { t } = useTranslation();
// Add form state
const [selectedModel, setSelectedModel] = useState('');
const [promptPrice, setPromptPrice] = useState('');
const [completionPrice, setCompletionPrice] = useState('');
const [cachePrice, setCachePrice] = useState('');
// Edit modal state
const [editModel, setEditModel] = useState<string | null>(null);
const [editPrompt, setEditPrompt] = useState('');
const [editCompletion, setEditCompletion] = useState('');
const [editCache, setEditCache] = useState('');
const handleSavePrice = () => {
if (!selectedModel) return;
const prompt = parseFloat(promptPrice) || 0;
@@ -43,12 +52,22 @@ export function PriceSettingsCard({
onPricesChange(newPrices);
};
const handleEditPrice = (model: string) => {
const handleOpenEdit = (model: string) => {
const price = modelPrices[model];
setSelectedModel(model);
setPromptPrice(price?.prompt?.toString() || '');
setCompletionPrice(price?.completion?.toString() || '');
setCachePrice(price?.cache?.toString() || '');
setEditModel(model);
setEditPrompt(price?.prompt?.toString() || '');
setEditCompletion(price?.completion?.toString() || '');
setEditCache(price?.cache?.toString() || '');
};
const handleSaveEdit = () => {
if (!editModel) return;
const prompt = parseFloat(editPrompt) || 0;
const completion = parseFloat(editCompletion) || 0;
const cache = editCache.trim() === '' ? prompt : parseFloat(editCache) || 0;
const newPrices = { ...modelPrices, [editModel]: { prompt, completion, cache } };
onPricesChange(newPrices);
setEditModel(null);
};
const handleModelSelect = (value: string) => {
@@ -65,6 +84,14 @@ export function PriceSettingsCard({
}
};
const options = useMemo(
() => [
{ value: '', label: t('usage_stats.model_price_select_placeholder') },
...modelNames.map((name) => ({ value: name, label: name }))
],
[modelNames, t]
);
return (
<Card title={t('usage_stats.model_price_settings')}>
<div className={styles.pricingSection}>
@@ -73,18 +100,12 @@ export function PriceSettingsCard({
<div className={styles.formRow}>
<div className={styles.formField}>
<label>{t('usage_stats.model_name')}</label>
<select
<Select
value={selectedModel}
onChange={(e) => handleModelSelect(e.target.value)}
className={styles.select}
>
<option value="">{t('usage_stats.model_price_select_placeholder')}</option>
{modelNames.map((name) => (
<option key={name} value={name}>
{name}
</option>
))}
</select>
options={options}
onChange={handleModelSelect}
placeholder={t('usage_stats.model_price_select_placeholder')}
/>
</div>
<div className={styles.formField}>
<label>{t('usage_stats.model_price_prompt')} ($/1M)</label>
@@ -144,7 +165,7 @@ export function PriceSettingsCard({
</div>
</div>
<div className={styles.priceActions}>
<Button variant="secondary" size="sm" onClick={() => handleEditPrice(model)}>
<Button variant="secondary" size="sm" onClick={() => handleOpenEdit(model)}>
{t('common.edit')}
</Button>
<Button variant="danger" size="sm" onClick={() => handleDeletePrice(model)}>
@@ -159,6 +180,57 @@ export function PriceSettingsCard({
)}
</div>
</div>
{/* Edit Modal */}
<Modal
open={editModel !== null}
title={editModel ?? ''}
onClose={() => setEditModel(null)}
footer={
<div className={styles.priceActions}>
<Button variant="secondary" onClick={() => setEditModel(null)}>
{t('common.cancel')}
</Button>
<Button variant="primary" onClick={handleSaveEdit}>
{t('common.save')}
</Button>
</div>
}
width={420}
>
<div className={styles.editModalBody}>
<div className={styles.formField}>
<label>{t('usage_stats.model_price_prompt')} ($/1M)</label>
<Input
type="number"
value={editPrompt}
onChange={(e) => setEditPrompt(e.target.value)}
placeholder="0.00"
step="0.0001"
/>
</div>
<div className={styles.formField}>
<label>{t('usage_stats.model_price_completion')} ($/1M)</label>
<Input
type="number"
value={editCompletion}
onChange={(e) => setEditCompletion(e.target.value)}
placeholder="0.00"
step="0.0001"
/>
</div>
<div className={styles.formField}>
<label>{t('usage_stats.model_price_cache')} ($/1M)</label>
<Input
type="number"
value={editCache}
onChange={(e) => setEditCache(e.target.value)}
placeholder="0.00"
step="0.0001"
/>
</div>
</div>
</Modal>
</Card>
);
}
+180
View File
@@ -0,0 +1,180 @@
import { useState, useCallback, useRef, useEffect, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import {
collectUsageDetails,
calculateServiceHealthData,
type ServiceHealthData,
type StatusBlockDetail,
} from '@/utils/usage';
import type { UsagePayload } from './hooks/useUsageData';
import styles from '@/pages/UsagePage.module.scss';
const COLOR_STOPS = [
{ r: 239, g: 68, b: 68 }, // #ef4444
{ r: 250, g: 204, b: 21 }, // #facc15
{ r: 34, g: 197, b: 94 }, // #22c55e
] as const;
function rateToColor(rate: number): string {
const t = Math.max(0, Math.min(1, rate));
const segment = t < 0.5 ? 0 : 1;
const localT = segment === 0 ? t * 2 : (t - 0.5) * 2;
const from = COLOR_STOPS[segment];
const to = COLOR_STOPS[segment + 1];
const r = Math.round(from.r + (to.r - from.r) * localT);
const g = Math.round(from.g + (to.g - from.g) * localT);
const b = Math.round(from.b + (to.b - from.b) * localT);
return `rgb(${r}, ${g}, ${b})`;
}
function formatDateTime(timestamp: number): string {
const date = new Date(timestamp);
const month = (date.getMonth() + 1).toString().padStart(2, '0');
const day = date.getDate().toString().padStart(2, '0');
const h = date.getHours().toString().padStart(2, '0');
const m = date.getMinutes().toString().padStart(2, '0');
return `${month}/${day} ${h}:${m}`;
}
export interface ServiceHealthCardProps {
usage: UsagePayload | null;
loading: boolean;
}
export function ServiceHealthCard({ usage, loading }: ServiceHealthCardProps) {
const { t } = useTranslation();
const [activeTooltip, setActiveTooltip] = useState<number | null>(null);
const gridRef = useRef<HTMLDivElement>(null);
const healthData: ServiceHealthData = useMemo(() => {
const details = usage ? collectUsageDetails(usage) : [];
return calculateServiceHealthData(details);
}, [usage]);
const hasData = healthData.totalSuccess + healthData.totalFailure > 0;
useEffect(() => {
if (activeTooltip === null) return;
const handler = (e: PointerEvent) => {
if (gridRef.current && !gridRef.current.contains(e.target as Node)) {
setActiveTooltip(null);
}
};
document.addEventListener('pointerdown', handler);
return () => document.removeEventListener('pointerdown', handler);
}, [activeTooltip]);
const handlePointerEnter = useCallback((e: React.PointerEvent, idx: number) => {
if (e.pointerType === 'mouse') {
setActiveTooltip(idx);
}
}, []);
const handlePointerLeave = useCallback((e: React.PointerEvent) => {
if (e.pointerType === 'mouse') {
setActiveTooltip(null);
}
}, []);
const handlePointerDown = useCallback((e: React.PointerEvent, idx: number) => {
if (e.pointerType === 'touch') {
e.preventDefault();
setActiveTooltip((prev) => (prev === idx ? null : idx));
}
}, []);
const getTooltipPositionClass = (idx: number): string => {
const col = Math.floor(idx / healthData.rows);
if (col <= 2) return styles.healthTooltipLeft;
if (col >= healthData.cols - 3) return styles.healthTooltipRight;
return '';
};
const getTooltipVerticalClass = (idx: number): string => {
const row = idx % healthData.rows;
if (row <= 1) return styles.healthTooltipBelow;
return '';
};
const renderTooltip = (detail: StatusBlockDetail, idx: number) => {
const total = detail.success + detail.failure;
const posClass = getTooltipPositionClass(idx);
const vertClass = getTooltipVerticalClass(idx);
const timeRange = `${formatDateTime(detail.startTime)} ${formatDateTime(detail.endTime)}`;
return (
<div className={`${styles.healthTooltip} ${posClass} ${vertClass}`}>
<span className={styles.healthTooltipTime}>{timeRange}</span>
{total > 0 ? (
<span className={styles.healthTooltipStats}>
<span className={styles.healthTooltipSuccess}>{t('status_bar.success_short')} {detail.success}</span>
<span className={styles.healthTooltipFailure}>{t('status_bar.failure_short')} {detail.failure}</span>
<span className={styles.healthTooltipRate}>({(detail.rate * 100).toFixed(1)}%)</span>
</span>
) : (
<span className={styles.healthTooltipStats}>{t('status_bar.no_requests')}</span>
)}
</div>
);
};
const rateClass = !hasData
? ''
: healthData.successRate >= 90
? styles.healthRateHigh
: healthData.successRate >= 50
? styles.healthRateMedium
: styles.healthRateLow;
return (
<div className={styles.healthCard}>
<div className={styles.healthHeader}>
<h3 className={styles.healthTitle}>{t('service_health.title')}</h3>
<div className={styles.healthMeta}>
<span className={styles.healthWindow}>{t('service_health.window')}</span>
<span className={`${styles.healthRate} ${rateClass}`}>
{loading ? '--' : hasData ? `${healthData.successRate.toFixed(1)}%` : '--'}
</span>
</div>
</div>
<div className={styles.healthGridScroller}>
<div
className={styles.healthGrid}
ref={gridRef}
>
{healthData.blockDetails.map((detail, idx) => {
const isIdle = detail.rate === -1;
const blockStyle = isIdle ? undefined : { backgroundColor: rateToColor(detail.rate) };
const isActive = activeTooltip === idx;
return (
<div
key={idx}
className={`${styles.healthBlockWrapper} ${isActive ? styles.healthBlockActive : ''}`}
onPointerEnter={(e) => handlePointerEnter(e, idx)}
onPointerLeave={handlePointerLeave}
onPointerDown={(e) => handlePointerDown(e, idx)}
>
<div
className={`${styles.healthBlock} ${isIdle ? styles.healthBlockIdle : ''}`}
style={blockStyle}
/>
{isActive && renderTooltip(detail, idx)}
</div>
);
})}
</div>
</div>
<div className={styles.healthLegend}>
<span className={styles.healthLegendLabel}>{t('service_health.oldest')}</span>
<div className={styles.healthLegendColors}>
<div className={`${styles.healthLegendBlock} ${styles.healthBlockIdle}`} />
<div className={styles.healthLegendBlock} style={{ backgroundColor: '#ef4444' }} />
<div className={styles.healthLegendBlock} style={{ backgroundColor: '#facc15' }} />
<div className={styles.healthLegendBlock} style={{ backgroundColor: '#22c55e' }} />
</div>
<span className={styles.healthLegendLabel}>{t('service_health.newest')}</span>
</div>
</div>
);
}
+4 -4
View File
@@ -56,9 +56,9 @@ export function StatCards({ usage, loading, modelPrices, sparklines }: StatCards
key: 'requests',
label: t('usage_stats.total_requests'),
icon: <IconSatellite size={16} />,
accent: '#3b82f6',
accentSoft: 'rgba(59, 130, 246, 0.18)',
accentBorder: 'rgba(59, 130, 246, 0.35)',
accent: '#8b8680',
accentSoft: 'rgba(139, 134, 128, 0.18)',
accentBorder: 'rgba(139, 134, 128, 0.35)',
value: loading ? '-' : (usage?.total_requests ?? 0).toLocaleString(),
meta: (
<>
@@ -67,7 +67,7 @@ export function StatCards({ usage, loading, modelPrices, sparklines }: StatCards
{t('usage_stats.success_requests')}: {loading ? '-' : (usage?.success_count ?? 0)}
</span>
<span className={styles.statMetaItem}>
<span className={styles.statMetaDot} style={{ backgroundColor: '#ef4444' }} />
<span className={styles.statMetaDot} style={{ backgroundColor: '#c65746' }} />
{t('usage_stats.failed_requests')}: {loading ? '-' : (usage?.failure_count ?? 0)}
</span>
</>
+1 -1
View File
@@ -13,7 +13,7 @@ import type { UsagePayload } from './hooks/useUsageData';
import styles from '@/pages/UsagePage.module.scss';
const TOKEN_COLORS: Record<TokenCategory, { border: string; bg: string }> = {
input: { border: '#3b82f6', bg: 'rgba(59, 130, 246, 0.25)' },
input: { border: '#8b8680', bg: 'rgba(139, 134, 128, 0.25)' },
output: { border: '#22c55e', bg: 'rgba(34, 197, 94, 0.25)' },
cached: { border: '#f59e0b', bg: 'rgba(245, 158, 11, 0.25)' },
reasoning: { border: '#8b5cf6', bg: 'rgba(139, 92, 246, 0.25)' }
+1 -1
View File
@@ -104,7 +104,7 @@ export function useSparklines({ usage, loading }: UseSparklinesOptions): UseSpar
);
const requestsSparkline = useMemo(
() => buildSparkline(buildLastHourSeries('requests'), '#3b82f6', 'rgba(59, 130, 246, 0.18)'),
() => buildSparkline(buildLastHourSeries('requests'), '#8b8680', 'rgba(139, 134, 128, 0.18)'),
[buildLastHourSeries, buildSparkline]
);
+3
View File
@@ -35,3 +35,6 @@ export type { TokenBreakdownChartProps } from './TokenBreakdownChart';
export { CostTrendChart } from './CostTrendChart';
export type { CostTrendChartProps } from './CostTrendChart';
export { ServiceHealthCard } from './ServiceHealthCard';
export type { ServiceHealthCardProps } from './ServiceHealthCard';
@@ -0,0 +1,216 @@
import { useTranslation } from 'react-i18next';
import { Button } from '@/components/ui/Button';
import { LoadingSpinner } from '@/components/ui/LoadingSpinner';
import { ToggleSwitch } from '@/components/ui/ToggleSwitch';
import { IconBot, IconCode, IconDownload, IconInfo, IconTrash2 } from '@/components/ui/icons';
import { ProviderStatusBar } from '@/components/providers/ProviderStatusBar';
import type { AuthFileItem } from '@/types';
import { resolveAuthProvider } from '@/utils/quota';
import { calculateStatusBarData, type KeyStats } from '@/utils/usage';
import { formatFileSize } from '@/utils/format';
import {
QUOTA_PROVIDER_TYPES,
formatModified,
getTypeColor,
getTypeLabel,
isRuntimeOnlyAuthFile,
normalizeAuthIndexValue,
resolveAuthFileStats,
type QuotaProviderType,
type ResolvedTheme
} from '@/features/authFiles/constants';
import type { AuthFileStatusBarData } from '@/features/authFiles/hooks/useAuthFilesStatusBarCache';
import { AuthFileQuotaSection } from '@/features/authFiles/components/AuthFileQuotaSection';
import styles from '@/pages/AuthFilesPage.module.scss';
export type AuthFileCardProps = {
file: AuthFileItem;
resolvedTheme: ResolvedTheme;
disableControls: boolean;
deleting: string | null;
statusUpdating: Record<string, boolean>;
quotaFilterType: QuotaProviderType | null;
keyStats: KeyStats;
statusBarCache: Map<string, AuthFileStatusBarData>;
onShowModels: (file: AuthFileItem) => void;
onShowDetails: (file: AuthFileItem) => void;
onDownload: (name: string) => void;
onOpenPrefixProxyEditor: (name: string) => void;
onDelete: (name: string) => void;
onToggleStatus: (file: AuthFileItem, enabled: boolean) => void;
};
const resolveQuotaType = (file: AuthFileItem): QuotaProviderType | null => {
const provider = resolveAuthProvider(file);
if (!QUOTA_PROVIDER_TYPES.has(provider as QuotaProviderType)) return null;
return provider as QuotaProviderType;
};
export function AuthFileCard(props: AuthFileCardProps) {
const { t } = useTranslation();
const {
file,
resolvedTheme,
disableControls,
deleting,
statusUpdating,
quotaFilterType,
keyStats,
statusBarCache,
onShowModels,
onShowDetails,
onDownload,
onOpenPrefixProxyEditor,
onDelete,
onToggleStatus
} = props;
const fileStats = resolveAuthFileStats(file, keyStats);
const isRuntimeOnly = isRuntimeOnlyAuthFile(file);
const isAistudio = (file.type || '').toLowerCase() === 'aistudio';
const showModelsButton = !isRuntimeOnly || isAistudio;
const typeColor = getTypeColor(file.type || 'unknown', resolvedTheme);
const quotaType =
quotaFilterType && resolveQuotaType(file) === quotaFilterType ? quotaFilterType : null;
const showQuotaLayout = Boolean(quotaType) && !isRuntimeOnly;
const providerCardClass =
quotaType === 'antigravity'
? styles.antigravityCard
: quotaType === 'codex'
? styles.codexCard
: quotaType === 'gemini-cli'
? styles.geminiCliCard
: '';
const rawAuthIndex = file['auth_index'] ?? file.authIndex;
const authIndexKey = normalizeAuthIndexValue(rawAuthIndex);
const statusData =
(authIndexKey && statusBarCache.get(authIndexKey)) || calculateStatusBarData([]);
return (
<div
className={`${styles.fileCard} ${providerCardClass} ${file.disabled ? styles.fileCardDisabled : ''}`}
>
<div className={styles.fileCardLayout}>
<div className={styles.fileCardMain}>
<div className={styles.cardHeader}>
<span
className={styles.typeBadge}
style={{
backgroundColor: typeColor.bg,
color: typeColor.text,
...(typeColor.border ? { border: typeColor.border } : {})
}}
>
{getTypeLabel(t, file.type || 'unknown')}
</span>
<span className={styles.fileName}>{file.name}</span>
</div>
<div className={styles.cardMeta}>
<span>
{t('auth_files.file_size')}: {file.size ? formatFileSize(file.size) : '-'}
</span>
<span>
{t('auth_files.file_modified')}: {formatModified(file)}
</span>
</div>
<div className={styles.cardStats}>
<span className={`${styles.statPill} ${styles.statSuccess}`}>
{t('stats.success')}: {fileStats.success}
</span>
<span className={`${styles.statPill} ${styles.statFailure}`}>
{t('stats.failure')}: {fileStats.failure}
</span>
</div>
<ProviderStatusBar statusData={statusData} styles={styles} />
{showQuotaLayout && quotaType && (
<AuthFileQuotaSection file={file} quotaType={quotaType} disableControls={disableControls} />
)}
<div className={styles.cardActions}>
{showModelsButton && (
<Button
variant="secondary"
size="sm"
onClick={() => onShowModels(file)}
className={styles.iconButton}
title={t('auth_files.models_button', { defaultValue: '模型' })}
disabled={disableControls}
>
<IconBot className={styles.actionIcon} size={16} />
</Button>
)}
{!isRuntimeOnly && (
<>
<Button
variant="secondary"
size="sm"
onClick={() => onShowDetails(file)}
className={styles.iconButton}
title={t('common.info', { defaultValue: '关于' })}
disabled={disableControls}
>
<IconInfo className={styles.actionIcon} size={16} />
</Button>
<Button
variant="secondary"
size="sm"
onClick={() => onDownload(file.name)}
className={styles.iconButton}
title={t('auth_files.download_button')}
disabled={disableControls}
>
<IconDownload className={styles.actionIcon} size={16} />
</Button>
<Button
variant="secondary"
size="sm"
onClick={() => onOpenPrefixProxyEditor(file.name)}
className={styles.iconButton}
title={t('auth_files.prefix_proxy_button')}
disabled={disableControls}
>
<IconCode className={styles.actionIcon} size={16} />
</Button>
<Button
variant="danger"
size="sm"
onClick={() => onDelete(file.name)}
className={styles.iconButton}
title={t('auth_files.delete_button')}
disabled={disableControls || deleting === file.name}
>
{deleting === file.name ? (
<LoadingSpinner size={14} />
) : (
<IconTrash2 className={styles.actionIcon} size={16} />
)}
</Button>
</>
)}
{!isRuntimeOnly && (
<div className={styles.statusToggle}>
<ToggleSwitch
ariaLabel={t('auth_files.status_toggle_label')}
checked={!file.disabled}
disabled={disableControls || statusUpdating[file.name] === true}
onChange={(value) => onToggleStatus(file, value)}
/>
</div>
)}
{isRuntimeOnly && (
<div className={styles.virtualBadge}>{t('auth_files.type_virtual') || '虚拟认证文件'}</div>
)}
</div>
</div>
</div>
</div>
);
}
@@ -0,0 +1,47 @@
import { useTranslation } from 'react-i18next';
import { Modal } from '@/components/ui/Modal';
import { Button } from '@/components/ui/Button';
import type { AuthFileItem } from '@/types';
import styles from '@/pages/AuthFilesPage.module.scss';
export type AuthFileDetailModalProps = {
open: boolean;
file: AuthFileItem | null;
onClose: () => void;
onCopyText: (text: string) => void;
};
export function AuthFileDetailModal({ open, file, onClose, onCopyText }: AuthFileDetailModalProps) {
const { t } = useTranslation();
return (
<Modal
open={open}
onClose={onClose}
title={file?.name || t('auth_files.title_section')}
footer={
<>
<Button variant="secondary" onClick={onClose}>
{t('common.close')}
</Button>
<Button
onClick={() => {
if (!file) return;
const text = JSON.stringify(file, null, 2);
onCopyText(text);
}}
>
{t('common.copy')}
</Button>
</>
}
>
{file && (
<div className={styles.detailContent}>
<pre className={styles.jsonContent}>{JSON.stringify(file, null, 2)}</pre>
</div>
)}
</Modal>
);
}
@@ -0,0 +1,91 @@
import { useTranslation } from 'react-i18next';
import { Modal } from '@/components/ui/Modal';
import { Button } from '@/components/ui/Button';
import { EmptyState } from '@/components/ui/EmptyState';
import type { AuthFileModelItem } from '@/features/authFiles/constants';
import { isModelExcluded } from '@/features/authFiles/constants';
import styles from '@/pages/AuthFilesPage.module.scss';
export type AuthFileModelsModalProps = {
open: boolean;
fileName: string;
fileType: string;
loading: boolean;
error: 'unsupported' | null;
models: AuthFileModelItem[];
excluded: Record<string, string[]>;
onClose: () => void;
onCopyText: (text: string) => void;
};
export function AuthFileModelsModal(props: AuthFileModelsModalProps) {
const { t } = useTranslation();
const { open, fileName, fileType, loading, error, models, excluded, onClose, onCopyText } = props;
return (
<Modal
open={open}
onClose={onClose}
title={t('auth_files.models_title', { defaultValue: '支持的模型' }) + ` - ${fileName}`}
footer={
<Button variant="secondary" onClick={onClose}>
{t('common.close')}
</Button>
}
>
{loading ? (
<div className={styles.hint}>
{t('auth_files.models_loading', { defaultValue: '正在加载模型列表...' })}
</div>
) : error === 'unsupported' ? (
<EmptyState
title={t('auth_files.models_unsupported', { defaultValue: '当前版本不支持此功能' })}
description={t('auth_files.models_unsupported_desc', {
defaultValue: '请更新 CLI Proxy API 到最新版本后重试'
})}
/>
) : models.length === 0 ? (
<EmptyState
title={t('auth_files.models_empty', { defaultValue: '该凭证暂无可用模型' })}
description={t('auth_files.models_empty_desc', {
defaultValue: '该认证凭证可能尚未被服务器加载或没有绑定任何模型'
})}
/>
) : (
<div className={styles.modelsList}>
{models.map((model) => {
const excludedModel = isModelExcluded(model.id, fileType, excluded);
return (
<div
key={model.id}
className={`${styles.modelItem} ${excludedModel ? styles.modelItemExcluded : ''}`}
onClick={() => {
onCopyText(model.id);
}}
title={
excludedModel
? t('auth_files.models_excluded_hint', {
defaultValue: '此 OAuth 模型已被禁用'
})
: t('common.copy', { defaultValue: '点击复制' })
}
>
<span className={styles.modelId}>{model.id}</span>
{model.display_name && model.display_name !== model.id && (
<span className={styles.modelDisplayName}>{model.display_name}</span>
)}
{model.type && <span className={styles.modelType}>{model.type}</span>}
{excludedModel && (
<span className={styles.modelExcludedBadge}>
{t('auth_files.models_excluded_badge', { defaultValue: '已禁用' })}
</span>
)}
</div>
);
})}
</div>
)}
</Modal>
);
}
@@ -0,0 +1,124 @@
import { useCallback, type ReactNode } from 'react';
import { useTranslation } from 'react-i18next';
import type { TFunction } from 'i18next';
import { ANTIGRAVITY_CONFIG, CODEX_CONFIG, GEMINI_CLI_CONFIG } from '@/components/quota';
import { useNotificationStore, useQuotaStore } from '@/stores';
import type { AuthFileItem } from '@/types';
import { getStatusFromError } from '@/utils/quota';
import {
isRuntimeOnlyAuthFile,
resolveQuotaErrorMessage,
type QuotaProviderType
} from '@/features/authFiles/constants';
import { QuotaProgressBar } from '@/features/authFiles/components/QuotaProgressBar';
import styles from '@/pages/AuthFilesPage.module.scss';
type QuotaState = { status?: string; error?: string; errorStatus?: number } | undefined;
const getQuotaConfig = (type: QuotaProviderType) => {
if (type === 'antigravity') return ANTIGRAVITY_CONFIG;
if (type === 'codex') return CODEX_CONFIG;
return GEMINI_CLI_CONFIG;
};
export type AuthFileQuotaSectionProps = {
file: AuthFileItem;
quotaType: QuotaProviderType;
disableControls: boolean;
};
export function AuthFileQuotaSection(props: AuthFileQuotaSectionProps) {
const { file, quotaType, disableControls } = props;
const { t } = useTranslation();
const showNotification = useNotificationStore((state) => state.showNotification);
const quota = useQuotaStore((state) => {
if (quotaType === 'antigravity') return state.antigravityQuota[file.name] as QuotaState;
if (quotaType === 'codex') return state.codexQuota[file.name] as QuotaState;
return state.geminiCliQuota[file.name] as QuotaState;
});
const updateQuotaState = useQuotaStore((state) => {
if (quotaType === 'antigravity') return state.setAntigravityQuota as unknown as (updater: unknown) => void;
if (quotaType === 'codex') return state.setCodexQuota as unknown as (updater: unknown) => void;
return state.setGeminiCliQuota as unknown as (updater: unknown) => void;
});
const refreshQuotaForFile = useCallback(async () => {
if (disableControls) return;
if (isRuntimeOnlyAuthFile(file)) return;
if (file.disabled) return;
if (quota?.status === 'loading') return;
const config = getQuotaConfig(quotaType) as unknown as {
i18nPrefix: string;
fetchQuota: (file: AuthFileItem, t: TFunction) => Promise<unknown>;
buildLoadingState: () => unknown;
buildSuccessState: (data: unknown) => unknown;
buildErrorState: (message: string, status?: number) => unknown;
renderQuotaItems: (quota: unknown, t: TFunction, helpers: unknown) => unknown;
};
updateQuotaState((prev: Record<string, unknown>) => ({
...prev,
[file.name]: config.buildLoadingState()
}));
try {
const data = await config.fetchQuota(file, t);
updateQuotaState((prev: Record<string, unknown>) => ({
...prev,
[file.name]: config.buildSuccessState(data)
}));
showNotification(t('auth_files.quota_refresh_success', { name: file.name }), 'success');
} catch (err: unknown) {
const message = err instanceof Error ? err.message : t('common.unknown_error');
const status = getStatusFromError(err);
updateQuotaState((prev: Record<string, unknown>) => ({
...prev,
[file.name]: config.buildErrorState(message, status)
}));
showNotification(t('auth_files.quota_refresh_failed', { name: file.name, message }), 'error');
}
}, [disableControls, file, quota?.status, quotaType, showNotification, t, updateQuotaState]);
const config = getQuotaConfig(quotaType) as unknown as {
i18nPrefix: string;
renderQuotaItems: (quota: unknown, t: TFunction, helpers: unknown) => unknown;
};
const quotaStatus = quota?.status ?? 'idle';
const canRefreshQuota = !disableControls && !file.disabled;
const quotaErrorMessage = resolveQuotaErrorMessage(
t,
quota?.errorStatus,
quota?.error || t('common.unknown_error')
);
return (
<div className={styles.quotaSection}>
{quotaStatus === 'loading' ? (
<div className={styles.quotaMessage}>{t(`${config.i18nPrefix}.loading`)}</div>
) : quotaStatus === 'idle' ? (
<button
type="button"
className={`${styles.quotaMessage} ${styles.quotaMessageAction}`}
onClick={() => void refreshQuotaForFile()}
disabled={!canRefreshQuota}
>
{t(`${config.i18nPrefix}.idle`)}
</button>
) : quotaStatus === 'error' ? (
<div className={styles.quotaError}>
{t(`${config.i18nPrefix}.load_failed`, {
message: quotaErrorMessage
})}
</div>
) : quota ? (
(config.renderQuotaItems(quota, t, { styles, QuotaProgressBar }) as ReactNode)
) : (
<div className={styles.quotaMessage}>{t(`${config.i18nPrefix}.idle`)}</div>
)}
</div>
);
}
@@ -0,0 +1,125 @@
import { useTranslation } from 'react-i18next';
import { Modal } from '@/components/ui/Modal';
import { Button } from '@/components/ui/Button';
import { LoadingSpinner } from '@/components/ui/LoadingSpinner';
import { Input } from '@/components/ui/Input';
import type {
PrefixProxyEditorField,
PrefixProxyEditorState
} from '@/features/authFiles/hooks/useAuthFilesPrefixProxyEditor';
import styles from '@/pages/AuthFilesPage.module.scss';
export type AuthFilesPrefixProxyEditorModalProps = {
disableControls: boolean;
editor: PrefixProxyEditorState | null;
updatedText: string;
dirty: boolean;
onClose: () => void;
onSave: () => void;
onChange: (field: PrefixProxyEditorField, value: string) => void;
};
export function AuthFilesPrefixProxyEditorModal(props: AuthFilesPrefixProxyEditorModalProps) {
const { t } = useTranslation();
const { disableControls, editor, updatedText, dirty, onClose, onSave, onChange } = props;
return (
<Modal
open={Boolean(editor)}
onClose={onClose}
closeDisabled={editor?.saving === true}
width={720}
title={
editor?.fileName
? t('auth_files.auth_field_editor_title', { name: editor.fileName })
: t('auth_files.prefix_proxy_button')
}
footer={
<>
<Button variant="secondary" onClick={onClose} disabled={editor?.saving === true}>
{t('common.cancel')}
</Button>
<Button
onClick={onSave}
loading={editor?.saving === true}
disabled={
disableControls || editor?.saving === true || !dirty || !editor?.json
}
>
{t('common.save')}
</Button>
</>
}
>
{editor && (
<div className={styles.prefixProxyEditor}>
{editor.loading ? (
<div className={styles.prefixProxyLoading}>
<LoadingSpinner size={14} />
<span>{t('auth_files.prefix_proxy_loading')}</span>
</div>
) : (
<>
{editor.error && <div className={styles.prefixProxyError}>{editor.error}</div>}
<div className={styles.prefixProxyJsonWrapper}>
<label className={styles.prefixProxyLabel}>
{t('auth_files.prefix_proxy_source_label')}
</label>
<textarea
className={styles.prefixProxyTextarea}
rows={10}
readOnly
value={updatedText}
/>
</div>
<div className={styles.prefixProxyFields}>
<Input
label={t('auth_files.prefix_label')}
value={editor.prefix}
disabled={disableControls || editor.saving || !editor.json}
onChange={(e) => onChange('prefix', e.target.value)}
/>
<Input
label={t('auth_files.proxy_url_label')}
value={editor.proxyUrl}
placeholder={t('auth_files.proxy_url_placeholder')}
disabled={disableControls || editor.saving || !editor.json}
onChange={(e) => onChange('proxyUrl', e.target.value)}
/>
<Input
label={t('auth_files.priority_label')}
value={editor.priority}
placeholder={t('auth_files.priority_placeholder')}
hint={t('auth_files.priority_hint')}
disabled={disableControls || editor.saving || !editor.json}
onChange={(e) => onChange('priority', e.target.value)}
/>
<div className="form-group">
<label>{t('auth_files.excluded_models_label')}</label>
<textarea
className="input"
value={editor.excludedModelsText}
placeholder={t('auth_files.excluded_models_placeholder')}
rows={4}
disabled={disableControls || editor.saving || !editor.json}
onChange={(e) => onChange('excludedModelsText', e.target.value)}
/>
<div className="hint">{t('auth_files.excluded_models_hint')}</div>
</div>
<Input
label={t('auth_files.disable_cooling_label')}
value={editor.disableCooling}
placeholder={t('auth_files.disable_cooling_placeholder')}
hint={t('auth_files.disable_cooling_hint')}
disabled={disableControls || editor.saving || !editor.json}
onChange={(e) => onChange('disableCooling', e.target.value)}
/>
</div>
</>
)}
</div>
)}
</Modal>
);
}
@@ -0,0 +1,65 @@
import { useTranslation } from 'react-i18next';
import { Card } from '@/components/ui/Card';
import { Button } from '@/components/ui/Button';
import { EmptyState } from '@/components/ui/EmptyState';
import styles from '@/pages/AuthFilesPage.module.scss';
type UnsupportedError = 'unsupported' | null;
export type OAuthExcludedCardProps = {
disableControls: boolean;
excludedError: UnsupportedError;
excluded: Record<string, string[]>;
onAdd: () => void;
onEdit: (provider: string) => void;
onDelete: (provider: string) => void;
};
export function OAuthExcludedCard(props: OAuthExcludedCardProps) {
const { t } = useTranslation();
const { disableControls, excludedError, excluded, onAdd, onEdit, onDelete } = props;
return (
<Card
title={t('oauth_excluded.title')}
extra={
<Button size="sm" onClick={onAdd} disabled={disableControls || excludedError === 'unsupported'}>
{t('oauth_excluded.add')}
</Button>
}
>
{excludedError === 'unsupported' ? (
<EmptyState
title={t('oauth_excluded.upgrade_required_title')}
description={t('oauth_excluded.upgrade_required_desc')}
/>
) : Object.keys(excluded).length === 0 ? (
<EmptyState title={t('oauth_excluded.list_empty_all')} />
) : (
<div className={styles.excludedList}>
{Object.entries(excluded).map(([provider, models]) => (
<div key={provider} className={styles.excludedItem}>
<div className={styles.excludedInfo}>
<div className={styles.excludedProvider}>{provider}</div>
<div className={styles.excludedModels}>
{models?.length
? t('oauth_excluded.model_count', { count: models.length })
: t('oauth_excluded.no_models')}
</div>
</div>
<div className={styles.excludedActions}>
<Button variant="secondary" size="sm" onClick={() => onEdit(provider)}>
{t('common.edit')}
</Button>
<Button variant="danger" size="sm" onClick={() => onDelete(provider)}>
{t('oauth_excluded.delete')}
</Button>
</div>
</div>
))}
</div>
)}
</Card>
);
}
@@ -0,0 +1,152 @@
import { useRef } 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 { ModelMappingDiagram, type ModelMappingDiagramRef } from '@/components/modelAlias';
import { IconChevronUp } from '@/components/ui/icons';
import type { OAuthModelAliasEntry } from '@/types';
import type { AuthFileModelItem } from '@/features/authFiles/constants';
import styles from '@/pages/AuthFilesPage.module.scss';
type UnsupportedError = 'unsupported' | null;
type ViewMode = 'diagram' | 'list';
export type OAuthModelAliasCardProps = {
disableControls: boolean;
viewMode: ViewMode;
onViewModeChange: (mode: ViewMode) => void;
onAdd: () => void;
onEditProvider: (provider?: string) => void;
onDeleteProvider: (provider: string) => void;
modelAliasError: UnsupportedError;
modelAlias: Record<string, OAuthModelAliasEntry[]>;
allProviderModels: Record<string, AuthFileModelItem[]>;
onUpdate: (provider: string, sourceModel: string, newAlias: string) => Promise<void>;
onDeleteLink: (provider: string, sourceModel: string, alias: string) => void;
onToggleFork: (provider: string, sourceModel: string, alias: string, fork: boolean) => Promise<void>;
onRenameAlias: (oldAlias: string, newAlias: string) => Promise<void>;
onDeleteAlias: (aliasName: string) => void;
};
export function OAuthModelAliasCard(props: OAuthModelAliasCardProps) {
const { t } = useTranslation();
const diagramRef = useRef<ModelMappingDiagramRef | null>(null);
const {
disableControls,
viewMode,
onViewModeChange,
onAdd,
onEditProvider,
onDeleteProvider,
modelAliasError,
modelAlias,
allProviderModels,
onUpdate,
onDeleteLink,
onToggleFork,
onRenameAlias,
onDeleteAlias
} = props;
return (
<Card
title={t('oauth_model_alias.title')}
extra={
<div className={styles.cardExtraButtons}>
<div className={styles.viewModeSwitch}>
<Button
variant={viewMode === 'list' ? 'secondary' : 'ghost'}
size="sm"
onClick={() => onViewModeChange('list')}
disabled={disableControls || modelAliasError === 'unsupported'}
>
{t('oauth_model_alias.view_mode_list')}
</Button>
<Button
variant={viewMode === 'diagram' ? 'secondary' : 'ghost'}
size="sm"
onClick={() => onViewModeChange('diagram')}
disabled={disableControls || modelAliasError === 'unsupported'}
>
{t('oauth_model_alias.view_mode_diagram')}
</Button>
</div>
<Button
size="sm"
onClick={onAdd}
disabled={disableControls || modelAliasError === 'unsupported'}
>
{t('oauth_model_alias.add')}
</Button>
</div>
}
>
{modelAliasError === 'unsupported' ? (
<EmptyState
title={t('oauth_model_alias.upgrade_required_title')}
description={t('oauth_model_alias.upgrade_required_desc')}
/>
) : viewMode === 'diagram' ? (
Object.keys(modelAlias).length === 0 ? (
<EmptyState title={t('oauth_model_alias.list_empty_all')} />
) : (
<div className={styles.aliasChartSection}>
<div className={styles.aliasChartHeader}>
<h4 className={styles.aliasChartTitle}>{t('oauth_model_alias.chart_title')}</h4>
<Button
variant="ghost"
size="sm"
onClick={() => diagramRef.current?.collapseAll()}
disabled={disableControls || modelAliasError === 'unsupported'}
title={t('oauth_model_alias.diagram_collapse')}
aria-label={t('oauth_model_alias.diagram_collapse')}
>
<IconChevronUp size={16} />
</Button>
</div>
<ModelMappingDiagram
ref={diagramRef}
modelAlias={modelAlias}
allProviderModels={allProviderModels}
onUpdate={onUpdate}
onDeleteLink={onDeleteLink}
onToggleFork={onToggleFork}
onRenameAlias={onRenameAlias}
onDeleteAlias={onDeleteAlias}
onEditProvider={onEditProvider}
onDeleteProvider={onDeleteProvider}
className={styles.aliasChart}
/>
</div>
)
) : Object.keys(modelAlias).length === 0 ? (
<EmptyState title={t('oauth_model_alias.list_empty_all')} />
) : (
<div className={styles.excludedList}>
{Object.entries(modelAlias).map(([provider, mappings]) => (
<div key={provider} className={styles.excludedItem}>
<div className={styles.excludedInfo}>
<div className={styles.excludedProvider}>{provider}</div>
<div className={styles.excludedModels}>
{mappings?.length
? t('oauth_model_alias.model_count', { count: mappings.length })
: t('oauth_model_alias.no_models')}
</div>
</div>
<div className={styles.excludedActions}>
<Button variant="secondary" size="sm" onClick={() => onEditProvider(provider)}>
{t('common.edit')}
</Button>
<Button variant="danger" size="sm" onClick={() => onDeleteProvider(provider)}>
{t('oauth_model_alias.delete')}
</Button>
</div>
</div>
))}
</div>
)}
</Card>
);
}
@@ -0,0 +1,28 @@
import styles from '@/pages/AuthFilesPage.module.scss';
export type QuotaProgressBarProps = {
percent: number | null;
highThreshold: number;
mediumThreshold: number;
};
export function QuotaProgressBar({ percent, highThreshold, mediumThreshold }: QuotaProgressBarProps) {
const clamp = (value: number, min: number, max: number) => Math.min(max, Math.max(min, value));
const normalized = percent === null ? null : clamp(percent, 0, 100);
const fillClass =
normalized === null
? styles.quotaBarFillMedium
: normalized >= highThreshold
? styles.quotaBarFillHigh
: normalized >= mediumThreshold
? styles.quotaBarFillMedium
: styles.quotaBarFillLow;
const widthPercent = Math.round(normalized ?? 0);
return (
<div className={styles.quotaBar}>
<div className={`${styles.quotaBarFill} ${fillClass}`} style={{ width: `${widthPercent}%` }} />
</div>
);
}
+235
View File
@@ -0,0 +1,235 @@
import type { TFunction } from 'i18next';
import type { AuthFileItem } from '@/types';
import {
normalizeUsageSourceId,
type KeyStatBucket,
type KeyStats
} from '@/utils/usage';
export type ThemeColors = { bg: string; text: string; border?: string };
export type TypeColorSet = { light: ThemeColors; dark?: ThemeColors };
export type ResolvedTheme = 'light' | 'dark';
export type AuthFileModelItem = { id: string; display_name?: string; type?: string; owned_by?: string };
export type QuotaProviderType = 'antigravity' | 'codex' | 'gemini-cli';
export const QUOTA_PROVIDER_TYPES = new Set<QuotaProviderType>(['antigravity', 'codex', 'gemini-cli']);
export const MIN_CARD_PAGE_SIZE = 3;
export const MAX_CARD_PAGE_SIZE = 30;
export const INTEGER_STRING_PATTERN = /^[+-]?\d+$/;
export const TRUTHY_TEXT_VALUES = new Set(['true', '1', 'yes', 'y', 'on']);
export const FALSY_TEXT_VALUES = new Set(['false', '0', 'no', 'n', 'off']);
// 标签类型颜色配置(对齐重构前 styles.css 的 file-type-badge 颜色)
export const TYPE_COLORS: Record<string, TypeColorSet> = {
qwen: {
light: { bg: '#e8f5e9', text: '#2e7d32' },
dark: { bg: '#1b5e20', text: '#81c784' }
},
kimi: {
light: { bg: '#fff4e5', text: '#ad6800' },
dark: { bg: '#7c4a03', text: '#ffd591' }
},
gemini: {
light: { bg: '#e3f2fd', text: '#1565c0' },
dark: { bg: '#0d47a1', text: '#64b5f6' }
},
'gemini-cli': {
light: { bg: '#e7efff', text: '#1e4fa3' },
dark: { bg: '#1c3f73', text: '#a8c7ff' }
},
aistudio: {
light: { bg: '#f0f2f5', text: '#2f343c' },
dark: { bg: '#373c42', text: '#cfd3db' }
},
claude: {
light: { bg: '#fce4ec', text: '#c2185b' },
dark: { bg: '#880e4f', text: '#f48fb1' }
},
codex: {
light: { bg: '#fff3e0', text: '#ef6c00' },
dark: { bg: '#e65100', text: '#ffb74d' }
},
antigravity: {
light: { bg: '#e0f7fa', text: '#006064' },
dark: { bg: '#004d40', text: '#80deea' }
},
iflow: {
light: { bg: '#f3e5f5', text: '#7b1fa2' },
dark: { bg: '#4a148c', text: '#ce93d8' }
},
empty: {
light: { bg: '#f5f5f5', text: '#616161' },
dark: { bg: '#424242', text: '#bdbdbd' }
},
unknown: {
light: { bg: '#f0f0f0', text: '#666666', border: '1px dashed #999999' },
dark: { bg: '#3a3a3a', text: '#aaaaaa', border: '1px dashed #666666' }
}
};
export const clampCardPageSize = (value: number) =>
Math.min(MAX_CARD_PAGE_SIZE, Math.max(MIN_CARD_PAGE_SIZE, Math.round(value)));
export const resolveQuotaErrorMessage = (
t: TFunction,
status: number | undefined,
fallback: string
): string => {
if (status === 404) return t('common.quota_update_required');
if (status === 403) return t('common.quota_check_credential');
return fallback;
};
export const normalizeProviderKey = (value: string) => value.trim().toLowerCase();
export const getTypeLabel = (t: TFunction, type: string): string => {
const key = `auth_files.filter_${type}`;
const translated = t(key);
if (translated !== key) return translated;
if (type.toLowerCase() === 'iflow') return 'iFlow';
return type.charAt(0).toUpperCase() + type.slice(1);
};
export const getTypeColor = (type: string, resolvedTheme: ResolvedTheme): ThemeColors => {
const set = TYPE_COLORS[type] || TYPE_COLORS.unknown;
return resolvedTheme === 'dark' && set.dark ? set.dark : set.light;
};
export const parsePriorityValue = (value: unknown): number | undefined => {
if (typeof value === 'number') {
return Number.isInteger(value) ? value : undefined;
}
if (typeof value !== 'string') return undefined;
const trimmed = value.trim();
if (!trimmed || !INTEGER_STRING_PATTERN.test(trimmed)) return undefined;
const parsed = Number.parseInt(trimmed, 10);
return Number.isSafeInteger(parsed) ? parsed : undefined;
};
export const normalizeExcludedModels = (value: unknown): string[] => {
if (!Array.isArray(value)) return [];
const seen = new Set<string>();
const normalized: string[] = [];
value.forEach((entry) => {
const model = String(entry ?? '')
.trim()
.toLowerCase();
if (!model || seen.has(model)) return;
seen.add(model);
normalized.push(model);
});
return normalized.sort((a, b) => a.localeCompare(b));
};
export const parseExcludedModelsText = (value: string): string[] =>
normalizeExcludedModels(value.split(/[\n,]+/));
export const parseDisableCoolingValue = (value: unknown): boolean | undefined => {
if (typeof value === 'boolean') return value;
if (typeof value === 'number' && Number.isFinite(value)) return value !== 0;
if (typeof value !== 'string') return undefined;
const normalized = value.trim().toLowerCase();
if (!normalized) return undefined;
if (TRUTHY_TEXT_VALUES.has(normalized)) return true;
if (FALSY_TEXT_VALUES.has(normalized)) return false;
return undefined;
};
// 标准化 auth_index 值(与 usage.ts 中的 normalizeAuthIndex 保持一致)
export function normalizeAuthIndexValue(value: unknown): string | null {
if (typeof value === 'number' && Number.isFinite(value)) {
return value.toString();
}
if (typeof value === 'string') {
const trimmed = value.trim();
return trimmed ? trimmed : null;
}
return null;
}
export function isRuntimeOnlyAuthFile(file: AuthFileItem): boolean {
const raw = file['runtime_only'] ?? file.runtimeOnly;
if (typeof raw === 'boolean') return raw;
if (typeof raw === 'string') return raw.trim().toLowerCase() === 'true';
return false;
}
export function resolveAuthFileStats(file: AuthFileItem, stats: KeyStats): KeyStatBucket {
const defaultStats: KeyStatBucket = { success: 0, failure: 0 };
const rawFileName = file?.name || '';
// 兼容 auth_index 和 authIndex 两种字段名(API 返回的是 auth_index
const rawAuthIndex = file['auth_index'] ?? file.authIndex;
const authIndexKey = normalizeAuthIndexValue(rawAuthIndex);
// 尝试根据 authIndex 匹配
if (authIndexKey && stats.byAuthIndex?.[authIndexKey]) {
return stats.byAuthIndex[authIndexKey];
}
// 尝试根据 source (文件名) 匹配
const fileNameId = rawFileName ? normalizeUsageSourceId(rawFileName) : '';
if (fileNameId && stats.bySource?.[fileNameId]) {
const fromName = stats.bySource[fileNameId];
if (fromName.success > 0 || fromName.failure > 0) {
return fromName;
}
}
// 尝试去掉扩展名后匹配
if (rawFileName) {
const nameWithoutExt = rawFileName.replace(/\.[^/.]+$/, '');
if (nameWithoutExt && nameWithoutExt !== rawFileName) {
const nameWithoutExtId = normalizeUsageSourceId(nameWithoutExt);
const fromNameWithoutExt = nameWithoutExtId ? stats.bySource?.[nameWithoutExtId] : undefined;
if (
fromNameWithoutExt &&
(fromNameWithoutExt.success > 0 || fromNameWithoutExt.failure > 0)
) {
return fromNameWithoutExt;
}
}
}
return defaultStats;
}
export const formatModified = (item: AuthFileItem): string => {
const raw = item['modtime'] ?? item.modified;
if (!raw) return '-';
const asNumber = Number(raw);
const date =
Number.isFinite(asNumber) && !Number.isNaN(asNumber)
? new Date(asNumber < 1e12 ? asNumber * 1000 : asNumber)
: new Date(String(raw));
return Number.isNaN(date.getTime()) ? '-' : date.toLocaleString();
};
// 检查模型是否被 OAuth 排除
export const isModelExcluded = (
modelId: string,
providerType: string,
excluded: Record<string, string[]>
): boolean => {
const providerKey = normalizeProviderKey(providerType);
const excludedModels = excluded[providerKey] || excluded[providerType] || [];
return excludedModels.some((pattern) => {
if (pattern.includes('*')) {
// 支持通配符匹配:先转义正则特殊字符,再将 * 视为通配符
const regexSafePattern = pattern
.split('*')
.map((segment) => segment.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))
.join('.*');
const regex = new RegExp(`^${regexSafePattern}$`, 'i');
return regex.test(modelId);
}
return pattern.toLowerCase() === modelId.toLowerCase();
});
};
@@ -0,0 +1,319 @@
import { useCallback, useRef, useState, type ChangeEvent, type RefObject } from 'react';
import { useTranslation } from 'react-i18next';
import { authFilesApi } from '@/services/api';
import { apiClient } from '@/services/api/client';
import { useNotificationStore } from '@/stores';
import type { AuthFileItem } from '@/types';
import { formatFileSize } from '@/utils/format';
import { MAX_AUTH_FILE_SIZE } from '@/utils/constants';
import { getTypeLabel, isRuntimeOnlyAuthFile } from '@/features/authFiles/constants';
type DeleteAllOptions = {
filter: string;
onResetFilterToAll: () => void;
};
export type UseAuthFilesDataResult = {
files: AuthFileItem[];
loading: boolean;
error: string;
uploading: boolean;
deleting: string | null;
deletingAll: boolean;
statusUpdating: Record<string, boolean>;
fileInputRef: RefObject<HTMLInputElement | null>;
loadFiles: () => Promise<void>;
handleUploadClick: () => void;
handleFileChange: (event: ChangeEvent<HTMLInputElement>) => Promise<void>;
handleDelete: (name: string) => void;
handleDeleteAll: (options: DeleteAllOptions) => void;
handleDownload: (name: string) => Promise<void>;
handleStatusToggle: (item: AuthFileItem, enabled: boolean) => Promise<void>;
};
export type UseAuthFilesDataOptions = {
refreshKeyStats: () => Promise<void>;
};
export function useAuthFilesData(options: UseAuthFilesDataOptions): UseAuthFilesDataResult {
const { refreshKeyStats } = options;
const { t } = useTranslation();
const { showNotification, showConfirmation } = useNotificationStore();
const [files, setFiles] = useState<AuthFileItem[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [uploading, setUploading] = useState(false);
const [deleting, setDeleting] = useState<string | null>(null);
const [deletingAll, setDeletingAll] = useState(false);
const [statusUpdating, setStatusUpdating] = useState<Record<string, boolean>>({});
const fileInputRef = useRef<HTMLInputElement | null>(null);
const loadFiles = useCallback(async () => {
setLoading(true);
setError('');
try {
const data = await authFilesApi.list();
setFiles(data?.files || []);
} catch (err: unknown) {
const errorMessage = err instanceof Error ? err.message : t('notification.refresh_failed');
setError(errorMessage);
} finally {
setLoading(false);
}
}, [t]);
const handleUploadClick = useCallback(() => {
fileInputRef.current?.click();
}, []);
const handleFileChange = useCallback(
async (event: ChangeEvent<HTMLInputElement>) => {
const fileList = event.target.files;
if (!fileList || fileList.length === 0) return;
const filesToUpload = Array.from(fileList);
const validFiles: File[] = [];
const invalidFiles: string[] = [];
const oversizedFiles: string[] = [];
filesToUpload.forEach((file) => {
if (!file.name.endsWith('.json')) {
invalidFiles.push(file.name);
return;
}
if (file.size > MAX_AUTH_FILE_SIZE) {
oversizedFiles.push(file.name);
return;
}
validFiles.push(file);
});
if (invalidFiles.length > 0) {
showNotification(t('auth_files.upload_error_json'), 'error');
}
if (oversizedFiles.length > 0) {
showNotification(
t('auth_files.upload_error_size', { maxSize: formatFileSize(MAX_AUTH_FILE_SIZE) }),
'error'
);
}
if (validFiles.length === 0) {
event.target.value = '';
return;
}
setUploading(true);
let successCount = 0;
const failed: { name: string; message: string }[] = [];
for (const file of validFiles) {
try {
await authFilesApi.upload(file);
successCount++;
} catch (err: unknown) {
const errorMessage = err instanceof Error ? err.message : 'Unknown error';
failed.push({ name: file.name, message: errorMessage });
}
}
if (successCount > 0) {
const suffix = validFiles.length > 1 ? ` (${successCount}/${validFiles.length})` : '';
showNotification(
`${t('auth_files.upload_success')}${suffix}`,
failed.length ? 'warning' : 'success'
);
await loadFiles();
await refreshKeyStats();
}
if (failed.length > 0) {
const details = failed.map((item) => `${item.name}: ${item.message}`).join('; ');
showNotification(`${t('notification.upload_failed')}: ${details}`, 'error');
}
setUploading(false);
event.target.value = '';
},
[loadFiles, refreshKeyStats, showNotification, t]
);
const handleDelete = useCallback(
(name: string) => {
showConfirmation({
title: t('auth_files.delete_title', { defaultValue: 'Delete File' }),
message: `${t('auth_files.delete_confirm')} "${name}" ?`,
variant: 'danger',
confirmText: t('common.confirm'),
onConfirm: async () => {
setDeleting(name);
try {
await authFilesApi.deleteFile(name);
showNotification(t('auth_files.delete_success'), 'success');
setFiles((prev) => prev.filter((item) => item.name !== name));
} catch (err: unknown) {
const errorMessage = err instanceof Error ? err.message : '';
showNotification(`${t('notification.delete_failed')}: ${errorMessage}`, 'error');
} finally {
setDeleting(null);
}
}
});
},
[showConfirmation, showNotification, t]
);
const handleDeleteAll = useCallback(
(deleteAllOptions: DeleteAllOptions) => {
const { filter, onResetFilterToAll } = deleteAllOptions;
const isFiltered = filter !== 'all';
const typeLabel = isFiltered ? getTypeLabel(t, filter) : t('auth_files.filter_all');
const confirmMessage = isFiltered
? t('auth_files.delete_filtered_confirm', { type: typeLabel })
: t('auth_files.delete_all_confirm');
showConfirmation({
title: t('auth_files.delete_all_title', { defaultValue: 'Delete All Files' }),
message: confirmMessage,
variant: 'danger',
confirmText: t('common.confirm'),
onConfirm: async () => {
setDeletingAll(true);
try {
if (!isFiltered) {
await authFilesApi.deleteAll();
showNotification(t('auth_files.delete_all_success'), 'success');
setFiles((prev) => prev.filter((file) => isRuntimeOnlyAuthFile(file)));
} else {
const filesToDelete = files.filter(
(f) => f.type === filter && !isRuntimeOnlyAuthFile(f)
);
if (filesToDelete.length === 0) {
showNotification(t('auth_files.delete_filtered_none', { type: typeLabel }), 'info');
setDeletingAll(false);
return;
}
let success = 0;
let failed = 0;
const deletedNames: string[] = [];
for (const file of filesToDelete) {
try {
await authFilesApi.deleteFile(file.name);
success++;
deletedNames.push(file.name);
} catch {
failed++;
}
}
setFiles((prev) => prev.filter((f) => !deletedNames.includes(f.name)));
if (failed === 0) {
showNotification(
t('auth_files.delete_filtered_success', { count: success, type: typeLabel }),
'success'
);
} else {
showNotification(
t('auth_files.delete_filtered_partial', { success, failed, type: typeLabel }),
'warning'
);
}
onResetFilterToAll();
}
} catch (err: unknown) {
const errorMessage = err instanceof Error ? err.message : '';
showNotification(`${t('notification.delete_failed')}: ${errorMessage}`, 'error');
} finally {
setDeletingAll(false);
}
}
});
},
[files, showConfirmation, showNotification, t]
);
const handleDownload = useCallback(
async (name: string) => {
try {
const response = await apiClient.getRaw(
`/auth-files/download?name=${encodeURIComponent(name)}`,
{ responseType: 'blob' }
);
const blob = new Blob([response.data]);
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = name;
a.click();
window.URL.revokeObjectURL(url);
showNotification(t('auth_files.download_success'), 'success');
} catch (err: unknown) {
const errorMessage = err instanceof Error ? err.message : '';
showNotification(`${t('notification.download_failed')}: ${errorMessage}`, 'error');
}
},
[showNotification, t]
);
const handleStatusToggle = useCallback(
async (item: AuthFileItem, enabled: boolean) => {
const name = item.name;
const nextDisabled = !enabled;
const previousDisabled = item.disabled === true;
setStatusUpdating((prev) => ({ ...prev, [name]: true }));
setFiles((prev) => prev.map((f) => (f.name === name ? { ...f, disabled: nextDisabled } : f)));
try {
const res = await authFilesApi.setStatus(name, nextDisabled);
setFiles((prev) =>
prev.map((f) => (f.name === name ? { ...f, disabled: res.disabled } : f))
);
showNotification(
enabled
? t('auth_files.status_enabled_success', { name })
: t('auth_files.status_disabled_success', { name }),
'success'
);
} catch (err: unknown) {
const errorMessage = err instanceof Error ? err.message : '';
setFiles((prev) =>
prev.map((f) => (f.name === name ? { ...f, disabled: previousDisabled } : f))
);
showNotification(`${t('notification.update_failed')}: ${errorMessage}`, 'error');
} finally {
setStatusUpdating((prev) => {
if (!prev[name]) return prev;
const next = { ...prev };
delete next[name];
return next;
});
}
},
[showNotification, t]
);
return {
files,
loading,
error,
uploading,
deleting,
deletingAll,
statusUpdating,
fileInputRef,
loadFiles,
handleUploadClick,
handleFileChange,
handleDelete,
handleDeleteAll,
handleDownload,
handleStatusToggle
};
}
@@ -0,0 +1,86 @@
import { useCallback, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { authFilesApi } from '@/services/api';
import { useNotificationStore } from '@/stores';
import type { AuthFileItem } from '@/types';
import type { AuthFileModelItem } from '@/features/authFiles/constants';
type ModelsError = 'unsupported' | null;
export type UseAuthFilesModelsResult = {
modelsModalOpen: boolean;
modelsLoading: boolean;
modelsList: AuthFileModelItem[];
modelsFileName: string;
modelsFileType: string;
modelsError: ModelsError;
showModels: (item: AuthFileItem) => Promise<void>;
closeModelsModal: () => void;
};
export function useAuthFilesModels(): UseAuthFilesModelsResult {
const { t } = useTranslation();
const showNotification = useNotificationStore((state) => state.showNotification);
const [modelsModalOpen, setModelsModalOpen] = useState(false);
const [modelsLoading, setModelsLoading] = useState(false);
const [modelsList, setModelsList] = useState<AuthFileModelItem[]>([]);
const [modelsFileName, setModelsFileName] = useState('');
const [modelsFileType, setModelsFileType] = useState('');
const [modelsError, setModelsError] = useState<ModelsError>(null);
const modelsCacheRef = useRef<Map<string, AuthFileModelItem[]>>(new Map());
const closeModelsModal = useCallback(() => {
setModelsModalOpen(false);
}, []);
const showModels = useCallback(
async (item: AuthFileItem) => {
setModelsFileName(item.name);
setModelsFileType(item.type || '');
setModelsList([]);
setModelsError(null);
setModelsModalOpen(true);
const cached = modelsCacheRef.current.get(item.name);
if (cached) {
setModelsList(cached);
setModelsLoading(false);
return;
}
setModelsLoading(true);
try {
const models = await authFilesApi.getModelsForAuthFile(item.name);
modelsCacheRef.current.set(item.name, models);
setModelsList(models);
} catch (err) {
const errorMessage = err instanceof Error ? err.message : '';
if (
errorMessage.includes('404') ||
errorMessage.includes('not found') ||
errorMessage.includes('Not Found')
) {
setModelsError('unsupported');
} else {
showNotification(`${t('notification.load_failed')}: ${errorMessage}`, 'error');
}
} finally {
setModelsLoading(false);
}
},
[showNotification, t]
);
return {
modelsModalOpen,
modelsLoading,
modelsList,
modelsFileName,
modelsFileType,
modelsError,
showModels,
closeModelsModal
};
}
@@ -0,0 +1,504 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { Trans, useTranslation } from 'react-i18next';
import { authFilesApi } from '@/services/api';
import { useNotificationStore } from '@/stores';
import type { AuthFileItem, OAuthModelAliasEntry } from '@/types';
import type { AuthFileModelItem } from '@/features/authFiles/constants';
import { normalizeProviderKey } from '@/features/authFiles/constants';
type UnsupportedError = 'unsupported' | null;
type ViewMode = 'diagram' | 'list';
export type UseAuthFilesOauthResult = {
excluded: Record<string, string[]>;
excludedError: UnsupportedError;
modelAlias: Record<string, OAuthModelAliasEntry[]>;
modelAliasError: UnsupportedError;
allProviderModels: Record<string, AuthFileModelItem[]>;
providerList: string[];
loadExcluded: () => Promise<void>;
loadModelAlias: () => Promise<void>;
deleteExcluded: (provider: string) => void;
deleteModelAlias: (provider: string) => void;
handleMappingUpdate: (provider: string, sourceModel: string, newAlias: string) => Promise<void>;
handleDeleteLink: (provider: string, sourceModel: string, alias: string) => void;
handleToggleFork: (
provider: string,
sourceModel: string,
alias: string,
fork: boolean
) => Promise<void>;
handleRenameAlias: (oldAlias: string, newAlias: string) => Promise<void>;
handleDeleteAlias: (aliasName: string) => void;
};
export type UseAuthFilesOauthOptions = {
viewMode: ViewMode;
files: AuthFileItem[];
};
export function useAuthFilesOauth(options: UseAuthFilesOauthOptions): UseAuthFilesOauthResult {
const { viewMode, files } = options;
const { t } = useTranslation();
const { showNotification, showConfirmation } = useNotificationStore();
const [excluded, setExcluded] = useState<Record<string, string[]>>({});
const [excludedError, setExcludedError] = useState<UnsupportedError>(null);
const [modelAlias, setModelAlias] = useState<Record<string, OAuthModelAliasEntry[]>>({});
const [modelAliasError, setModelAliasError] = useState<UnsupportedError>(null);
const [allProviderModels, setAllProviderModels] = useState<Record<string, AuthFileModelItem[]>>(
{}
);
const excludedUnsupportedRef = useRef(false);
const mappingsUnsupportedRef = useRef(false);
const providerList = useMemo(() => {
const providers = new Set<string>();
Object.keys(modelAlias).forEach((provider) => {
const key = provider.trim().toLowerCase();
if (key) providers.add(key);
});
files.forEach((file) => {
if (typeof file.type === 'string') {
const key = file.type.trim().toLowerCase();
if (key) providers.add(key);
}
if (typeof file.provider === 'string') {
const key = file.provider.trim().toLowerCase();
if (key) providers.add(key);
}
});
return Array.from(providers);
}, [files, modelAlias]);
useEffect(() => {
if (viewMode !== 'diagram') return;
let cancelled = false;
const loadAllModels = async () => {
if (providerList.length === 0) {
if (!cancelled) setAllProviderModels({});
return;
}
const results = await Promise.all(
providerList.map(async (provider) => {
try {
const models = await authFilesApi.getModelDefinitions(provider);
return { provider, models };
} catch {
return { provider, models: [] as AuthFileModelItem[] };
}
})
);
if (cancelled) return;
const nextModels: Record<string, AuthFileModelItem[]> = {};
results.forEach(({ provider, models }) => {
if (models.length > 0) {
nextModels[provider] = models;
}
});
setAllProviderModels(nextModels);
};
void loadAllModels();
return () => {
cancelled = true;
};
}, [providerList, viewMode]);
const loadExcluded = useCallback(async () => {
try {
const res = await authFilesApi.getOauthExcludedModels();
excludedUnsupportedRef.current = false;
setExcluded(res || {});
setExcludedError(null);
} catch (err: unknown) {
const status =
typeof err === 'object' && err !== null && 'status' in err
? (err as { status?: unknown }).status
: undefined;
if (status === 404) {
setExcluded({});
setExcludedError('unsupported');
if (!excludedUnsupportedRef.current) {
excludedUnsupportedRef.current = true;
showNotification(t('oauth_excluded.upgrade_required'), 'warning');
}
return;
}
// 静默失败
}
}, [showNotification, t]);
const loadModelAlias = useCallback(async () => {
try {
const res = await authFilesApi.getOauthModelAlias();
mappingsUnsupportedRef.current = false;
setModelAlias(res || {});
setModelAliasError(null);
} catch (err: unknown) {
const status =
typeof err === 'object' && err !== null && 'status' in err
? (err as { status?: unknown }).status
: undefined;
if (status === 404) {
setModelAlias({});
setModelAliasError('unsupported');
if (!mappingsUnsupportedRef.current) {
mappingsUnsupportedRef.current = true;
showNotification(t('oauth_model_alias.upgrade_required'), 'warning');
}
return;
}
// 静默失败
}
}, [showNotification, t]);
const deleteExcluded = useCallback(
(provider: string) => {
const providerLabel = provider.trim() || provider;
showConfirmation({
title: t('oauth_excluded.delete_title', { defaultValue: 'Delete Exclusion' }),
message: t('oauth_excluded.delete_confirm', { provider: providerLabel }),
variant: 'danger',
confirmText: t('common.confirm'),
onConfirm: async () => {
const providerKey = normalizeProviderKey(provider);
if (!providerKey) {
showNotification(t('oauth_excluded.provider_required'), 'error');
return;
}
try {
await authFilesApi.deleteOauthExcludedEntry(providerKey);
await loadExcluded();
showNotification(t('oauth_excluded.delete_success'), 'success');
} catch (err: unknown) {
try {
const current = await authFilesApi.getOauthExcludedModels();
const next: Record<string, string[]> = {};
Object.entries(current).forEach(([key, models]) => {
if (normalizeProviderKey(key) === providerKey) return;
next[key] = models;
});
await authFilesApi.replaceOauthExcludedModels(next);
await loadExcluded();
showNotification(t('oauth_excluded.delete_success'), 'success');
} catch (fallbackErr: unknown) {
const errorMessage =
fallbackErr instanceof Error
? fallbackErr.message
: err instanceof Error
? err.message
: '';
showNotification(`${t('oauth_excluded.delete_failed')}: ${errorMessage}`, 'error');
}
}
}
});
},
[loadExcluded, showConfirmation, showNotification, t]
);
const deleteModelAlias = useCallback(
(provider: string) => {
showConfirmation({
title: t('oauth_model_alias.delete_title', { defaultValue: 'Delete Mappings' }),
message: t('oauth_model_alias.delete_confirm', { provider }),
variant: 'danger',
confirmText: t('common.confirm'),
onConfirm: async () => {
try {
await authFilesApi.deleteOauthModelAlias(provider);
await loadModelAlias();
showNotification(t('oauth_model_alias.delete_success'), 'success');
} catch (err: unknown) {
const errorMessage = err instanceof Error ? err.message : '';
showNotification(`${t('oauth_model_alias.delete_failed')}: ${errorMessage}`, 'error');
}
}
});
},
[loadModelAlias, showConfirmation, showNotification, t]
);
const handleMappingUpdate = useCallback(
async (provider: string, sourceModel: string, newAlias: string) => {
if (!provider || !sourceModel || !newAlias) return;
const normalizedProvider = normalizeProviderKey(provider);
if (!normalizedProvider) return;
const providerKey = Object.keys(modelAlias).find(
(key) => normalizeProviderKey(key) === normalizedProvider
);
const currentMappings = (providerKey ? modelAlias[providerKey] : null) ?? [];
const nameTrim = sourceModel.trim();
const aliasTrim = newAlias.trim();
const nameKey = nameTrim.toLowerCase();
const aliasKey = aliasTrim.toLowerCase();
if (
currentMappings.some(
(m) =>
(m.name ?? '').trim().toLowerCase() === nameKey &&
(m.alias ?? '').trim().toLowerCase() === aliasKey
)
) {
return;
}
const nextMappings: OAuthModelAliasEntry[] = [
...currentMappings,
{ name: nameTrim, alias: aliasTrim, fork: true }
];
try {
await authFilesApi.saveOauthModelAlias(normalizedProvider, nextMappings);
await loadModelAlias();
showNotification(t('oauth_model_alias.save_success'), 'success');
} catch (err: unknown) {
const errorMessage = err instanceof Error ? err.message : '';
showNotification(`${t('oauth_model_alias.save_failed')}: ${errorMessage}`, 'error');
}
},
[loadModelAlias, modelAlias, showNotification, t]
);
const handleDeleteLink = useCallback(
(provider: string, sourceModel: string, alias: string) => {
const nameTrim = sourceModel.trim();
const aliasTrim = alias.trim();
if (!provider || !nameTrim || !aliasTrim) return;
showConfirmation({
title: t('oauth_model_alias.delete_link_title', { defaultValue: 'Unlink mapping' }),
message: (
<Trans
i18nKey="oauth_model_alias.delete_link_confirm"
values={{ provider, sourceModel: nameTrim, alias: aliasTrim }}
components={{ code: <code /> }}
/>
),
variant: 'danger',
confirmText: t('common.confirm'),
onConfirm: async () => {
const normalizedProvider = normalizeProviderKey(provider);
const providerKey = Object.keys(modelAlias).find(
(key) => normalizeProviderKey(key) === normalizedProvider
);
const currentMappings = (providerKey ? modelAlias[providerKey] : null) ?? [];
const nameKey = nameTrim.toLowerCase();
const aliasKey = aliasTrim.toLowerCase();
const nextMappings = currentMappings.filter(
(m) =>
(m.name ?? '').trim().toLowerCase() !== nameKey ||
(m.alias ?? '').trim().toLowerCase() !== aliasKey
);
if (nextMappings.length === currentMappings.length) return;
try {
if (nextMappings.length === 0) {
await authFilesApi.deleteOauthModelAlias(normalizedProvider);
} else {
await authFilesApi.saveOauthModelAlias(normalizedProvider, nextMappings);
}
await loadModelAlias();
showNotification(t('oauth_model_alias.save_success'), 'success');
} catch (err: unknown) {
const errorMessage = err instanceof Error ? err.message : '';
showNotification(`${t('oauth_model_alias.save_failed')}: ${errorMessage}`, 'error');
}
}
});
},
[loadModelAlias, modelAlias, showConfirmation, showNotification, t]
);
const handleToggleFork = useCallback(
async (provider: string, sourceModel: string, alias: string, fork: boolean) => {
const normalizedProvider = normalizeProviderKey(provider);
if (!normalizedProvider) return;
const providerKey = Object.keys(modelAlias).find(
(key) => normalizeProviderKey(key) === normalizedProvider
);
const currentMappings = (providerKey ? modelAlias[providerKey] : null) ?? [];
const nameKey = sourceModel.trim().toLowerCase();
const aliasKey = alias.trim().toLowerCase();
let changed = false;
const nextMappings = currentMappings.map((m) => {
const mName = (m.name ?? '').trim().toLowerCase();
const mAlias = (m.alias ?? '').trim().toLowerCase();
if (mName === nameKey && mAlias === aliasKey) {
changed = true;
return fork ? { ...m, fork: true } : { name: m.name, alias: m.alias };
}
return m;
});
if (!changed) return;
try {
await authFilesApi.saveOauthModelAlias(normalizedProvider, nextMappings);
await loadModelAlias();
showNotification(t('oauth_model_alias.save_success'), 'success');
} catch (err: unknown) {
const errorMessage = err instanceof Error ? err.message : '';
showNotification(`${t('oauth_model_alias.save_failed')}: ${errorMessage}`, 'error');
}
},
[loadModelAlias, modelAlias, showNotification, t]
);
const handleRenameAlias = useCallback(
async (oldAlias: string, newAlias: string) => {
const oldTrim = oldAlias.trim();
const newTrim = newAlias.trim();
if (!oldTrim || !newTrim || oldTrim === newTrim) return;
const oldKey = oldTrim.toLowerCase();
const providersToUpdate = Object.entries(modelAlias).filter(([_, mappings]) =>
mappings.some((m) => (m.alias ?? '').trim().toLowerCase() === oldKey)
);
if (providersToUpdate.length === 0) return;
let hadFailure = false;
let failureMessage = '';
try {
const results = await Promise.allSettled(
providersToUpdate.map(([provider, mappings]) => {
const nextMappings = mappings.map((m) =>
(m.alias ?? '').trim().toLowerCase() === oldKey ? { ...m, alias: newTrim } : m
);
return authFilesApi.saveOauthModelAlias(provider, nextMappings);
})
);
const failures = results.filter(
(result): result is PromiseRejectedResult => result.status === 'rejected'
);
if (failures.length > 0) {
hadFailure = true;
const reason = failures[0].reason;
failureMessage = reason instanceof Error ? reason.message : String(reason ?? '');
}
} finally {
await loadModelAlias();
}
if (hadFailure) {
showNotification(
failureMessage
? `${t('oauth_model_alias.save_failed')}: ${failureMessage}`
: t('oauth_model_alias.save_failed'),
'error'
);
} else {
showNotification(t('oauth_model_alias.save_success'), 'success');
}
},
[loadModelAlias, modelAlias, showNotification, t]
);
const handleDeleteAlias = useCallback(
(aliasName: string) => {
const aliasTrim = aliasName.trim();
if (!aliasTrim) return;
const aliasKey = aliasTrim.toLowerCase();
const providersToUpdate = Object.entries(modelAlias).filter(([_, mappings]) =>
mappings.some((m) => (m.alias ?? '').trim().toLowerCase() === aliasKey)
);
if (providersToUpdate.length === 0) return;
showConfirmation({
title: t('oauth_model_alias.delete_alias_title', { defaultValue: 'Delete Alias' }),
message: (
<Trans
i18nKey="oauth_model_alias.delete_alias_confirm"
values={{ alias: aliasTrim }}
components={{ code: <code /> }}
/>
),
variant: 'danger',
confirmText: t('common.confirm'),
onConfirm: async () => {
let hadFailure = false;
let failureMessage = '';
try {
const results = await Promise.allSettled(
providersToUpdate.map(([provider, mappings]) => {
const nextMappings = mappings.filter(
(m) => (m.alias ?? '').trim().toLowerCase() !== aliasKey
);
if (nextMappings.length === 0) {
return authFilesApi.deleteOauthModelAlias(provider);
}
return authFilesApi.saveOauthModelAlias(provider, nextMappings);
})
);
const failures = results.filter(
(result): result is PromiseRejectedResult => result.status === 'rejected'
);
if (failures.length > 0) {
hadFailure = true;
const reason = failures[0].reason;
failureMessage = reason instanceof Error ? reason.message : String(reason ?? '');
}
} finally {
await loadModelAlias();
}
if (hadFailure) {
showNotification(
failureMessage
? `${t('oauth_model_alias.delete_failed')}: ${failureMessage}`
: t('oauth_model_alias.delete_failed'),
'error'
);
} else {
showNotification(t('oauth_model_alias.delete_success'), 'success');
}
}
});
},
[loadModelAlias, modelAlias, showConfirmation, showNotification, t]
);
return {
excluded,
excludedError,
modelAlias,
modelAliasError,
allProviderModels,
providerList,
loadExcluded,
loadModelAlias,
deleteExcluded,
deleteModelAlias,
handleMappingUpdate,
handleDeleteLink,
handleToggleFork,
handleRenameAlias,
handleDeleteAlias
};
}
@@ -0,0 +1,254 @@
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { authFilesApi } from '@/services/api';
import { useNotificationStore } from '@/stores';
import { formatFileSize } from '@/utils/format';
import { MAX_AUTH_FILE_SIZE } from '@/utils/constants';
import {
normalizeExcludedModels,
parseDisableCoolingValue,
parseExcludedModelsText,
parsePriorityValue
} from '@/features/authFiles/constants';
export type PrefixProxyEditorField =
| 'prefix'
| 'proxyUrl'
| 'priority'
| 'excludedModelsText'
| 'disableCooling';
export type PrefixProxyEditorState = {
fileName: string;
loading: boolean;
saving: boolean;
error: string | null;
originalText: string;
rawText: string;
json: Record<string, unknown> | null;
prefix: string;
proxyUrl: string;
priority: string;
excludedModelsText: string;
disableCooling: string;
};
export type UseAuthFilesPrefixProxyEditorOptions = {
disableControls: boolean;
loadFiles: () => Promise<void>;
loadKeyStats: () => Promise<void>;
};
export type UseAuthFilesPrefixProxyEditorResult = {
prefixProxyEditor: PrefixProxyEditorState | null;
prefixProxyUpdatedText: string;
prefixProxyDirty: boolean;
openPrefixProxyEditor: (name: string) => Promise<void>;
closePrefixProxyEditor: () => void;
handlePrefixProxyChange: (field: PrefixProxyEditorField, value: string) => void;
handlePrefixProxySave: () => Promise<void>;
};
const buildPrefixProxyUpdatedText = (editor: PrefixProxyEditorState | null): string => {
if (!editor?.json) return editor?.rawText ?? '';
const next: Record<string, unknown> = { ...editor.json };
if ('prefix' in next || editor.prefix.trim()) {
next.prefix = editor.prefix;
}
if ('proxy_url' in next || editor.proxyUrl.trim()) {
next.proxy_url = editor.proxyUrl;
}
const parsedPriority = parsePriorityValue(editor.priority);
if (parsedPriority !== undefined) {
next.priority = parsedPriority;
} else if ('priority' in next) {
delete next.priority;
}
const excludedModels = parseExcludedModelsText(editor.excludedModelsText);
if (excludedModels.length > 0) {
next.excluded_models = excludedModels;
} else if ('excluded_models' in next) {
delete next.excluded_models;
}
const parsedDisableCooling = parseDisableCoolingValue(editor.disableCooling);
if (parsedDisableCooling !== undefined) {
next.disable_cooling = parsedDisableCooling;
} else if ('disable_cooling' in next) {
delete next.disable_cooling;
}
return JSON.stringify(next);
};
export function useAuthFilesPrefixProxyEditor(
options: UseAuthFilesPrefixProxyEditorOptions
): UseAuthFilesPrefixProxyEditorResult {
const { disableControls, loadFiles, loadKeyStats } = options;
const { t } = useTranslation();
const showNotification = useNotificationStore((state) => state.showNotification);
const [prefixProxyEditor, setPrefixProxyEditor] = useState<PrefixProxyEditorState | null>(null);
const prefixProxyUpdatedText = buildPrefixProxyUpdatedText(prefixProxyEditor);
const prefixProxyDirty =
Boolean(prefixProxyEditor?.json) &&
Boolean(prefixProxyEditor?.originalText) &&
prefixProxyUpdatedText !== prefixProxyEditor?.originalText;
const closePrefixProxyEditor = () => {
setPrefixProxyEditor(null);
};
const openPrefixProxyEditor = async (name: string) => {
if (disableControls) return;
if (prefixProxyEditor?.fileName === name) {
setPrefixProxyEditor(null);
return;
}
setPrefixProxyEditor({
fileName: name,
loading: true,
saving: false,
error: null,
originalText: '',
rawText: '',
json: null,
prefix: '',
proxyUrl: '',
priority: '',
excludedModelsText: '',
disableCooling: ''
});
try {
const rawText = await authFilesApi.downloadText(name);
const trimmed = rawText.trim();
let parsed: unknown;
try {
parsed = JSON.parse(trimmed) as unknown;
} catch {
setPrefixProxyEditor((prev) => {
if (!prev || prev.fileName !== name) return prev;
return {
...prev,
loading: false,
error: t('auth_files.prefix_proxy_invalid_json'),
rawText: trimmed,
originalText: trimmed
};
});
return;
}
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
setPrefixProxyEditor((prev) => {
if (!prev || prev.fileName !== name) return prev;
return {
...prev,
loading: false,
error: t('auth_files.prefix_proxy_invalid_json'),
rawText: trimmed,
originalText: trimmed
};
});
return;
}
const json = parsed as Record<string, unknown>;
const originalText = JSON.stringify(json);
const prefix = typeof json.prefix === 'string' ? json.prefix : '';
const proxyUrl = typeof json.proxy_url === 'string' ? json.proxy_url : '';
const priority = parsePriorityValue(json.priority);
const excludedModels = normalizeExcludedModels(json.excluded_models);
const disableCoolingValue = parseDisableCoolingValue(json.disable_cooling);
setPrefixProxyEditor((prev) => {
if (!prev || prev.fileName !== name) return prev;
return {
...prev,
loading: false,
originalText,
rawText: originalText,
json,
prefix,
proxyUrl,
priority: priority !== undefined ? String(priority) : '',
excludedModelsText: excludedModels.join('\n'),
disableCooling:
disableCoolingValue === undefined ? '' : disableCoolingValue ? 'true' : 'false',
error: null
};
});
} catch (err: unknown) {
const errorMessage = err instanceof Error ? err.message : t('notification.download_failed');
setPrefixProxyEditor((prev) => {
if (!prev || prev.fileName !== name) return prev;
return { ...prev, loading: false, error: errorMessage, rawText: '' };
});
showNotification(`${t('notification.download_failed')}: ${errorMessage}`, 'error');
}
};
const handlePrefixProxyChange = (field: PrefixProxyEditorField, value: string) => {
setPrefixProxyEditor((prev) => {
if (!prev) return prev;
if (field === 'prefix') return { ...prev, prefix: value };
if (field === 'proxyUrl') return { ...prev, proxyUrl: value };
if (field === 'priority') return { ...prev, priority: value };
if (field === 'excludedModelsText') return { ...prev, excludedModelsText: value };
return { ...prev, disableCooling: value };
});
};
const handlePrefixProxySave = async () => {
if (!prefixProxyEditor?.json) return;
if (!prefixProxyDirty) return;
const name = prefixProxyEditor.fileName;
const payload = prefixProxyUpdatedText;
const fileSize = new Blob([payload]).size;
if (fileSize > MAX_AUTH_FILE_SIZE) {
showNotification(
t('auth_files.upload_error_size', { maxSize: formatFileSize(MAX_AUTH_FILE_SIZE) }),
'error'
);
return;
}
setPrefixProxyEditor((prev) => {
if (!prev || prev.fileName !== name) return prev;
return { ...prev, saving: true };
});
try {
const file = new File([payload], name, { type: 'application/json' });
await authFilesApi.upload(file);
showNotification(t('auth_files.prefix_proxy_saved_success', { name }), 'success');
await loadFiles();
await loadKeyStats();
setPrefixProxyEditor(null);
} catch (err: unknown) {
const errorMessage = err instanceof Error ? err.message : '';
showNotification(`${t('notification.upload_failed')}: ${errorMessage}`, 'error');
setPrefixProxyEditor((prev) => {
if (!prev || prev.fileName !== name) return prev;
return { ...prev, saving: false };
});
}
};
return {
prefixProxyEditor,
prefixProxyUpdatedText,
prefixProxyDirty,
openPrefixProxyEditor,
closePrefixProxyEditor,
handlePrefixProxyChange,
handlePrefixProxySave
};
}
@@ -0,0 +1,35 @@
import { useCallback, useRef, useState } from 'react';
import { usageApi } from '@/services/api';
import { collectUsageDetails, type KeyStats, type UsageDetail } from '@/utils/usage';
export type UseAuthFilesStatsResult = {
keyStats: KeyStats;
usageDetails: UsageDetail[];
loadKeyStats: () => Promise<void>;
};
export function useAuthFilesStats(): UseAuthFilesStatsResult {
const [keyStats, setKeyStats] = useState<KeyStats>({ bySource: {}, byAuthIndex: {} });
const [usageDetails, setUsageDetails] = useState<UsageDetail[]>([]);
const loadingKeyStatsRef = useRef(false);
const loadKeyStats = useCallback(async () => {
if (loadingKeyStatsRef.current) return;
loadingKeyStatsRef.current = true;
try {
const usageResponse = await usageApi.getUsage();
const usageData = usageResponse?.usage ?? usageResponse;
const stats = await usageApi.getKeyStats(usageData);
setKeyStats(stats);
const details = collectUsageDetails(usageData);
setUsageDetails(details);
} catch {
// 静默失败
} finally {
loadingKeyStatsRef.current = false;
}
}, []);
return { keyStats, usageDetails, loadKeyStats };
}
@@ -0,0 +1,28 @@
import { useMemo } from 'react';
import type { AuthFileItem } from '@/types';
import { calculateStatusBarData, type UsageDetail } from '@/utils/usage';
import { normalizeAuthIndexValue } from '@/features/authFiles/constants';
export type AuthFileStatusBarData = ReturnType<typeof calculateStatusBarData>;
export function useAuthFilesStatusBarCache(files: AuthFileItem[], usageDetails: UsageDetail[]) {
return useMemo(() => {
const cache = new Map<string, AuthFileStatusBarData>();
files.forEach((file) => {
const rawAuthIndex = file['auth_index'] ?? file.authIndex;
const authIndexKey = normalizeAuthIndexValue(rawAuthIndex);
if (authIndexKey) {
const filteredDetails = usageDetails.filter((detail) => {
const detailAuthIndex = normalizeAuthIndexValue(detail.auth_index);
return detailAuthIndex !== null && detailAuthIndex === authIndexKey;
});
cache.set(authIndexKey, calculateStatusBarData(filteredDetails));
}
});
return cache;
}, [files, usageDetails]);
}
+30
View File
@@ -0,0 +1,30 @@
export type AuthFilesUiState = {
filter?: string;
search?: string;
page?: number;
pageSize?: number;
};
const AUTH_FILES_UI_STATE_KEY = 'authFilesPage.uiState';
export const readAuthFilesUiState = (): AuthFilesUiState | null => {
if (typeof window === 'undefined') return null;
try {
const raw = window.sessionStorage.getItem(AUTH_FILES_UI_STATE_KEY);
if (!raw) return null;
const parsed = JSON.parse(raw) as AuthFilesUiState;
return parsed && typeof parsed === 'object' ? parsed : null;
} catch {
return null;
}
};
export const writeAuthFilesUiState = (state: AuthFilesUiState) => {
if (typeof window === 'undefined') return;
try {
window.sessionStorage.setItem(AUTH_FILES_UI_STATE_KEY, JSON.stringify(state));
} catch {
// ignore
}
};
+19
View File
@@ -54,6 +54,10 @@
"login": "CLI Proxy API Management Center",
"abbr": "CPAMC"
},
"splash": {
"title": "CLI Proxy API",
"subtitle": "Management Center"
},
"auto_login": {
"title": "Auto Login in Progress...",
"message": "Attempting to connect to server using locally saved connection information"
@@ -194,6 +198,8 @@
"gemini_keys_add_btn": "Add Key",
"gemini_base_url_label": "Base URL (Optional):",
"gemini_base_url_placeholder": "e.g.: https://generativelanguage.googleapis.com",
"gemini_add_modal_proxy_label": "Proxy URL (Optional):",
"gemini_add_modal_proxy_placeholder": "e.g.: socks5://proxy.example.com:1080",
"gemini_edit_modal_title": "Edit Gemini API Key",
"gemini_edit_modal_key_label": "API Key:",
"gemini_delete_confirm": "Are you sure you want to delete this Gemini key?",
@@ -490,6 +496,8 @@
"secondary_window": "Weekly limit",
"code_review_primary_window": "Code review 5-hour limit",
"code_review_secondary_window": "Code review weekly limit",
"additional_primary_window": "{{name}} 5-hour limit",
"additional_secondary_window": "{{name}} weekly limit",
"plan_label": "Plan",
"plan_plus": "Plus",
"plan_team": "Team",
@@ -824,6 +832,17 @@
"success": "Success",
"failure": "Failure"
},
"status_bar": {
"success_short": "✓",
"failure_short": "✗",
"no_requests": "No requests"
},
"service_health": {
"title": "Service Health",
"window": "Last 7 days",
"oldest": "Oldest",
"newest": "Latest"
},
"logs": {
"title": "Logs Viewer",
"refresh_button": "Refresh Logs",
+19
View File
@@ -54,6 +54,10 @@
"login": "Центр управления CLI Proxy API",
"abbr": "CPAMC"
},
"splash": {
"title": "CLI Proxy API",
"subtitle": "Центр управления"
},
"auto_login": {
"title": "Автовход...",
"message": "Пытаемся подключиться к серверу, используя сохранённые данные"
@@ -194,6 +198,8 @@
"gemini_keys_add_btn": "Добавить ключ",
"gemini_base_url_label": "Базовый URL (необязательно):",
"gemini_base_url_placeholder": "например: https://generativelanguage.googleapis.com",
"gemini_add_modal_proxy_label": "URL прокси (необязательно):",
"gemini_add_modal_proxy_placeholder": "например: socks5://proxy.example.com:1080",
"gemini_edit_modal_title": "Редактирование API-ключа Gemini",
"gemini_edit_modal_key_label": "API-ключ:",
"gemini_delete_confirm": "Удалить этот ключ Gemini?",
@@ -493,6 +499,8 @@
"secondary_window": "Недельный лимит",
"code_review_primary_window": "Лимит code review на 5 часов",
"code_review_secondary_window": "Недельный лимит code review",
"additional_primary_window": "{{name}}: лимит на 5 часов",
"additional_secondary_window": "{{name}}: недельный лимит",
"plan_label": "Тариф",
"plan_plus": "Plus",
"plan_team": "Team",
@@ -827,6 +835,17 @@
"success": "Успех",
"failure": "Сбой"
},
"status_bar": {
"success_short": "✓",
"failure_short": "✗",
"no_requests": "Нет запросов"
},
"service_health": {
"title": "Состояние сервиса",
"window": "Последние 7 дней",
"oldest": "Старые",
"newest": "Новые"
},
"logs": {
"title": "Просмотр журналов",
"refresh_button": "Обновить журналы",
+19
View File
@@ -54,6 +54,10 @@
"login": "CLI Proxy API Management Center",
"abbr": "CPAMC"
},
"splash": {
"title": "CLI Proxy API",
"subtitle": "管理中心"
},
"auto_login": {
"title": "正在自动登录...",
"message": "正在使用本地保存的连接信息尝试连接服务器"
@@ -194,6 +198,8 @@
"gemini_keys_add_btn": "添加密钥",
"gemini_base_url_label": "Base URL (可选)",
"gemini_base_url_placeholder": "例如: https://generativelanguage.googleapis.com",
"gemini_add_modal_proxy_label": "代理 URL (可选):",
"gemini_add_modal_proxy_placeholder": "例如: socks5://proxy.example.com:1080",
"gemini_edit_modal_title": "编辑Gemini API密钥",
"gemini_edit_modal_key_label": "API密钥:",
"gemini_delete_confirm": "确定要删除这个Gemini密钥吗?",
@@ -490,6 +496,8 @@
"secondary_window": "周限额",
"code_review_primary_window": "代码审查 5 小时限额",
"code_review_secondary_window": "代码审查周限额",
"additional_primary_window": "{{name}} 5 小时限额",
"additional_secondary_window": "{{name}} 周限额",
"plan_label": "套餐",
"plan_plus": "Plus",
"plan_team": "Team",
@@ -824,6 +832,17 @@
"success": "成功",
"failure": "失败"
},
"status_bar": {
"success_short": "✓",
"failure_short": "✗",
"no_requests": "无请求"
},
"service_health": {
"title": "服务健康监测",
"window": "最近 7 天",
"oldest": "最早",
"newest": "最新"
},
"logs": {
"title": "日志查看",
"refresh_button": "刷新日志",
+9
View File
@@ -21,6 +21,7 @@ const buildEmptyForm = (): GeminiFormState => ({
apiKey: '',
prefix: '',
baseUrl: '',
proxyUrl: '',
headers: [],
excludedModels: [],
excludedText: '',
@@ -138,6 +139,7 @@ export function AiProvidersGeminiEditPage() {
apiKey: form.apiKey.trim(),
prefix: form.prefix?.trim() || undefined,
baseUrl: form.baseUrl?.trim() || undefined,
proxyUrl: form.proxyUrl?.trim() || undefined,
headers: buildHeaderObject(form.headers),
excludedModels: parseExcludedModels(form.excludedText),
};
@@ -218,6 +220,13 @@ export function AiProvidersGeminiEditPage() {
onChange={(e) => setForm((prev) => ({ ...prev, baseUrl: e.target.value }))}
disabled={disableControls || saving}
/>
<Input
label={t('ai_providers.gemini_add_modal_proxy_label')}
placeholder={t('ai_providers.gemini_add_modal_proxy_placeholder')}
value={form.proxyUrl ?? ''}
onChange={(e) => setForm((prev) => ({ ...prev, proxyUrl: e.target.value }))}
disabled={disableControls || saving}
/>
<HeaderInputList
entries={form.headers}
onChange={(entries) => setForm((prev) => ({ ...prev, headers: entries }))}
+1 -1
View File
@@ -59,7 +59,7 @@ function StatusSuccessIcon() {
function StatusErrorIcon() {
return (
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
<circle cx="8" cy="8" r="8" fill="var(--danger-color, #ef4444)" />
<circle cx="8" cy="8" r="8" fill="var(--danger-color, #c65746)" />
<path
d="M5 5L11 11M11 5L5 11"
stroke="white"
+145 -34
View File
@@ -93,9 +93,9 @@
}
.statFailure {
background-color: var(--failure-badge-bg, #fee2e2);
color: var(--failure-badge-text, #991b1b);
border-color: var(--failure-badge-border, #fca5a5);
background-color: var(--failure-badge-bg);
color: var(--failure-badge-text);
border-color: var(--failure-badge-border);
}
// 字段行样式:标签 + 值
@@ -311,8 +311,8 @@
}
.apiKeyEntryStatFailure {
background: var(--failure-badge-bg, #fee2e2);
color: var(--failure-badge-text, #991b1b);
background: var(--failure-badge-bg);
color: var(--failure-badge-text);
}
// OpenAI 模型发现(二级界面)
@@ -402,37 +402,123 @@
gap: 2px;
flex: 1;
min-width: 180px;
position: relative;
}
.statusBlockWrapper {
flex: 1;
min-width: 6px;
position: relative;
cursor: pointer;
-webkit-tap-highlight-color: transparent;
}
.statusBlock {
flex: 1;
width: 100%;
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;
.statusBlockWrapper:hover &,
.statusBlockWrapper.statusBlockActive & {
transform: scaleY(1.8);
opacity: 0.9;
}
}
.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);
}
.statusTooltip {
position: absolute;
bottom: calc(100% + 8px);
left: 50%;
transform: translateX(-50%);
background: var(--bg-primary, #fff);
border: 1px solid var(--border-secondary, #e5e7eb);
border-radius: 6px;
padding: 6px 10px;
font-size: 11px;
line-height: 1.5;
white-space: nowrap;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.12);
z-index: $z-dropdown;
pointer-events: none;
color: var(--text-primary);
// 小箭头
&::after {
content: '';
position: absolute;
top: 100%;
left: 50%;
transform: translateX(-50%);
border: 5px solid transparent;
border-top-color: var(--bg-primary, #fff);
}
&::before {
content: '';
position: absolute;
top: 100%;
left: 50%;
transform: translateX(-50%);
border: 6px solid transparent;
border-top-color: var(--border-secondary, #e5e7eb);
}
}
// 防止左右溢出
.statusTooltipLeft {
left: 0;
transform: translateX(0);
&::after,
&::before {
left: 8px;
transform: none;
}
}
.statusTooltipRight {
left: auto;
right: 0;
transform: translateX(0);
&::after,
&::before {
left: auto;
right: 8px;
transform: none;
}
}
.tooltipTime {
color: var(--text-secondary);
display: block;
margin-bottom: 2px;
}
.tooltipStats {
display: flex;
align-items: center;
gap: 8px;
}
.tooltipSuccess {
color: var(--success-color, #22c55e);
}
.tooltipFailure {
color: var(--danger-color, #ef4444);
}
.tooltipRate {
color: var(--text-secondary);
margin-left: 2px;
}
.statusRate {
display: flex;
align-items: center;
@@ -456,8 +542,19 @@
}
.statusRateLow {
color: var(--failure-badge-text, #991b1b);
background: var(--failure-badge-bg, #fee2e2);
color: var(--failure-badge-text);
background: var(--failure-badge-bg);
}
@include mobile {
.statusTooltip {
font-size: 12px;
padding: 8px 12px;
}
.statusBlocks {
min-width: 140px;
}
}
// ============================================
@@ -775,8 +872,8 @@
// 暗色主题适配
:global([data-theme='dark']) {
.headerBadge {
background: rgba(59, 130, 246, 0.15);
border-color: rgba(59, 130, 246, 0.3);
background: rgba($primary-color, 0.14);
border-color: rgba($primary-color, 0.35);
color: var(--text-secondary);
strong {
@@ -785,22 +882,22 @@
}
.modelTag {
background: rgba(59, 130, 246, 0.1);
background: rgba($primary-color, 0.1);
border-color: var(--border-secondary);
}
.excludedModelTag {
background: rgba(251, 191, 36, 0.22);
border-color: rgba(251, 191, 36, 0.55);
color: #fde68a;
background: rgba($warning-color, 0.22);
border-color: rgba($warning-color, 0.55);
color: var(--warning-color);
.modelName {
color: #fde68a;
color: var(--warning-color);
}
}
.excludedModelsLabel {
color: #fde68a;
color: var(--warning-color);
}
.apiKeyEntryCard {
@@ -816,6 +913,20 @@
background-color: var(--border-primary, #374151);
}
.statusTooltip {
background: var(--bg-secondary, #1f2937);
border-color: var(--border-primary, #374151);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
&::after {
border-top-color: var(--bg-secondary, #1f2937);
}
&::before {
border-top-color: var(--border-primary, #374151);
}
}
.statusRateHigh {
background: rgba(34, 197, 94, 0.2);
color: #86efac;
@@ -827,7 +938,7 @@
}
.statusRateLow {
background: rgba(239, 68, 68, 0.2);
color: #fca5a5;
background: rgba($error-color, 0.24);
color: #f1b0a6;
}
}
+127 -34
View File
@@ -56,7 +56,7 @@
.errorBox {
padding: $spacing-md;
background-color: rgba(239, 68, 68, 0.1);
background-color: rgba($error-color, 0.1);
border: 1px solid var(--danger-color);
border-radius: $radius-md;
color: var(--danger-color);
@@ -382,11 +382,11 @@
}
.quotaBarFillMedium {
background-color: var(--warning-color, #f59e0b);
background-color: var(--warning-color);
}
.quotaBarFillLow {
background-color: var(--danger-color, #ef4444);
background-color: var(--danger-color);
}
.quotaMeta {
@@ -443,7 +443,7 @@
.quotaError {
font-size: 12px;
color: var(--danger-color);
background-color: rgba(239, 68, 68, 0.08);
background-color: rgba($error-color, 0.08);
border: 1px solid var(--danger-color);
border-radius: $radius-sm;
padding: $spacing-xs $spacing-sm;
@@ -451,9 +451,9 @@
.quotaWarning {
font-size: 12px;
color: var(--warning-color, #f59e0b);
background-color: rgba(245, 158, 11, 0.12);
border: 1px solid var(--warning-color, #f59e0b);
color: var(--warning-text);
background-color: var(--warning-bg);
border: 1px solid var(--warning-border);
border-radius: $radius-sm;
padding: $spacing-xs $spacing-sm;
}
@@ -586,9 +586,9 @@
}
.statFailure {
background-color: var(--failure-badge-bg, #fee2e2);
color: var(--failure-badge-text, #991b1b);
border-color: var(--failure-badge-border, #fca5a5);
background-color: var(--failure-badge-bg);
color: var(--failure-badge-text);
border-color: var(--failure-badge-border);
}
// 状态监测栏
@@ -605,39 +605,121 @@
gap: 2px;
flex: 1;
min-width: 180px;
position: relative;
}
.statusBlockWrapper {
flex: 1;
min-width: 6px;
position: relative;
cursor: pointer;
-webkit-tap-highlight-color: transparent;
}
.statusBlock {
flex: 1;
width: 100%;
height: 8px;
border-radius: 2px;
min-width: 6px;
transition:
transform 0.15s ease,
opacity 0.15s ease;
transition: transform 0.15s ease, opacity 0.15s ease;
&:hover {
transform: scaleY(1.5);
opacity: 0.85;
.statusBlockWrapper:hover &,
.statusBlockWrapper.statusBlockActive & {
transform: scaleY(1.8);
opacity: 0.9;
}
}
.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);
}
.statusTooltip {
position: absolute;
bottom: calc(100% + 8px);
left: 50%;
transform: translateX(-50%);
background: var(--bg-primary, #fff);
border: 1px solid var(--border-secondary, #e5e7eb);
border-radius: 6px;
padding: 6px 10px;
font-size: 11px;
line-height: 1.5;
white-space: nowrap;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.12);
z-index: $z-dropdown;
pointer-events: none;
color: var(--text-primary);
&::after {
content: '';
position: absolute;
top: 100%;
left: 50%;
transform: translateX(-50%);
border: 5px solid transparent;
border-top-color: var(--bg-primary, #fff);
}
&::before {
content: '';
position: absolute;
top: 100%;
left: 50%;
transform: translateX(-50%);
border: 6px solid transparent;
border-top-color: var(--border-secondary, #e5e7eb);
}
}
.statusTooltipLeft {
left: 0;
transform: translateX(0);
&::after,
&::before {
left: 8px;
transform: none;
}
}
.statusTooltipRight {
left: auto;
right: 0;
transform: translateX(0);
&::after,
&::before {
left: auto;
right: 8px;
transform: none;
}
}
.tooltipTime {
color: var(--text-secondary);
display: block;
margin-bottom: 2px;
}
.tooltipStats {
display: flex;
align-items: center;
gap: 8px;
}
.tooltipSuccess {
color: var(--success-color, #22c55e);
}
.tooltipFailure {
color: var(--danger-color, #ef4444);
}
.tooltipRate {
color: var(--text-secondary);
margin-left: 2px;
}
.statusRate {
display: flex;
align-items: center;
@@ -661,8 +743,19 @@
}
.statusRateLow {
color: var(--failure-badge-text, #991b1b);
background: var(--failure-badge-bg, #fee2e2);
color: var(--failure-badge-text);
background: var(--failure-badge-bg);
}
@include mobile {
.statusTooltip {
font-size: 12px;
padding: 8px 12px;
}
.statusBlocks {
min-width: 140px;
}
}
.prefixProxyEditor {
@@ -687,7 +780,7 @@
padding: $spacing-sm $spacing-md;
border-radius: $radius-md;
border: 1px solid var(--danger-color);
background-color: rgba(239, 68, 68, 0.1);
background-color: rgba($error-color, 0.1);
color: var(--danger-color);
font-size: 12px;
}
@@ -1097,7 +1190,7 @@
.modelExcludedBadge {
font-size: 10px;
color: var(--danger-color);
background-color: rgba(239, 68, 68, 0.1);
background-color: rgba($error-color, 0.1);
padding: 2px 6px;
border-radius: 8px;
border: 1px solid var(--danger-color);
+202 -2062
View File
File diff suppressed because it is too large Load Diff
+10 -28
View File
@@ -192,15 +192,15 @@
color: var(--text-secondary);
&.modified {
color: #f59e0b;
color: var(--warning-color);
}
&.saved {
color: #16a34a;
color: var(--success-color);
}
&.error {
color: #dc2626;
color: var(--danger-color);
}
}
@@ -331,12 +331,12 @@
align-items: center;
gap: 8px;
padding: 8px 10px;
background: rgba(255, 255, 255, 0.7);
background: color-mix(in srgb, var(--bg-primary) 82%, transparent);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border: 1px solid rgba(255, 255, 255, 0.3);
border: 1px solid color-mix(in srgb, var(--border-color) 60%, transparent);
border-radius: 999px;
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.1);
box-shadow: var(--shadow-lg);
max-width: inherit;
overflow-x: auto;
scrollbar-width: none;
@@ -351,7 +351,7 @@
font-weight: 600;
padding: 5px 8px;
border-radius: 999px;
background: rgba(0, 0, 0, 0.06);
background: color-mix(in srgb, var(--text-primary) 6%, transparent);
text-align: center;
max-width: min(280px, 46vw);
white-space: nowrap;
@@ -373,7 +373,7 @@
transition: background-color 0.2s ease, transform 0.15s ease;
&:hover:not(:disabled) {
background: rgba(0, 0, 0, 0.06);
background: color-mix(in srgb, var(--text-primary) 10%, transparent);
transform: scale(1.08);
}
@@ -395,26 +395,8 @@
width: 7px;
height: 7px;
border-radius: 999px;
background: #f59e0b;
box-shadow: 0 0 0 2px rgba(245, 158, 11, 0.25);
}
:global([data-theme='dark']) {
.floatingActionList {
background: rgba(30, 30, 30, 0.7);
border-color: rgba(255, 255, 255, 0.1);
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.4);
}
.floatingStatus {
background: rgba(255, 255, 255, 0.08);
}
.floatingActionButton {
&:hover:not(:disabled) {
background: rgba(255, 255, 255, 0.1);
}
}
background: var(--warning-color);
box-shadow: 0 0 0 2px rgba($warning-color, 0.25);
}
@media (max-width: 1200px) {
+5 -5
View File
@@ -4,7 +4,7 @@
.container {
min-height: 100vh;
display: flex;
background: var(--bg-primary);
background: var(--bg-secondary);
}
// 左侧品牌展示区
@@ -88,7 +88,7 @@
justify-content: center;
align-items: center;
padding: $spacing-2xl;
background: var(--bg-primary);
background: var(--bg-secondary);
position: relative;
@media (max-width: $breakpoint-mobile) {
@@ -183,7 +183,7 @@
&:focus {
outline: none;
border-color: var(--primary-color);
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.15);
box-shadow: 0 0 0 3px rgba($primary-color, 0.18);
}
}
@@ -235,8 +235,8 @@
// 错误提示框
.errorBox {
background: rgba(239, 68, 68, 0.1);
border: 1px solid rgba(239, 68, 68, 0.4);
background: rgba($error-color, 0.1);
border: 1px solid rgba($error-color, 0.4);
border-radius: $radius-md;
padding: $spacing-sm $spacing-md;
color: $error-color;
+2 -2
View File
@@ -188,8 +188,8 @@ export function LoginPage() {
/* 启动动画 */
<div className={styles.splashContent}>
<img src={INLINE_LOGO_JPEG} alt="CPAMC" className={styles.splashLogo} />
<h1 className={styles.splashTitle}>CLI Proxy API</h1>
<p className={styles.splashSubtitle}>Management Center</p>
<h1 className={styles.splashTitle}>{t('splash.title')}</h1>
<p className={styles.splashSubtitle}>{t('splash.subtitle')}</p>
<div className={styles.splashLoader}>
<div className={styles.splashLoaderBar} />
</div>
+15 -15
View File
@@ -308,7 +308,7 @@
color: var(--text-primary);
&:hover {
background: rgba(59, 130, 246, 0.06);
background: rgba($primary-color, 0.06);
}
@include tablet {
@@ -409,14 +409,14 @@
.statusInfo {
color: var(--info-color);
background: rgba(59, 130, 246, 0.12);
border-color: rgba(59, 130, 246, 0.25);
background: rgba($primary-color, 0.12);
border-color: rgba($primary-color, 0.25);
}
.statusWarn {
color: var(--warning-color);
background: rgba(245, 158, 11, 0.14);
border-color: rgba(245, 158, 11, 0.25);
color: var(--warning-text);
background: var(--warning-bg);
border-color: var(--warning-border);
}
.statusError {
@@ -427,20 +427,20 @@
.levelInfo {
color: var(--info-color);
background: rgba(59, 130, 246, 0.12);
border-color: rgba(59, 130, 246, 0.25);
background: rgba($primary-color, 0.12);
border-color: rgba($primary-color, 0.25);
}
.levelWarn {
color: var(--warning-color);
background: rgba(245, 158, 11, 0.14);
border-color: rgba(245, 158, 11, 0.25);
color: var(--warning-text);
background: var(--warning-bg);
border-color: var(--warning-border);
}
.levelError {
color: var(--error-color);
background: rgba(239, 68, 68, 0.12);
border-color: rgba(239, 68, 68, 0.25);
background: rgba($error-color, 0.12);
border-color: rgba($error-color, 0.25);
}
.levelDebug,
@@ -452,8 +452,8 @@
.methodBadge {
color: var(--text-primary);
background: rgba(59, 130, 246, 0.08);
border-color: rgba(59, 130, 246, 0.22);
background: rgba($primary-color, 0.08);
border-color: rgba($primary-color, 0.22);
}
.path {
+1 -24
View File
@@ -20,6 +20,7 @@ import {
import { useHeaderRefresh } from '@/hooks/useHeaderRefresh';
import { useAuthStore, useConfigStore, useNotificationStore } from '@/stores';
import { logsApi } from '@/services/api/logs';
import { copyToClipboard } from '@/utils/clipboard';
import { MANAGEMENT_API_PREFIX } from '@/utils/constants';
import { formatUnixTimestamp } from '@/utils/format';
import styles from './LogsPage.module.scss';
@@ -344,30 +345,6 @@ const getErrorMessage = (err: unknown): string => {
return typeof message === 'string' ? message : '';
};
const copyToClipboard = async (text: string) => {
try {
await navigator.clipboard.writeText(text);
return true;
} catch {
try {
const textarea = document.createElement('textarea');
textarea.value = text;
textarea.style.position = 'fixed';
textarea.style.opacity = '0';
textarea.style.left = '-9999px';
textarea.style.top = '0';
document.body.appendChild(textarea);
textarea.focus();
textarea.select();
const ok = document.execCommand('copy');
document.body.removeChild(textarea);
return ok;
} catch {
return false;
}
}
};
type TabType = 'logs' | 'errors';
export function LogsPage() {
+4 -4
View File
@@ -61,13 +61,13 @@
}
&.error {
background-color: rgba(239, 68, 68, 0.1);
color: #dc2626;
background-color: rgba($error-color, 0.12);
color: $error-color;
}
&.waiting {
background-color: rgba(59, 130, 246, 0.1);
color: #3b82f6;
background-color: rgba($primary-color, 0.12);
color: var(--primary-color);
}
}
+6 -6
View File
@@ -6,6 +6,7 @@ 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 { copyToClipboard } from '@/utils/clipboard';
import styles from './OAuthPage.module.scss';
import iconCodexLight from '@/assets/icons/codex_light.svg';
import iconCodexDark from '@/assets/icons/codex_drak.svg';
@@ -186,12 +187,11 @@ export function OAuthPage() {
const copyLink = async (url?: string) => {
if (!url) return;
try {
await navigator.clipboard.writeText(url);
showNotification(t('notification.link_copied'), 'success');
} catch {
showNotification('Copy failed', 'error');
}
const copied = await copyToClipboard(url);
showNotification(
t(copied ? 'notification.link_copied' : 'notification.copy_failed'),
copied ? 'success' : 'error'
);
};
const submitCallback = async (provider: OAuthProvider) => {
+7 -7
View File
@@ -65,7 +65,7 @@
.errorBox {
padding: $spacing-md;
background-color: rgba(239, 68, 68, 0.1);
background-color: rgba($error-color, 0.1);
border: 1px solid var(--danger-color);
border-radius: $radius-md;
color: var(--danger-color);
@@ -233,11 +233,11 @@
}
.quotaBarFillMedium {
background-color: var(--warning-color, #f59e0b);
background-color: var(--warning-color);
}
.quotaBarFillLow {
background-color: var(--danger-color, #ef4444);
background-color: var(--danger-color);
}
.quotaMeta {
@@ -276,7 +276,7 @@
.quotaError {
font-size: 12px;
color: var(--danger-color);
background-color: rgba(239, 68, 68, 0.08);
background-color: rgba($error-color, 0.08);
border: 1px solid var(--danger-color);
border-radius: $radius-sm;
padding: $spacing-xs $spacing-sm;
@@ -284,9 +284,9 @@
.quotaWarning {
font-size: 12px;
color: var(--warning-color, #f59e0b);
background-color: rgba(245, 158, 11, 0.12);
border: 1px solid var(--warning-color, #f59e0b);
color: var(--warning-text);
background-color: var(--warning-bg);
border: 1px solid var(--warning-border);
border-radius: $radius-sm;
padding: $spacing-xs $spacing-sm;
}
+1 -1
View File
@@ -83,7 +83,7 @@
&:hover {
transform: translateY(-1px);
border-color: var(--primary-color);
box-shadow: 0 8px 18px rgba(59, 130, 246, 0.15);
box-shadow: 0 8px 18px rgba($primary-color, 0.18);
}
&:active {
+281 -126
View File
@@ -44,100 +44,8 @@
font-weight: 600;
}
.timeRangeSelectWrap {
position: relative;
display: inline-flex;
align-items: center;
}
.timeRangeSelect {
display: inline-flex;
align-items: center;
justify-content: space-between;
gap: 8px;
.timeRangeSelectControl {
min-width: 164px;
height: 40px;
padding: 0 12px;
border: 1px solid var(--border-color);
border-radius: $radius-md;
background-color: var(--bg-primary);
box-shadow: var(--shadow);
color: var(--text-primary);
font-size: 13px;
font-weight: 500;
cursor: pointer;
appearance: none;
text-align: left;
&:hover {
border-color: var(--border-hover);
}
&:focus {
outline: none;
box-shadow: var(--shadow), 0 0 0 3px rgba(59, 130, 246, 0.15);
}
&[aria-expanded='true'] {
border-color: var(--primary-color);
box-shadow: var(--shadow), 0 0 0 3px rgba(59, 130, 246, 0.15);
}
}
.timeRangeSelectedText {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.timeRangeSelectIcon {
display: inline-flex;
color: var(--text-secondary);
flex-shrink: 0;
transition: transform 0.2s ease;
[aria-expanded='true'] > & {
transform: rotate(180deg);
}
}
.timeRangeDropdown {
position: absolute;
top: calc(100% + 6px);
left: 0;
right: 0;
z-index: 1000;
background: var(--bg-primary);
border: 1px solid var(--border-color);
border-radius: $radius-lg;
padding: 6px;
box-shadow: var(--shadow-lg);
display: flex;
flex-direction: column;
gap: 4px;
}
.timeRangeOption {
padding: 8px 12px;
border-radius: $radius-md;
border: 1px solid transparent;
background: transparent;
color: var(--text-primary);
cursor: pointer;
text-align: left;
font-size: 13px;
font-weight: 500;
transition: background-color 0.15s ease, border-color 0.15s ease;
&:hover {
background: var(--bg-secondary);
}
}
.timeRangeOptionActive {
border-color: rgba(59, 130, 246, 0.5);
background: rgba(59, 130, 246, 0.10);
font-weight: 600;
}
.pageTitle {
@@ -149,7 +57,7 @@
.errorBox {
padding: 10px;
background-color: rgba(239, 68, 68, 0.1);
background-color: rgba($error-color, 0.1);
border: 1px solid var(--error-color);
border-radius: $radius-sm;
color: var(--error-color);
@@ -170,15 +78,11 @@
display: flex;
align-items: center;
justify-content: center;
background: rgba(243, 244, 246, 0.75);
background: color-mix(in srgb, var(--bg-secondary) 78%, transparent);
backdrop-filter: blur(6px);
-webkit-backdrop-filter: blur(6px);
}
:global([data-theme='dark']) .loadingOverlay {
background: rgba(25, 25, 25, 0.72);
}
.loadingOverlayContent {
display: inline-flex;
align-items: center;
@@ -191,9 +95,9 @@
}
.loadingOverlaySpinner {
border-color: rgba(59, 130, 246, 0.25);
border-color: rgba($primary-color, 0.25);
border-top-color: var(--primary-color);
box-shadow: 0 0 10px rgba(59, 130, 246, 0.25);
box-shadow: 0 0 10px rgba($primary-color, 0.25);
}
.loadingOverlayText {
@@ -214,9 +118,9 @@
}
.statCard {
--accent: #3b82f6;
--accent-soft: rgba(59, 130, 246, 0.18);
--accent-border: rgba(59, 130, 246, 0.35);
--accent: var(--primary-color);
--accent-soft: rgba($primary-color, 0.18);
--accent-border: rgba($primary-color, 0.35);
grid-column: span 4;
position: relative;
@@ -366,11 +270,11 @@
}
.statSuccess {
color: var(--success-color, #22c55e);
color: var(--success-color);
}
.statFailure {
color: var(--danger-color, #ef4444);
color: var(--danger-color);
}
.statNeutral {
@@ -459,8 +363,8 @@
}
.apiSortBtnActive {
border-color: rgba(59, 130, 246, 0.5);
background: rgba(59, 130, 246, 0.10);
border-color: rgba($primary-color, 0.5);
background: rgba($primary-color, 0.1);
color: var(--text-primary);
font-weight: 600;
}
@@ -745,24 +649,6 @@
}
}
.select {
padding: 10px 12px;
border: 1px solid var(--border-color);
border-radius: $radius-md;
background-color: var(--bg-primary);
color: var(--text-primary);
font-size: 14px;
cursor: pointer;
height: 40px;
box-sizing: border-box;
&:focus {
outline: none;
border-color: var(--primary-color);
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.15);
}
}
.pricesList {
display: flex;
flex-direction: column;
@@ -831,6 +717,12 @@
flex-shrink: 0;
}
.editModalBody {
display: flex;
flex-direction: column;
gap: 12px;
}
// Chart Section (80%比例)
.chartSection {
display: flex;
@@ -1018,3 +910,266 @@
color: var(--text-tertiary);
margin: 10px 0 0 0;
}
// Service Health Card
.healthCard {
background: var(--bg-primary);
border: 1px solid var(--border-color);
border-radius: $radius-lg;
padding: 18px;
display: flex;
flex-direction: column;
gap: 14px;
box-shadow: var(--shadow-lg);
}
.healthHeader {
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
flex-wrap: wrap;
}
.healthTitle {
font-size: 14px;
font-weight: 700;
color: var(--text-primary);
margin: 0;
}
.healthMeta {
display: flex;
align-items: center;
gap: 10px;
}
.healthWindow {
font-size: 11px;
color: var(--text-tertiary);
}
.healthRate {
display: flex;
align-items: center;
font-size: 12px;
font-weight: 600;
white-space: nowrap;
padding: 4px 8px;
border-radius: 6px;
background: var(--bg-tertiary);
}
.healthRateHigh {
color: var(--success-badge-text, #065f46);
background: var(--success-badge-bg, #d1fae5);
}
.healthRateMedium {
color: var(--warning-text, #92400e);
background: var(--warning-bg, #fef3c7);
}
.healthRateLow {
color: var(--failure-badge-text);
background: var(--failure-badge-bg);
}
.healthGridScroller {
overflow-x: auto;
overflow-y: hidden;
-webkit-overflow-scrolling: touch;
}
.healthGrid {
display: grid;
gap: 3px;
grid-auto-flow: column;
grid-template-rows: repeat(7, 10px);
width: fit-content;
margin: 0 auto;
}
.healthBlockWrapper {
position: relative;
cursor: pointer;
-webkit-tap-highlight-color: transparent;
width: 10px;
height: 10px;
}
.healthBlock {
width: 100%;
height: 100%;
border-radius: 2px;
transition: transform 0.15s ease, opacity 0.15s ease;
.healthBlockWrapper:hover &,
.healthBlockWrapper.healthBlockActive & {
transform: scaleY(1.6);
opacity: 0.85;
}
}
.healthBlockIdle {
background-color: var(--border-secondary, #e5e7eb);
}
.healthTooltip {
position: absolute;
bottom: calc(100% + 8px);
left: 50%;
transform: translateX(-50%);
background: var(--bg-primary, #fff);
border: 1px solid var(--border-secondary, #e5e7eb);
border-radius: 6px;
padding: 6px 10px;
font-size: 11px;
line-height: 1.5;
white-space: nowrap;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.12);
z-index: $z-dropdown;
pointer-events: none;
color: var(--text-primary);
&::after {
content: '';
position: absolute;
top: 100%;
left: 50%;
transform: translateX(-50%);
border: 5px solid transparent;
border-top-color: var(--bg-primary, #fff);
}
&::before {
content: '';
position: absolute;
top: 100%;
left: 50%;
transform: translateX(-50%);
border: 6px solid transparent;
border-top-color: var(--border-secondary, #e5e7eb);
}
}
// When tooltip should appear below (for top rows)
.healthTooltipBelow {
bottom: auto;
top: calc(100% + 8px);
&::after {
top: auto;
bottom: 100%;
border-top-color: transparent;
border-bottom-color: var(--bg-primary, #fff);
}
&::before {
top: auto;
bottom: 100%;
border-top-color: transparent;
border-bottom-color: var(--border-secondary, #e5e7eb);
}
}
.healthTooltipLeft {
left: 0;
transform: translateX(0);
&::after,
&::before {
left: 8px;
transform: none;
}
}
.healthTooltipRight {
left: auto;
right: 0;
transform: translateX(0);
&::after,
&::before {
left: auto;
right: 8px;
transform: none;
}
}
.healthTooltipTime {
color: var(--text-secondary);
display: block;
margin-bottom: 2px;
}
.healthTooltipStats {
display: flex;
align-items: center;
gap: 8px;
}
.healthTooltipSuccess {
color: var(--success-color, #22c55e);
}
.healthTooltipFailure {
color: var(--danger-color, #ef4444);
}
.healthTooltipRate {
color: var(--text-secondary);
margin-left: 2px;
}
.healthLegend {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
flex-wrap: wrap;
}
.healthLegendLabel {
font-size: 10px;
color: var(--text-tertiary);
}
.healthLegendColors {
display: flex;
gap: 3px;
}
.healthLegendBlock {
width: 10px;
height: 10px;
border-radius: 2px;
}
@include mobile {
.healthCard {
padding: 14px;
gap: 10px;
}
.healthGrid {
grid-template-rows: repeat(7, 6px);
gap: 2px;
margin: 0;
}
.healthBlockWrapper {
width: 6px;
height: 6px;
}
.healthTooltip {
font-size: 10px;
padding: 4px 8px;
}
.healthLegendBlock {
width: 8px;
height: 8px;
}
}
+23 -53
View File
@@ -1,4 +1,4 @@
import { useState, useMemo, useCallback, useEffect, useRef } from 'react';
import { useState, useMemo, useCallback, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import {
Chart as ChartJS,
@@ -13,7 +13,7 @@ import {
} from 'chart.js';
import { Button } from '@/components/ui/Button';
import { LoadingSpinner } from '@/components/ui/LoadingSpinner';
import { IconChevronDown } from '@/components/ui/icons';
import { Select } from '@/components/ui/Select';
import { useMediaQuery } from '@/hooks/useMediaQuery';
import { useHeaderRefresh } from '@/hooks/useHeaderRefresh';
import { useThemeStore, useConfigStore } from '@/stores';
@@ -27,6 +27,7 @@ import {
CredentialStatsCard,
TokenBreakdownChart,
CostTrendChart,
ServiceHealthCard,
useUsageData,
useSparklines,
useChartData
@@ -120,19 +121,6 @@ export function UsagePage() {
const isDark = resolvedTheme === 'dark';
const config = useConfigStore((state) => state.config);
// Time range dropdown
const [timeRangeOpen, setTimeRangeOpen] = useState(false);
const timeRangeRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
if (!timeRangeOpen) return;
const handleClickOutside = (event: MouseEvent) => {
if (!timeRangeRef.current?.contains(event.target as Node)) setTimeRangeOpen(false);
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, [timeRangeOpen]);
// Data hook
const {
usage,
@@ -156,6 +144,15 @@ export function UsagePage() {
const [chartLines, setChartLines] = useState<string[]>(loadChartLines);
const [timeRange, setTimeRange] = useState<UsageTimeRange>(loadTimeRange);
const timeRangeOptions = useMemo(
() =>
TIME_RANGE_OPTIONS.map((opt) => ({
value: opt.value,
label: t(opt.labelKey)
})),
[t]
);
const filteredUsage = useMemo(
() => (usage ? filterUsageByTimeRange(usage, timeRange) : null),
[usage, timeRange]
@@ -238,44 +235,14 @@ export function UsagePage() {
<div className={styles.headerActions}>
<div className={styles.timeRangeGroup}>
<span className={styles.timeRangeLabel}>{t('usage_stats.range_filter')}</span>
<div className={styles.timeRangeSelectWrap} ref={timeRangeRef}>
<button
type="button"
className={styles.timeRangeSelect}
onClick={() => setTimeRangeOpen((prev) => !prev)}
aria-haspopup="listbox"
aria-expanded={timeRangeOpen}
>
<span className={styles.timeRangeSelectedText}>
{t(TIME_RANGE_OPTIONS.find((o) => o.value === timeRange)?.labelKey ?? 'usage_stats.range_24h')}
</span>
<span className={styles.timeRangeSelectIcon} aria-hidden="true">
<IconChevronDown size={14} />
</span>
</button>
{timeRangeOpen && (
<div className={styles.timeRangeDropdown} role="listbox" aria-label={t('usage_stats.range_filter')}>
{TIME_RANGE_OPTIONS.map((opt) => {
const active = opt.value === timeRange;
return (
<button
key={opt.value}
type="button"
role="option"
aria-selected={active}
className={`${styles.timeRangeOption} ${active ? styles.timeRangeOptionActive : ''}`}
onClick={() => {
setTimeRange(opt.value);
setTimeRangeOpen(false);
}}
>
{t(opt.labelKey)}
</button>
);
})}
</div>
)}
</div>
<Select
value={timeRange}
options={timeRangeOptions}
onChange={(value) => setTimeRange(value as UsageTimeRange)}
className={styles.timeRangeSelectControl}
ariaLabel={t('usage_stats.range_filter')}
fullWidth={false}
/>
</div>
<Button
variant="secondary"
@@ -342,6 +309,9 @@ export function UsagePage() {
onChange={handleChartLinesChange}
/>
{/* Service Health */}
<ServiceHealthCard usage={usage} loading={loading} />
{/* Charts Grid */}
<div className={styles.chartsGrid}>
<UsageChart
+2 -22
View File
@@ -7,10 +7,10 @@ import axios, { AxiosInstance, AxiosRequestConfig, AxiosResponse } from 'axios';
import type { ApiClientConfig, ApiError } from '@/types';
import {
BUILD_DATE_HEADER_KEYS,
MANAGEMENT_API_PREFIX,
REQUEST_TIMEOUT_MS,
VERSION_HEADER_KEYS
} from '@/utils/constants';
import { computeApiUrl } from '@/utils/connection';
class ApiClient {
private instance: AxiosInstance;
@@ -32,7 +32,7 @@ class ApiClient {
* API
*/
setConfig(config: ApiClientConfig): void {
this.apiBase = this.normalizeApiBase(config.apiBase);
this.apiBase = computeApiUrl(config.apiBase);
this.managementKey = config.managementKey;
if (config.timeout) {
@@ -42,26 +42,6 @@ class ApiClient {
}
}
/**
* API Base URL
*/
private normalizeApiBase(base: string): string {
let normalized = base.trim();
// 移除尾部的 /v0/management
normalized = normalized.replace(/\/?v0\/management\/?$/i, '');
// 移除尾部斜杠
normalized = normalized.replace(/\/+$/, '');
// 添加协议
if (!/^https?:\/\//i.test(normalized)) {
normalized = `http://${normalized}`;
}
return `${normalized}${MANAGEMENT_API_PREFIX}`;
}
private readHeader(
headers: Record<string, unknown> | undefined,
keys: string[]
+3 -13
View File
@@ -4,27 +4,17 @@
import axios from 'axios';
import { normalizeModelList } from '@/utils/models';
import { normalizeApiBase } from '@/utils/connection';
import { apiCallApi, getApiCallErrorMessage } from './apiCall';
const normalizeBaseUrl = (baseUrl: string): string => {
let normalized = String(baseUrl || '').trim();
if (!normalized) return '';
normalized = normalized.replace(/\/?v0\/management\/?$/i, '');
normalized = normalized.replace(/\/+$/g, '');
if (!/^https?:\/\//i.test(normalized)) {
normalized = `http://${normalized}`;
}
return normalized;
};
const buildModelsEndpoint = (baseUrl: string): string => {
const normalized = normalizeBaseUrl(baseUrl);
const normalized = normalizeApiBase(baseUrl);
if (!normalized) return '';
return `${normalized}/models`;
};
const buildV1ModelsEndpoint = (baseUrl: string): string => {
const normalized = normalizeBaseUrl(baseUrl);
const normalized = normalizeApiBase(baseUrl);
if (!normalized) return '';
return `${normalized}/v1/models`;
};
+1
View File
@@ -99,6 +99,7 @@ const serializeGeminiKey = (config: GeminiKeyConfig) => {
const payload: Record<string, unknown> = { 'api-key': config.apiKey };
if (config.prefix?.trim()) payload.prefix = config.prefix.trim();
if (config.baseUrl) payload['base-url'] = config.baseUrl;
if (config.proxyUrl) payload['proxy-url'] = config.proxyUrl;
const headers = serializeHeaders(config.headers);
if (headers) payload.headers = headers;
if (config.excludedModels && config.excludedModels.length) {
+2
View File
@@ -153,6 +153,8 @@ const normalizeGeminiKeyConfig = (item: unknown): GeminiKeyConfig | null => {
if (prefix) config.prefix = prefix;
const baseUrl = record ? record['base-url'] ?? record.baseUrl ?? record['base_url'] : undefined;
if (baseUrl) config.baseUrl = String(baseUrl);
const proxyUrl = record ? record['proxy-url'] ?? record.proxyUrl ?? record['proxy_url'] : undefined;
if (proxyUrl) config.proxyUrl = String(proxyUrl);
const headers = normalizeHeaders(record?.headers);
if (headers) config.headers = headers;
const excludedModels = normalizeExcludedModels(record?.['excluded-models'] ?? record?.excludedModels);
+35 -15
View File
@@ -17,7 +17,7 @@
&.btn-primary {
background-color: var(--primary-color);
color: #fff;
color: var(--primary-contrast, #fff);
border-color: var(--primary-color);
&:hover {
@@ -32,6 +32,7 @@
color: var(--text-primary);
&:hover {
background-color: var(--bg-hover, var(--bg-tertiary));
border-color: var(--border-hover);
}
}
@@ -43,7 +44,7 @@
&:hover {
color: var(--text-primary);
background: var(--bg-secondary);
background: var(--bg-tertiary);
}
}
@@ -72,20 +73,31 @@
}
}
:global([data-theme='dark']) {
.btn {
color: #fff;
}
.btn.btn-secondary,
.btn.btn-ghost {
color: #fff;
}
}
.input,
textarea {
width: 100%;
border: 1px solid var(--border-color);
border-radius: $radius-md;
padding: 10px 12px;
background-color: var(--bg-primary);
background-color: var(--bg-secondary);
color: var(--text-primary);
transition: border-color $transition-fast, box-shadow $transition-fast;
&:focus {
outline: none;
border-color: var(--primary-color);
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.15);
box-shadow: 0 0 0 3px rgba($primary-color, 0.18);
}
}
@@ -144,20 +156,20 @@ textarea {
&.success {
color: $success-color;
border-color: rgba(16, 185, 129, 0.35);
background: rgba(16, 185, 129, 0.08);
border-color: rgba($success-color, 0.35);
background: rgba($success-color, 0.08);
}
&.warning {
color: $warning-color;
border-color: rgba(245, 158, 11, 0.35);
background: rgba(245, 158, 11, 0.08);
border-color: rgba($warning-color, 0.35);
background: rgba($warning-color, 0.08);
}
&.error {
color: $error-color;
border-color: rgba(239, 68, 68, 0.35);
background: rgba(239, 68, 68, 0.08);
border-color: rgba($error-color, 0.35);
background: rgba($error-color, 0.08);
}
&.muted {
@@ -220,13 +232,13 @@ textarea {
}
&.success {
border-color: rgba(16, 185, 129, 0.4);
border-color: rgba($success-color, 0.4);
}
&.warning {
border-color: rgba(245, 158, 11, 0.4);
border-color: rgba($warning-color, 0.4);
}
&.error {
border-color: rgba(239, 68, 68, 0.4);
border-color: rgba($error-color, 0.4);
}
.message {
@@ -324,6 +336,7 @@ textarea {
}
.loading-spinner {
// Fallback: legacy white spinner (in case color-mix is unsupported)
border: 3px solid rgba(255, 255, 255, 0.2);
border-top-color: #fff;
border-radius: 50%;
@@ -332,6 +345,13 @@ textarea {
animation: spin 0.8s linear infinite;
}
@supports (color: color-mix(in srgb, currentColor 22%, transparent)) {
.loading-spinner {
border-color: color-mix(in srgb, currentColor 22%, transparent);
border-top-color: currentColor;
}
}
@keyframes spin {
from {
transform: rotate(0deg);
@@ -611,8 +631,8 @@ textarea {
}
.error-box {
background: rgba(239, 68, 68, 0.1);
border: 1px solid rgba(239, 68, 68, 0.4);
background: rgba($error-color, 0.1);
border: 1px solid rgba($error-color, 0.4);
border-radius: $radius-md;
padding: $spacing-sm $spacing-md;
color: $error-color;
+3 -3
View File
@@ -229,7 +229,7 @@
&:focus-visible {
outline: none;
background: var(--bg-secondary);
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.2);
box-shadow: 0 0 0 2px rgba($primary-color, 0.22);
}
&.active {
@@ -382,9 +382,9 @@
}
&.active {
background: rgba(59, 130, 246, 0.12);
background: rgba($primary-color, 0.14);
color: var(--primary-color);
border: 1px solid rgba(59, 130, 246, 0.3);
border: 1px solid rgba($primary-color, 0.35);
}
}
+72 -38
View File
@@ -4,74 +4,108 @@
// 浅色主题默认
:root {
--bg-primary: #ffffff;
--bg-secondary: #f3f4f6;
--bg-tertiary: #e5e7eb;
// 极简暖灰浅色模式
--bg-secondary: #faf9f5; // 页面背景纸感
--bg-primary: #f0eee8; // 容器/卡片背景
--bg-tertiary: #e9e6df; // hover/次级背景
--bg-hover: var(--bg-tertiary);
--bg-quinary: #f6f4ee;
--bg-error-light: rgba(198, 87, 70, 0.1);
--text-primary: #1f2937;
--text-secondary: #6b7280;
--text-tertiary: #9ca3af;
--text-primary: #2d2a26;
--text-secondary: #6d6760;
--text-tertiary: #a29c95;
--text-quaternary: #c0bab3;
--text-muted: var(--text-tertiary);
--border-color: #e5e7eb;
--border-hover: #d1d5db;
--border-color: #e3e1db; // 边框/分割线
--border-secondary: var(--border-color);
--border-primary: #d5d2cb;
--border-hover: #cecac4;
--primary-color: #3b82f6;
--primary-hover: #2563eb;
--primary-active: #1d4ed8;
--primary-color: #8b8680; // 行动色主色
--primary-hover: #7f7a74;
--primary-active: #726d67;
--primary-contrast: #ffffff;
--success-color: #10b981;
--warning-color: #f59e0b;
--error-color: #ef4444;
--info-color: #3b82f6;
--warning-color: #c65746; // 错误/警告色
--error-color: #c65746;
--danger-color: var(--error-color);
--info-color: var(--primary-color);
--warning-bg: rgba(198, 87, 70, 0.12);
--warning-border: rgba(198, 87, 70, 0.35);
--warning-text: var(--warning-color);
--success-badge-bg: #d1fae5;
--success-badge-text: #065f46;
--success-badge-border: #6ee7b7;
--failure-badge-bg: #fee2e2;
--failure-badge-text: #991b1b;
--failure-badge-border: #fca5a5;
--failure-badge-bg: rgba(198, 87, 70, 0.14);
--failure-badge-text: #8a3a30;
--failure-badge-border: rgba(198, 87, 70, 0.35);
--count-badge-bg: rgba(59, 130, 246, 0.14);
--count-badge-bg: rgba(139, 134, 128, 0.18);
--count-badge-text: var(--primary-active);
--shadow: 0 1px 3px 0 rgb(0 0 0 / 0.1);
--shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.1);
--shadow: 0 1px 2px 0 rgb(0 0 0 / 0.08);
--shadow-lg: 0 10px 18px -3px rgb(0 0 0 / 0.1);
--radius-md: 8px;
--accent-tertiary: var(--bg-tertiary);
}
// 深色主题#191919
[data-theme='dark'] {
--bg-primary: #202020;
--bg-secondary: #191919;
--bg-tertiary: #262626;
// 极简暖灰深色模式提升对比度与层级
--bg-secondary: #151412; // 页面背景
--bg-primary: #1d1b18; // 容器/卡片背景
--bg-tertiary: #262320; // hover/次级背景
--bg-hover: #2e2a26;
--bg-quinary: #191714;
--bg-error-light: rgba(198, 87, 70, 0.18);
--text-primary: #fafafa;
--text-secondary: #a3a3a3;
--text-tertiary: #737373;
--text-primary: #f6f4f1;
--text-secondary: #c9c3bb;
--text-tertiary: #9c958d;
--text-quaternary: #6f6962;
--text-muted: var(--text-tertiary);
--border-color: #262626;
--border-hover: #404040;
--border-color: #3a3530;
--border-secondary: var(--border-color);
--border-primary: #4a453f;
--border-hover: #5a544d;
--primary-color: #3b82f6;
--primary-hover: #60a5fa;
--primary-active: #93c5fd;
--primary-color: #8b8680;
--primary-hover: #9a948e;
--primary-active: #a6a099;
--primary-contrast: #ffffff;
--success-color: #10b981;
--warning-color: #f59e0b;
--error-color: #ef4444;
--info-color: #3b82f6;
--warning-color: #c65746;
--error-color: #c65746;
--danger-color: var(--error-color);
--info-color: var(--primary-color);
--warning-bg: rgba(198, 87, 70, 0.22);
--warning-border: rgba(198, 87, 70, 0.45);
--warning-text: #f1b0a6;
--success-badge-bg: rgba(6, 78, 59, 0.3);
--success-badge-text: #6ee7b7;
--success-badge-border: #059669;
--failure-badge-bg: rgba(153, 27, 27, 0.3);
--failure-badge-text: #fca5a5;
--failure-badge-border: #dc2626;
--failure-badge-bg: rgba(198, 87, 70, 0.24);
--failure-badge-text: #f1b0a6;
--failure-badge-border: rgba(198, 87, 70, 0.5);
--count-badge-bg: rgba(59, 130, 246, 0.25);
--count-badge-bg: rgba(139, 134, 128, 0.28);
--count-badge-text: var(--primary-active);
--shadow: 0 1px 3px 0 rgb(0 0 0 / 0.3);
--shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.3);
--radius-md: 8px;
--accent-tertiary: var(--bg-tertiary);
}
+4 -4
View File
@@ -3,11 +3,11 @@
*/
// 颜色
$primary-color: #3b82f6;
$primary-color: #8b8680;
$success-color: #10b981;
$warning-color: #f59e0b;
$error-color: #ef4444;
$info-color: #3b82f6;
$warning-color: #c65746;
$error-color: #c65746;
$info-color: #8b8680;
// 灰阶
$gray-50: #f9fafb;
+1
View File
@@ -20,6 +20,7 @@ export interface GeminiKeyConfig {
apiKey: string;
prefix?: string;
baseUrl?: string;
proxyUrl?: string;
headers?: Record<string, string>;
excludedModels?: string[];
}
+12
View File
@@ -88,6 +88,15 @@ export interface CodexRateLimitInfo {
secondaryWindow?: CodexUsageWindow | null;
}
export interface CodexAdditionalRateLimit {
limit_name?: string;
limitName?: string;
metered_feature?: string;
meteredFeature?: string;
rate_limit?: CodexRateLimitInfo | null;
rateLimit?: CodexRateLimitInfo | null;
}
export interface CodexUsagePayload {
plan_type?: string;
planType?: string;
@@ -95,6 +104,8 @@ export interface CodexUsagePayload {
rateLimit?: CodexRateLimitInfo | null;
code_review_rate_limit?: CodexRateLimitInfo | null;
codeReviewRateLimit?: CodexRateLimitInfo | null;
additional_rate_limits?: CodexAdditionalRateLimit[] | null;
additionalRateLimits?: CodexAdditionalRateLimit[] | null;
}
// Claude API payload types
@@ -174,6 +185,7 @@ export interface CodexQuotaWindow {
id: string;
label: string;
labelKey?: string;
labelParams?: Record<string, string | number>;
usedPercent: number | null;
resetLabel: string;
}
+49
View File
@@ -0,0 +1,49 @@
export async function copyToClipboard(text: string): Promise<boolean> {
try {
if (typeof navigator !== 'undefined' && navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(text);
return true;
}
} catch {
// fallback below
}
try {
if (typeof document === 'undefined') return false;
if (!document.body) return false;
const activeElement = document.activeElement as HTMLElement | null;
const textarea = document.createElement('textarea');
textarea.value = text;
textarea.setAttribute('readonly', '');
textarea.style.position = 'fixed';
textarea.style.opacity = '0';
textarea.style.pointerEvents = 'none';
textarea.style.left = '-9999px';
textarea.style.top = '0';
textarea.style.width = '1px';
textarea.style.height = '1px';
textarea.style.padding = '0';
textarea.style.border = '0';
document.body.appendChild(textarea);
textarea.focus();
textarea.select();
textarea.setSelectionRange(0, textarea.value.length);
const copied = document.execCommand('copy');
document.body.removeChild(textarea);
if (activeElement?.focus) {
try {
activeElement.focus();
} catch {
// ignore
}
}
return copied;
} catch {
return false;
}
}
+14 -4
View File
@@ -3,6 +3,14 @@
* src/utils/string.js
*/
const resolveDefaultLocale = (): string | undefined => {
const fromDocument =
typeof document !== 'undefined' ? document.documentElement?.lang?.trim() : '';
if (fromDocument) return fromDocument;
const fromNavigator = typeof navigator !== 'undefined' ? navigator.language?.trim() : '';
return fromNavigator || undefined;
};
/**
* API Key
*/
@@ -38,14 +46,15 @@ export function formatFileSize(bytes: number): string {
/**
*
*/
export function formatDateTime(date: string | Date): string {
export function formatDateTime(date: string | Date, locale?: string): string {
const d = typeof date === 'string' ? new Date(date) : date;
if (isNaN(d.getTime())) {
return 'Invalid Date';
}
return d.toLocaleString('zh-CN', {
const resolvedLocale = locale?.trim() || resolveDefaultLocale();
return d.toLocaleString(resolvedLocale, {
year: 'numeric',
month: '2-digit',
day: '2-digit',
@@ -89,8 +98,9 @@ export function formatUnixTimestamp(value: unknown, locale?: string): string {
/**
*
*/
export function formatNumber(num: number): string {
return num.toLocaleString('zh-CN');
export function formatNumber(num: number, locale?: string): string {
const resolvedLocale = locale?.trim() || resolveDefaultLocale();
return num.toLocaleString(resolvedLocale);
}
/**
+135 -14
View File
@@ -1003,10 +1003,10 @@ export interface ChartData {
}
const CHART_COLORS = [
{ borderColor: '#3b82f6', backgroundColor: 'rgba(59, 130, 246, 0.15)' },
{ borderColor: '#8b8680', backgroundColor: 'rgba(139, 134, 128, 0.15)' },
{ borderColor: '#22c55e', backgroundColor: 'rgba(34, 197, 94, 0.15)' },
{ borderColor: '#f59e0b', backgroundColor: 'rgba(245, 158, 11, 0.15)' },
{ borderColor: '#ef4444', backgroundColor: 'rgba(239, 68, 68, 0.15)' },
{ borderColor: '#c65746', backgroundColor: 'rgba(198, 87, 70, 0.15)' },
{ borderColor: '#8b5cf6', backgroundColor: 'rgba(139, 92, 246, 0.15)' },
{ borderColor: '#06b6d4', backgroundColor: 'rgba(6, 182, 212, 0.15)' },
{ borderColor: '#ec4899', backgroundColor: 'rgba(236, 72, 153, 0.15)' },
@@ -1117,11 +1117,26 @@ export function buildChartData(
*/
export type StatusBlockState = 'success' | 'failure' | 'mixed' | 'idle';
/**
*
*/
export interface StatusBlockDetail {
success: number;
failure: number;
/** 该格子的成功率 (0–1),无请求时为 -1 */
rate: number;
/** 格子起始时间戳 (ms) */
startTime: number;
/** 格子结束时间戳 (ms) */
endTime: number;
}
/**
*
*/
export interface StatusBarData {
blocks: StatusBlockState[];
blockDetails: StatusBlockDetail[];
successRate: number;
totalSuccess: number;
totalFailure: number;
@@ -1138,7 +1153,7 @@ export function calculateStatusBarData(
): StatusBarData {
const BLOCK_COUNT = 20;
const BLOCK_DURATION_MS = 10 * 60 * 1000; // 10 minutes
const WINDOW_MS = 200 * 60 * 1000; // 200 minutes
const WINDOW_MS = BLOCK_COUNT * BLOCK_DURATION_MS; // 200 minutes
const now = Date.now();
const windowStart = now - WINDOW_MS;
@@ -1182,18 +1197,30 @@ export function calculateStatusBarData(
}
});
// Convert stats to block states
const blocks: StatusBlockState[] = blockStats.map((stat) => {
if (stat.success === 0 && stat.failure === 0) {
return 'idle';
// Convert stats to block states and build details
const blocks: StatusBlockState[] = [];
const blockDetails: StatusBlockDetail[] = [];
blockStats.forEach((stat, idx) => {
const total = stat.success + stat.failure;
if (total === 0) {
blocks.push('idle');
} else if (stat.failure === 0) {
blocks.push('success');
} else if (stat.success === 0) {
blocks.push('failure');
} else {
blocks.push('mixed');
}
if (stat.failure === 0) {
return 'success';
}
if (stat.success === 0) {
return 'failure';
}
return 'mixed';
const blockStartTime = windowStart + idx * BLOCK_DURATION_MS;
blockDetails.push({
success: stat.success,
failure: stat.failure,
rate: total > 0 ? stat.success / total : -1,
startTime: blockStartTime,
endTime: blockStartTime + BLOCK_DURATION_MS,
});
});
// Calculate success rate
@@ -1202,12 +1229,106 @@ export function calculateStatusBarData(
return {
blocks,
blockDetails,
successRate,
totalSuccess,
totalFailure
};
}
/**
* 168/77×96
* 15
*/
export interface ServiceHealthData {
blocks: StatusBlockState[];
blockDetails: StatusBlockDetail[];
successRate: number;
totalSuccess: number;
totalFailure: number;
rows: number;
cols: number;
}
export function calculateServiceHealthData(
usageDetails: UsageDetail[]
): ServiceHealthData {
const ROWS = 7;
const COLS = 96;
const BLOCK_COUNT = ROWS * COLS; // 672
const BLOCK_DURATION_MS = 15 * 60 * 1000; // 15 minutes
const WINDOW_MS = BLOCK_COUNT * BLOCK_DURATION_MS; // 168 hours (7 days)
const now = Date.now();
const windowStart = now - WINDOW_MS;
const blockStats: Array<{ success: number; failure: number }> = Array.from(
{ length: BLOCK_COUNT },
() => ({ success: 0, failure: 0 })
);
let totalSuccess = 0;
let totalFailure = 0;
usageDetails.forEach((detail) => {
const timestamp = Date.parse(detail.timestamp);
if (Number.isNaN(timestamp) || timestamp < windowStart || timestamp > now) {
return;
}
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;
}
}
});
const blocks: StatusBlockState[] = [];
const blockDetails: StatusBlockDetail[] = [];
blockStats.forEach((stat, idx) => {
const total = stat.success + stat.failure;
if (total === 0) {
blocks.push('idle');
} else if (stat.failure === 0) {
blocks.push('success');
} else if (stat.success === 0) {
blocks.push('failure');
} else {
blocks.push('mixed');
}
const blockStartTime = windowStart + idx * BLOCK_DURATION_MS;
blockDetails.push({
success: stat.success,
failure: stat.failure,
rate: total > 0 ? stat.success / total : -1,
startTime: blockStartTime,
endTime: blockStartTime + BLOCK_DURATION_MS,
});
});
const total = totalSuccess + totalFailure;
const successRate = total > 0 ? (totalSuccess / total) * 100 : 100;
return {
blocks,
blockDetails,
successRate,
totalSuccess,
totalFailure,
rows: ROWS,
cols: COLS,
};
}
export function computeKeyStats(usageData: unknown, masker: (val: string) => string = maskApiKey): KeyStats {
const apis = getApisRecord(usageData);
if (!apis) {
+1 -1
View File
@@ -63,7 +63,7 @@ export default defineConfig({
}
},
build: {
target: 'es2015',
target: 'es2020',
outDir: 'dist',
assetsInlineLimit: 100000000,
chunkSizeWarningLimit: 100000000,