mirror of
https://github.com/router-for-me/Cli-Proxy-API-Management-Center.git
synced 2026-02-14 16:50:51 +08:00
Compare commits
47 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
52cf9d86c0 | ||
|
|
a2507b1373 | ||
|
|
1f8c4331c7 | ||
|
|
faadc3ea3e | ||
|
|
32b576123c | ||
|
|
5dce24e3ea | ||
|
|
bf824f8561 | ||
|
|
3a7ddfdff1 | ||
|
|
431ec1e0f5 | ||
|
|
e2368ddfd7 | ||
|
|
6f4bc7c3bb | ||
|
|
3937a403b1 | ||
|
|
f003a34dc0 | ||
|
|
dc4ceabc7b | ||
|
|
e13d7f5e0f | ||
|
|
03a1644df7 | ||
|
|
9a6a8ba7fa | ||
|
|
3b886e47d2 | ||
|
|
06201a9fc4 | ||
|
|
ef448806aa | ||
|
|
8a33f5ab55 | ||
|
|
ab3922f9e6 | ||
|
|
5dbff4c3e0 | ||
|
|
4dde62ac58 | ||
|
|
1d3335746b | ||
|
|
c6d00e8b3f | ||
|
|
9ef7d439d2 | ||
|
|
c53a231c41 | ||
|
|
705e6dac54 | ||
|
|
daef2521f1 | ||
|
|
0640edc9c9 | ||
|
|
7068588c58 | ||
|
|
de0753f0ce | ||
|
|
d027d04f64 | ||
|
|
c4ca9be7b5 | ||
|
|
180a4ccab4 | ||
|
|
78512f8039 | ||
|
|
7cdede6de8 | ||
|
|
7ec5329576 | ||
|
|
5d0232e5de | ||
|
|
15c5f742f4 | ||
|
|
b4cd8c946d | ||
|
|
ee9b9f6e14 | ||
|
|
01abe3dc02 | ||
|
|
b957d05636 | ||
|
|
2a4ccff96e | ||
|
|
b5f869ed25 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -10,6 +10,7 @@ api.md
|
||||
usage.json
|
||||
CLAUDE.md
|
||||
AGENTS.md
|
||||
management-api*
|
||||
antigravity_usage.json
|
||||
codex_usage.json
|
||||
style.md
|
||||
|
||||
21
LICENSE
Normal file
21
LICENSE
Normal 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.
|
||||
51
README.md
51
README.md
@@ -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 isn’t)
|
||||
|
||||
- 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`).
|
||||
|
||||
52
README_CN.md
52
README_CN.md
@@ -1,14 +1,14 @@
|
||||
# CLI Proxy API 管理中心
|
||||
|
||||
用于管理与排障 **CLI Proxy API** 的单文件 WebUI(React + TypeScript),通过 **Management API** 完成配置、凭据、日志与统计等运维工作。
|
||||
用于管理与故障排查 **CLI Proxy API** 的单文件 Web UI(React + 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(状态管理)
|
||||
- Axios(HTTP 客户端)
|
||||
- react-router-dom v7(HashRouter)
|
||||
- Chart.js(数据可视化)
|
||||
- CodeMirror 6(YAML 编辑器)
|
||||
- SCSS Modules(样式)
|
||||
- i18next(国际化)
|
||||
|
||||
## 多语言支持
|
||||
|
||||
目前支持三种语言:
|
||||
|
||||
- 英文 (en)
|
||||
- 简体中文 (zh-CN)
|
||||
- 俄文 (ru)
|
||||
|
||||
界面语言会根据浏览器设置自动切换,也可在页面底部手动切换。
|
||||
|
||||
## 浏览器兼容性
|
||||
|
||||
- 构建目标:`ES2020`
|
||||
- 支持 Chrome、Firefox、Safari、Edge 等现代浏览器
|
||||
- 支持移动端响应式布局,可通过手机/平板访问
|
||||
|
||||
## 构建与发布说明
|
||||
|
||||
- 使用 Vite 输出 **单文件 HTML**(`dist/index.html`),资源全部内联(`vite-plugin-singlefile`)。
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 (
|
||||
@@ -428,6 +297,22 @@ function PayloadRulesEditor({
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const rules = value.length ? value : [];
|
||||
const protocolOptions = useMemo(
|
||||
() =>
|
||||
VISUAL_CONFIG_PROTOCOL_OPTIONS.map((option) => ({
|
||||
value: option.value,
|
||||
label: t(option.labelKey, { defaultValue: option.defaultLabel }),
|
||||
})),
|
||||
[t]
|
||||
);
|
||||
const payloadValueTypeOptions = useMemo(
|
||||
() =>
|
||||
VISUAL_CONFIG_PAYLOAD_VALUE_TYPE_OPTIONS.map((option) => ({
|
||||
value: option.value,
|
||||
label: t(option.labelKey, { defaultValue: option.defaultLabel }),
|
||||
})),
|
||||
[t]
|
||||
);
|
||||
|
||||
const addRule = () => onChange([...rules, { id: makeClientId(), models: [], params: [] }]);
|
||||
const removeRule = (ruleIndex: number) => onChange(rules.filter((_, i) => i !== ruleIndex));
|
||||
@@ -531,9 +416,9 @@ function PayloadRulesEditor({
|
||||
>
|
||||
{protocolFirst ? (
|
||||
<>
|
||||
<ToastSelect
|
||||
<Select
|
||||
value={model.protocol ?? ''}
|
||||
options={VISUAL_CONFIG_PROTOCOL_OPTIONS}
|
||||
options={protocolOptions}
|
||||
disabled={disabled}
|
||||
ariaLabel={t('config_management.visual.payload_rules.provider_type')}
|
||||
onChange={(nextValue) =>
|
||||
@@ -559,9 +444,9 @@ function PayloadRulesEditor({
|
||||
onChange={(e) => updateModel(ruleIndex, modelIndex, { name: e.target.value })}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<ToastSelect
|
||||
<Select
|
||||
value={model.protocol ?? ''}
|
||||
options={VISUAL_CONFIG_PROTOCOL_OPTIONS}
|
||||
options={protocolOptions}
|
||||
disabled={disabled}
|
||||
ariaLabel={t('config_management.visual.payload_rules.provider_type')}
|
||||
onChange={(nextValue) =>
|
||||
@@ -601,9 +486,9 @@ function PayloadRulesEditor({
|
||||
onChange={(e) => updateParam(ruleIndex, paramIndex, { path: e.target.value })}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<ToastSelect
|
||||
<Select
|
||||
value={param.valueType}
|
||||
options={VISUAL_CONFIG_PAYLOAD_VALUE_TYPE_OPTIONS}
|
||||
options={payloadValueTypeOptions}
|
||||
disabled={disabled}
|
||||
ariaLabel={t('config_management.visual.payload_rules.param_type')}
|
||||
onChange={(nextValue) =>
|
||||
@@ -671,6 +556,14 @@ function PayloadFilterRulesEditor({
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const rules = value.length ? value : [];
|
||||
const protocolOptions = useMemo(
|
||||
() =>
|
||||
VISUAL_CONFIG_PROTOCOL_OPTIONS.map((option) => ({
|
||||
value: option.value,
|
||||
label: t(option.labelKey, { defaultValue: option.defaultLabel }),
|
||||
})),
|
||||
[t]
|
||||
);
|
||||
|
||||
const addRule = () => onChange([...rules, { id: makeClientId(), models: [], params: [] }]);
|
||||
const removeRule = (ruleIndex: number) => onChange(rules.filter((_, i) => i !== ruleIndex));
|
||||
@@ -736,9 +629,9 @@ function PayloadFilterRulesEditor({
|
||||
onChange={(e) => updateModel(ruleIndex, modelIndex, { name: e.target.value })}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<ToastSelect
|
||||
<Select
|
||||
value={model.protocol ?? ''}
|
||||
options={VISUAL_CONFIG_PROTOCOL_OPTIONS}
|
||||
options={protocolOptions}
|
||||
disabled={disabled}
|
||||
ariaLabel={t('config_management.visual.payload_rules.provider_type')}
|
||||
onChange={(nextValue) =>
|
||||
@@ -989,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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
src/components/ui/Select.module.scss
Normal file
110
src/components/ui/Select.module.scss
Normal 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
src/components/ui/Select.tsx
Normal file
94
src/components/ui/Select.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useState } from 'react';
|
||||
import { useState, useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
import { formatTokensInMillions, formatUsd, type ApiStats } from '@/utils/usage';
|
||||
import { formatCompactNumber, formatUsd, type ApiStats } from '@/utils/usage';
|
||||
import styles from '@/pages/UsagePage.module.scss';
|
||||
|
||||
export interface ApiDetailsCardProps {
|
||||
@@ -10,9 +10,14 @@ export interface ApiDetailsCardProps {
|
||||
hasPrices: boolean;
|
||||
}
|
||||
|
||||
type ApiSortKey = 'endpoint' | 'requests' | 'tokens' | 'cost';
|
||||
type SortDir = 'asc' | 'desc';
|
||||
|
||||
export function ApiDetailsCard({ apiStats, loading, hasPrices }: ApiDetailsCardProps) {
|
||||
const { t } = useTranslation();
|
||||
const [expandedApis, setExpandedApis] = useState<Set<string>>(new Set());
|
||||
const [sortKey, setSortKey] = useState<ApiSortKey>('requests');
|
||||
const [sortDir, setSortDir] = useState<SortDir>('desc');
|
||||
|
||||
const toggleExpand = (endpoint: string) => {
|
||||
setExpandedApis((prev) => {
|
||||
@@ -26,65 +31,125 @@ export function ApiDetailsCard({ apiStats, loading, hasPrices }: ApiDetailsCardP
|
||||
});
|
||||
};
|
||||
|
||||
const handleSort = (key: ApiSortKey) => {
|
||||
if (sortKey === key) {
|
||||
setSortDir((d) => (d === 'asc' ? 'desc' : 'asc'));
|
||||
} else {
|
||||
setSortKey(key);
|
||||
setSortDir(key === 'endpoint' ? 'asc' : 'desc');
|
||||
}
|
||||
};
|
||||
|
||||
const sorted = useMemo(() => {
|
||||
const list = [...apiStats];
|
||||
const dir = sortDir === 'asc' ? 1 : -1;
|
||||
list.sort((a, b) => {
|
||||
switch (sortKey) {
|
||||
case 'endpoint': return dir * a.endpoint.localeCompare(b.endpoint);
|
||||
case 'requests': return dir * (a.totalRequests - b.totalRequests);
|
||||
case 'tokens': return dir * (a.totalTokens - b.totalTokens);
|
||||
case 'cost': return dir * (a.totalCost - b.totalCost);
|
||||
default: return 0;
|
||||
}
|
||||
});
|
||||
return list;
|
||||
}, [apiStats, sortKey, sortDir]);
|
||||
|
||||
const arrow = (key: ApiSortKey) =>
|
||||
sortKey === key ? (sortDir === 'asc' ? ' ▲' : ' ▼') : '';
|
||||
|
||||
return (
|
||||
<Card title={t('usage_stats.api_details')}>
|
||||
<Card title={t('usage_stats.api_details')} className={styles.detailsFixedCard}>
|
||||
{loading ? (
|
||||
<div className={styles.hint}>{t('common.loading')}</div>
|
||||
) : apiStats.length > 0 ? (
|
||||
<div className={styles.apiList}>
|
||||
{apiStats.map((api) => (
|
||||
<div key={api.endpoint} className={styles.apiItem}>
|
||||
<div className={styles.apiHeader} onClick={() => toggleExpand(api.endpoint)}>
|
||||
<div className={styles.apiInfo}>
|
||||
<span className={styles.apiEndpoint}>{api.endpoint}</span>
|
||||
<div className={styles.apiStats}>
|
||||
<span className={styles.apiBadge}>
|
||||
<span className={styles.requestCountCell}>
|
||||
<span>
|
||||
{t('usage_stats.requests_count')}: {api.totalRequests.toLocaleString()}
|
||||
</span>
|
||||
<span className={styles.requestBreakdown}>
|
||||
(<span className={styles.statSuccess}>{api.successCount.toLocaleString()}</span>{' '}
|
||||
<span className={styles.statFailure}>{api.failureCount.toLocaleString()}</span>)
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
<span className={styles.apiBadge}>
|
||||
{t('usage_stats.tokens_count')}: {formatTokensInMillions(api.totalTokens)}
|
||||
</span>
|
||||
{hasPrices && api.totalCost > 0 && (
|
||||
<span className={styles.apiBadge}>
|
||||
{t('usage_stats.total_cost')}: {formatUsd(api.totalCost)}
|
||||
) : sorted.length > 0 ? (
|
||||
<>
|
||||
<div className={styles.apiSortBar}>
|
||||
{([
|
||||
['endpoint', 'usage_stats.api_endpoint'],
|
||||
['requests', 'usage_stats.requests_count'],
|
||||
['tokens', 'usage_stats.tokens_count'],
|
||||
...(hasPrices ? [['cost', 'usage_stats.total_cost']] : []),
|
||||
] as [ApiSortKey, string][]).map(([key, labelKey]) => (
|
||||
<button
|
||||
key={key}
|
||||
type="button"
|
||||
aria-pressed={sortKey === key}
|
||||
className={`${styles.apiSortBtn} ${sortKey === key ? styles.apiSortBtnActive : ''}`}
|
||||
onClick={() => handleSort(key)}
|
||||
>
|
||||
{t(labelKey)}{arrow(key)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className={styles.detailsScroll}>
|
||||
<div className={styles.apiList}>
|
||||
{sorted.map((api, index) => {
|
||||
const isExpanded = expandedApis.has(api.endpoint);
|
||||
const panelId = `api-models-${index}`;
|
||||
|
||||
return (
|
||||
<div key={api.endpoint} className={styles.apiItem}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.apiHeader}
|
||||
onClick={() => toggleExpand(api.endpoint)}
|
||||
aria-expanded={isExpanded}
|
||||
aria-controls={panelId}
|
||||
>
|
||||
<div className={styles.apiInfo}>
|
||||
<span className={styles.apiEndpoint}>{api.endpoint}</span>
|
||||
<div className={styles.apiStats}>
|
||||
<span className={styles.apiBadge}>
|
||||
<span className={styles.requestCountCell}>
|
||||
<span>
|
||||
{t('usage_stats.requests_count')}: {api.totalRequests.toLocaleString()}
|
||||
</span>
|
||||
<span className={styles.requestBreakdown}>
|
||||
(<span className={styles.statSuccess}>{api.successCount.toLocaleString()}</span>{' '}
|
||||
<span className={styles.statFailure}>{api.failureCount.toLocaleString()}</span>)
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
<span className={styles.apiBadge}>
|
||||
{t('usage_stats.tokens_count')}: {formatCompactNumber(api.totalTokens)}
|
||||
</span>
|
||||
{hasPrices && api.totalCost > 0 && (
|
||||
<span className={styles.apiBadge}>
|
||||
{t('usage_stats.total_cost')}: {formatUsd(api.totalCost)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<span className={styles.expandIcon}>
|
||||
{isExpanded ? '▼' : '▶'}
|
||||
</span>
|
||||
</button>
|
||||
{isExpanded && (
|
||||
<div id={panelId} className={styles.apiModels}>
|
||||
{Object.entries(api.models).map(([model, stats]) => (
|
||||
<div key={model} className={styles.modelRow}>
|
||||
<span className={styles.modelName}>{model}</span>
|
||||
<span className={styles.modelStat}>
|
||||
<span className={styles.requestCountCell}>
|
||||
<span>{stats.requests.toLocaleString()}</span>
|
||||
<span className={styles.requestBreakdown}>
|
||||
(<span className={styles.statSuccess}>{stats.successCount.toLocaleString()}</span>{' '}
|
||||
<span className={styles.statFailure}>{stats.failureCount.toLocaleString()}</span>)
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
<span className={styles.modelStat}>{formatCompactNumber(stats.tokens)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<span className={styles.expandIcon}>
|
||||
{expandedApis.has(api.endpoint) ? '▼' : '▶'}
|
||||
</span>
|
||||
</div>
|
||||
{expandedApis.has(api.endpoint) && (
|
||||
<div className={styles.apiModels}>
|
||||
{Object.entries(api.models).map(([model, stats]) => (
|
||||
<div key={model} className={styles.modelRow}>
|
||||
<span className={styles.modelName}>{model}</span>
|
||||
<span className={styles.modelStat}>
|
||||
<span className={styles.requestCountCell}>
|
||||
<span>{stats.requests.toLocaleString()}</span>
|
||||
<span className={styles.requestBreakdown}>
|
||||
(<span className={styles.statSuccess}>{stats.successCount.toLocaleString()}</span>{' '}
|
||||
<span className={styles.statFailure}>{stats.failureCount.toLocaleString()}</span>)
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
<span className={styles.modelStat}>{formatTokensInMillions(stats.tokens)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className={styles.hint}>{t('usage_stats.no_data')}</div>
|
||||
)}
|
||||
|
||||
@@ -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')}
|
||||
|
||||
144
src/components/usage/CostTrendChart.tsx
Normal file
144
src/components/usage/CostTrendChart.tsx
Normal file
@@ -0,0 +1,144 @@
|
||||
import { useState, useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { ScriptableContext } from 'chart.js';
|
||||
import { Line } from 'react-chartjs-2';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import {
|
||||
buildHourlyCostSeries,
|
||||
buildDailyCostSeries,
|
||||
formatUsd,
|
||||
type ModelPrice
|
||||
} from '@/utils/usage';
|
||||
import { buildChartOptions, getHourChartMinWidth } from '@/utils/usage/chartConfig';
|
||||
import type { UsagePayload } from './hooks/useUsageData';
|
||||
import styles from '@/pages/UsagePage.module.scss';
|
||||
|
||||
export interface CostTrendChartProps {
|
||||
usage: UsagePayload | null;
|
||||
loading: boolean;
|
||||
isDark: boolean;
|
||||
isMobile: boolean;
|
||||
modelPrices: Record<string, ModelPrice>;
|
||||
hourWindowHours?: number;
|
||||
}
|
||||
|
||||
const COST_COLOR = '#f59e0b';
|
||||
const COST_BG = 'rgba(245, 158, 11, 0.15)';
|
||||
|
||||
function buildGradient(ctx: ScriptableContext<'line'>) {
|
||||
const chart = ctx.chart;
|
||||
const area = chart.chartArea;
|
||||
if (!area) return COST_BG;
|
||||
const gradient = chart.ctx.createLinearGradient(0, area.top, 0, area.bottom);
|
||||
gradient.addColorStop(0, 'rgba(245, 158, 11, 0.28)');
|
||||
gradient.addColorStop(0.6, 'rgba(245, 158, 11, 0.12)');
|
||||
gradient.addColorStop(1, 'rgba(245, 158, 11, 0.02)');
|
||||
return gradient;
|
||||
}
|
||||
|
||||
export function CostTrendChart({
|
||||
usage,
|
||||
loading,
|
||||
isDark,
|
||||
isMobile,
|
||||
modelPrices,
|
||||
hourWindowHours
|
||||
}: CostTrendChartProps) {
|
||||
const { t } = useTranslation();
|
||||
const [period, setPeriod] = useState<'hour' | 'day'>('hour');
|
||||
const hasPrices = Object.keys(modelPrices).length > 0;
|
||||
|
||||
const { chartData, chartOptions, hasData } = useMemo(() => {
|
||||
if (!hasPrices || !usage) {
|
||||
return { chartData: { labels: [], datasets: [] }, chartOptions: {}, hasData: false };
|
||||
}
|
||||
|
||||
const series =
|
||||
period === 'hour'
|
||||
? buildHourlyCostSeries(usage, modelPrices, hourWindowHours)
|
||||
: buildDailyCostSeries(usage, modelPrices);
|
||||
|
||||
const data = {
|
||||
labels: series.labels,
|
||||
datasets: [
|
||||
{
|
||||
label: t('usage_stats.total_cost'),
|
||||
data: series.data,
|
||||
borderColor: COST_COLOR,
|
||||
backgroundColor: buildGradient,
|
||||
pointBackgroundColor: COST_COLOR,
|
||||
pointBorderColor: COST_COLOR,
|
||||
fill: true,
|
||||
tension: 0.35
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
const baseOptions = buildChartOptions({ period, labels: series.labels, isDark, isMobile });
|
||||
const options = {
|
||||
...baseOptions,
|
||||
scales: {
|
||||
...baseOptions.scales,
|
||||
y: {
|
||||
...baseOptions.scales?.y,
|
||||
ticks: {
|
||||
...(baseOptions.scales?.y && 'ticks' in baseOptions.scales.y ? baseOptions.scales.y.ticks : {}),
|
||||
callback: (value: string | number) => formatUsd(Number(value))
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return { chartData: data, chartOptions: options, hasData: series.hasData };
|
||||
}, [usage, period, isDark, isMobile, modelPrices, hasPrices, hourWindowHours, t]);
|
||||
|
||||
return (
|
||||
<Card
|
||||
title={t('usage_stats.cost_trend')}
|
||||
extra={
|
||||
<div className={styles.periodButtons}>
|
||||
<Button
|
||||
variant={period === 'hour' ? 'primary' : 'secondary'}
|
||||
size="sm"
|
||||
onClick={() => setPeriod('hour')}
|
||||
>
|
||||
{t('usage_stats.by_hour')}
|
||||
</Button>
|
||||
<Button
|
||||
variant={period === 'day' ? 'primary' : 'secondary'}
|
||||
size="sm"
|
||||
onClick={() => setPeriod('day')}
|
||||
>
|
||||
{t('usage_stats.by_day')}
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{loading ? (
|
||||
<div className={styles.hint}>{t('common.loading')}</div>
|
||||
) : !hasPrices ? (
|
||||
<div className={styles.hint}>{t('usage_stats.cost_need_price')}</div>
|
||||
) : !hasData ? (
|
||||
<div className={styles.hint}>{t('usage_stats.cost_no_data')}</div>
|
||||
) : (
|
||||
<div className={styles.chartWrapper}>
|
||||
<div className={styles.chartArea}>
|
||||
<div className={styles.chartScroller}>
|
||||
<div
|
||||
className={styles.chartCanvas}
|
||||
style={
|
||||
period === 'hour'
|
||||
? { minWidth: getHourChartMinWidth(chartData.labels.length, isMobile) }
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<Line data={chartData} options={chartOptions} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
336
src/components/usage/CredentialStatsCard.tsx
Normal file
336
src/components/usage/CredentialStatsCard.tsx
Normal file
@@ -0,0 +1,336 @@
|
||||
import { useMemo, useState, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
import {
|
||||
computeKeyStats,
|
||||
collectUsageDetails,
|
||||
buildCandidateUsageSourceIds,
|
||||
formatCompactNumber
|
||||
} from '@/utils/usage';
|
||||
import { authFilesApi } from '@/services/api/authFiles';
|
||||
import type { GeminiKeyConfig, ProviderKeyConfig, OpenAIProviderConfig } from '@/types';
|
||||
import type { AuthFileItem } from '@/types/authFile';
|
||||
import type { UsagePayload } from './hooks/useUsageData';
|
||||
import styles from '@/pages/UsagePage.module.scss';
|
||||
|
||||
export interface CredentialStatsCardProps {
|
||||
usage: UsagePayload | null;
|
||||
loading: boolean;
|
||||
geminiKeys: GeminiKeyConfig[];
|
||||
claudeConfigs: ProviderKeyConfig[];
|
||||
codexConfigs: ProviderKeyConfig[];
|
||||
vertexConfigs: ProviderKeyConfig[];
|
||||
openaiProviders: OpenAIProviderConfig[];
|
||||
}
|
||||
|
||||
interface CredentialInfo {
|
||||
name: string;
|
||||
type: string;
|
||||
}
|
||||
|
||||
interface CredentialRow {
|
||||
key: string;
|
||||
displayName: string;
|
||||
type: string;
|
||||
success: number;
|
||||
failure: number;
|
||||
total: number;
|
||||
successRate: number;
|
||||
}
|
||||
|
||||
interface CredentialBucket {
|
||||
success: number;
|
||||
failure: number;
|
||||
}
|
||||
|
||||
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 || null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function CredentialStatsCard({
|
||||
usage,
|
||||
loading,
|
||||
geminiKeys,
|
||||
claudeConfigs,
|
||||
codexConfigs,
|
||||
vertexConfigs,
|
||||
openaiProviders,
|
||||
}: CredentialStatsCardProps) {
|
||||
const { t } = useTranslation();
|
||||
const [authFileMap, setAuthFileMap] = useState<Map<string, CredentialInfo>>(new Map());
|
||||
|
||||
// Fetch auth files for auth_index-based matching
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
authFilesApi
|
||||
.list()
|
||||
.then((res) => {
|
||||
if (cancelled) return;
|
||||
const files = Array.isArray(res) ? res : (res as { files?: AuthFileItem[] })?.files;
|
||||
if (!Array.isArray(files)) return;
|
||||
const map = new Map<string, CredentialInfo>();
|
||||
files.forEach((file) => {
|
||||
const rawAuthIndex = file['auth_index'] ?? file.authIndex;
|
||||
const key = normalizeAuthIndexValue(rawAuthIndex);
|
||||
if (key) {
|
||||
map.set(key, {
|
||||
name: file.name || key,
|
||||
type: (file.type || file.provider || '').toString(),
|
||||
});
|
||||
}
|
||||
});
|
||||
setAuthFileMap(map);
|
||||
})
|
||||
.catch(() => {});
|
||||
return () => { cancelled = true; };
|
||||
}, []);
|
||||
|
||||
// Aggregate rows: all from bySource only (no separate byAuthIndex rows to avoid duplicates).
|
||||
// Auth files are used purely for name resolution of unmatched source IDs.
|
||||
const rows = useMemo((): CredentialRow[] => {
|
||||
if (!usage) return [];
|
||||
const { bySource } = computeKeyStats(usage);
|
||||
const details = collectUsageDetails(usage);
|
||||
const result: CredentialRow[] = [];
|
||||
const consumedSourceIds = new Set<string>();
|
||||
const authIndexToRowIndex = new Map<string, number>();
|
||||
const sourceToAuthIndex = new Map<string, string>();
|
||||
const fallbackByAuthIndex = new Map<string, CredentialBucket>();
|
||||
|
||||
const mergeBucketToRow = (index: number, bucket: CredentialBucket) => {
|
||||
const target = result[index];
|
||||
if (!target) return;
|
||||
target.success += bucket.success;
|
||||
target.failure += bucket.failure;
|
||||
target.total = target.success + target.failure;
|
||||
target.successRate = target.total > 0 ? (target.success / target.total) * 100 : 100;
|
||||
};
|
||||
|
||||
// Aggregate all candidate source IDs for one provider config into a single row
|
||||
const addConfigRow = (
|
||||
apiKey: string,
|
||||
prefix: string | undefined,
|
||||
name: string,
|
||||
type: string,
|
||||
rowKey: string,
|
||||
) => {
|
||||
const candidates = buildCandidateUsageSourceIds({ apiKey, prefix });
|
||||
let success = 0;
|
||||
let failure = 0;
|
||||
candidates.forEach((id) => {
|
||||
const bucket = bySource[id];
|
||||
if (bucket) {
|
||||
success += bucket.success;
|
||||
failure += bucket.failure;
|
||||
consumedSourceIds.add(id);
|
||||
}
|
||||
});
|
||||
const total = success + failure;
|
||||
if (total > 0) {
|
||||
result.push({
|
||||
key: rowKey,
|
||||
displayName: name,
|
||||
type,
|
||||
success,
|
||||
failure,
|
||||
total,
|
||||
successRate: (success / total) * 100,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Provider rows — one row per config, stats merged across all its candidate source IDs
|
||||
geminiKeys.forEach((c, i) =>
|
||||
addConfigRow(c.apiKey, c.prefix, c.prefix?.trim() || `Gemini #${i + 1}`, 'gemini', `gemini:${i}`));
|
||||
claudeConfigs.forEach((c, i) =>
|
||||
addConfigRow(c.apiKey, c.prefix, c.prefix?.trim() || `Claude #${i + 1}`, 'claude', `claude:${i}`));
|
||||
codexConfigs.forEach((c, i) =>
|
||||
addConfigRow(c.apiKey, c.prefix, c.prefix?.trim() || `Codex #${i + 1}`, 'codex', `codex:${i}`));
|
||||
vertexConfigs.forEach((c, i) =>
|
||||
addConfigRow(c.apiKey, c.prefix, c.prefix?.trim() || `Vertex #${i + 1}`, 'vertex', `vertex:${i}`));
|
||||
// OpenAI compatibility providers — one row per provider, merged across all apiKey entries (prefix counted once).
|
||||
openaiProviders.forEach((provider, providerIndex) => {
|
||||
const prefix = provider.prefix;
|
||||
const displayName = prefix?.trim() || provider.name || `OpenAI #${providerIndex + 1}`;
|
||||
|
||||
const candidates = new Set<string>();
|
||||
buildCandidateUsageSourceIds({ prefix }).forEach((id) => candidates.add(id));
|
||||
(provider.apiKeyEntries || []).forEach((entry) => {
|
||||
buildCandidateUsageSourceIds({ apiKey: entry.apiKey }).forEach((id) => candidates.add(id));
|
||||
});
|
||||
|
||||
let success = 0;
|
||||
let failure = 0;
|
||||
candidates.forEach((id) => {
|
||||
const bucket = bySource[id];
|
||||
if (bucket) {
|
||||
success += bucket.success;
|
||||
failure += bucket.failure;
|
||||
consumedSourceIds.add(id);
|
||||
}
|
||||
});
|
||||
|
||||
const total = success + failure;
|
||||
if (total > 0) {
|
||||
result.push({
|
||||
key: `openai:${providerIndex}`,
|
||||
displayName,
|
||||
type: 'openai',
|
||||
success,
|
||||
failure,
|
||||
total,
|
||||
successRate: (success / total) * 100,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Build source → auth file name mapping for remaining unmatched entries.
|
||||
// Also collect fallback stats for details without source but with auth_index.
|
||||
const sourceToAuthFile = new Map<string, CredentialInfo>();
|
||||
details.forEach((d) => {
|
||||
const authIdx = normalizeAuthIndexValue(d.auth_index);
|
||||
if (!d.source) {
|
||||
if (!authIdx) return;
|
||||
const fallback = fallbackByAuthIndex.get(authIdx) ?? { success: 0, failure: 0 };
|
||||
if (d.failed === true) {
|
||||
fallback.failure += 1;
|
||||
} else {
|
||||
fallback.success += 1;
|
||||
}
|
||||
fallbackByAuthIndex.set(authIdx, fallback);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!authIdx || consumedSourceIds.has(d.source)) return;
|
||||
if (!sourceToAuthIndex.has(d.source)) {
|
||||
sourceToAuthIndex.set(d.source, authIdx);
|
||||
}
|
||||
if (!sourceToAuthFile.has(d.source)) {
|
||||
const mapped = authFileMap.get(authIdx);
|
||||
if (mapped) sourceToAuthFile.set(d.source, mapped);
|
||||
}
|
||||
});
|
||||
|
||||
// Remaining unmatched bySource entries — resolve name from auth files if possible
|
||||
Object.entries(bySource).forEach(([key, bucket]) => {
|
||||
if (consumedSourceIds.has(key)) return;
|
||||
const total = bucket.success + bucket.failure;
|
||||
const authFile = sourceToAuthFile.get(key);
|
||||
const row = {
|
||||
key,
|
||||
displayName: authFile?.name || (key.startsWith('t:') ? key.slice(2) : key),
|
||||
type: authFile?.type || '',
|
||||
success: bucket.success,
|
||||
failure: bucket.failure,
|
||||
total,
|
||||
successRate: total > 0 ? (bucket.success / total) * 100 : 100,
|
||||
};
|
||||
const rowIndex = result.push(row) - 1;
|
||||
const authIdx = sourceToAuthIndex.get(key);
|
||||
if (authIdx && !authIndexToRowIndex.has(authIdx)) {
|
||||
authIndexToRowIndex.set(authIdx, rowIndex);
|
||||
}
|
||||
});
|
||||
|
||||
// Include requests that have auth_index but missing source.
|
||||
fallbackByAuthIndex.forEach((bucket, authIdx) => {
|
||||
if (bucket.success + bucket.failure === 0) return;
|
||||
|
||||
const mapped = authFileMap.get(authIdx);
|
||||
let targetRowIndex = authIndexToRowIndex.get(authIdx);
|
||||
if (targetRowIndex === undefined && mapped) {
|
||||
const matchedIndex = result.findIndex(
|
||||
(row) => row.displayName === mapped.name && row.type === mapped.type
|
||||
);
|
||||
if (matchedIndex >= 0) {
|
||||
targetRowIndex = matchedIndex;
|
||||
authIndexToRowIndex.set(authIdx, matchedIndex);
|
||||
}
|
||||
}
|
||||
|
||||
if (targetRowIndex !== undefined) {
|
||||
mergeBucketToRow(targetRowIndex, bucket);
|
||||
return;
|
||||
}
|
||||
|
||||
const total = bucket.success + bucket.failure;
|
||||
const rowIndex = result.push({
|
||||
key: `auth:${authIdx}`,
|
||||
displayName: mapped?.name || authIdx,
|
||||
type: mapped?.type || '',
|
||||
success: bucket.success,
|
||||
failure: bucket.failure,
|
||||
total,
|
||||
successRate: (bucket.success / total) * 100
|
||||
}) - 1;
|
||||
authIndexToRowIndex.set(authIdx, rowIndex);
|
||||
});
|
||||
|
||||
return result.sort((a, b) => b.total - a.total);
|
||||
}, [usage, geminiKeys, claudeConfigs, codexConfigs, vertexConfigs, openaiProviders, authFileMap]);
|
||||
|
||||
return (
|
||||
<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>
|
||||
<tr>
|
||||
<th>{t('usage_stats.credential_name')}</th>
|
||||
<th>{t('usage_stats.requests_count')}</th>
|
||||
<th>{t('usage_stats.success_rate')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((row) => (
|
||||
<tr key={row.key}>
|
||||
<td className={styles.modelCell}>
|
||||
<span>{row.displayName}</span>
|
||||
{row.type && (
|
||||
<span className={styles.credentialType}>{row.type}</span>
|
||||
)}
|
||||
</td>
|
||||
<td>
|
||||
<span className={styles.requestCountCell}>
|
||||
<span>{formatCompactNumber(row.total)}</span>
|
||||
<span className={styles.requestBreakdown}>
|
||||
(<span className={styles.statSuccess}>{row.success.toLocaleString()}</span>{' '}
|
||||
<span className={styles.statFailure}>{row.failure.toLocaleString()}</span>)
|
||||
</span>
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<span
|
||||
className={
|
||||
row.successRate >= 95
|
||||
? styles.statSuccess
|
||||
: row.successRate >= 80
|
||||
? styles.statNeutral
|
||||
: styles.statFailure
|
||||
}
|
||||
>
|
||||
{row.successRate.toFixed(1)}%
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className={styles.hint}>{t('usage_stats.no_data')}</div>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState, useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
import { formatTokensInMillions, formatUsd } from '@/utils/usage';
|
||||
import { formatCompactNumber, formatUsd } from '@/utils/usage';
|
||||
import styles from '@/pages/UsagePage.module.scss';
|
||||
|
||||
export interface ModelStat {
|
||||
@@ -18,43 +19,137 @@ export interface ModelStatsCardProps {
|
||||
hasPrices: boolean;
|
||||
}
|
||||
|
||||
type SortKey = 'model' | 'requests' | 'tokens' | 'cost' | 'successRate';
|
||||
type SortDir = 'asc' | 'desc';
|
||||
|
||||
interface ModelStatWithRate extends ModelStat {
|
||||
successRate: number;
|
||||
}
|
||||
|
||||
export function ModelStatsCard({ modelStats, loading, hasPrices }: ModelStatsCardProps) {
|
||||
const { t } = useTranslation();
|
||||
const [sortKey, setSortKey] = useState<SortKey>('requests');
|
||||
const [sortDir, setSortDir] = useState<SortDir>('desc');
|
||||
|
||||
const handleSort = (key: SortKey) => {
|
||||
if (sortKey === key) {
|
||||
setSortDir((d) => (d === 'asc' ? 'desc' : 'asc'));
|
||||
} else {
|
||||
setSortKey(key);
|
||||
setSortDir(key === 'model' ? 'asc' : 'desc');
|
||||
}
|
||||
};
|
||||
|
||||
const sorted = useMemo((): ModelStatWithRate[] => {
|
||||
const list: ModelStatWithRate[] = modelStats.map((s) => ({
|
||||
...s,
|
||||
successRate: s.requests > 0 ? (s.successCount / s.requests) * 100 : 100,
|
||||
}));
|
||||
const dir = sortDir === 'asc' ? 1 : -1;
|
||||
list.sort((a, b) => {
|
||||
if (sortKey === 'model') return dir * a.model.localeCompare(b.model);
|
||||
return dir * ((a[sortKey] as number) - (b[sortKey] as number));
|
||||
});
|
||||
return list;
|
||||
}, [modelStats, sortKey, sortDir]);
|
||||
|
||||
const arrow = (key: SortKey) =>
|
||||
sortKey === key ? (sortDir === 'asc' ? ' ▲' : ' ▼') : '';
|
||||
const ariaSort = (key: SortKey): 'none' | 'ascending' | 'descending' =>
|
||||
sortKey === key ? (sortDir === 'asc' ? 'ascending' : 'descending') : 'none';
|
||||
|
||||
return (
|
||||
<Card title={t('usage_stats.models')}>
|
||||
<Card title={t('usage_stats.models')} className={styles.detailsFixedCard}>
|
||||
{loading ? (
|
||||
<div className={styles.hint}>{t('common.loading')}</div>
|
||||
) : modelStats.length > 0 ? (
|
||||
<div className={styles.tableWrapper}>
|
||||
<table className={styles.table}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{t('usage_stats.model_name')}</th>
|
||||
<th>{t('usage_stats.requests_count')}</th>
|
||||
<th>{t('usage_stats.tokens_count')}</th>
|
||||
{hasPrices && <th>{t('usage_stats.total_cost')}</th>}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{modelStats.map((stat) => (
|
||||
<tr key={stat.model}>
|
||||
<td className={styles.modelCell}>{stat.model}</td>
|
||||
<td>
|
||||
<span className={styles.requestCountCell}>
|
||||
<span>{stat.requests.toLocaleString()}</span>
|
||||
<span className={styles.requestBreakdown}>
|
||||
(<span className={styles.statSuccess}>{stat.successCount.toLocaleString()}</span>{' '}
|
||||
<span className={styles.statFailure}>{stat.failureCount.toLocaleString()}</span>)
|
||||
</span>
|
||||
</span>
|
||||
</td>
|
||||
<td>{formatTokensInMillions(stat.tokens)}</td>
|
||||
{hasPrices && <td>{stat.cost > 0 ? formatUsd(stat.cost) : '--'}</td>}
|
||||
) : sorted.length > 0 ? (
|
||||
<div className={styles.detailsScroll}>
|
||||
<div className={styles.tableWrapper}>
|
||||
<table className={styles.table}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th className={styles.sortableHeader} aria-sort={ariaSort('model')}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.sortHeaderButton}
|
||||
onClick={() => handleSort('model')}
|
||||
>
|
||||
{t('usage_stats.model_name')}{arrow('model')}
|
||||
</button>
|
||||
</th>
|
||||
<th className={styles.sortableHeader} aria-sort={ariaSort('requests')}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.sortHeaderButton}
|
||||
onClick={() => handleSort('requests')}
|
||||
>
|
||||
{t('usage_stats.requests_count')}{arrow('requests')}
|
||||
</button>
|
||||
</th>
|
||||
<th className={styles.sortableHeader} aria-sort={ariaSort('tokens')}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.sortHeaderButton}
|
||||
onClick={() => handleSort('tokens')}
|
||||
>
|
||||
{t('usage_stats.tokens_count')}{arrow('tokens')}
|
||||
</button>
|
||||
</th>
|
||||
<th className={styles.sortableHeader} aria-sort={ariaSort('successRate')}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.sortHeaderButton}
|
||||
onClick={() => handleSort('successRate')}
|
||||
>
|
||||
{t('usage_stats.success_rate')}{arrow('successRate')}
|
||||
</button>
|
||||
</th>
|
||||
{hasPrices && (
|
||||
<th className={styles.sortableHeader} aria-sort={ariaSort('cost')}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.sortHeaderButton}
|
||||
onClick={() => handleSort('cost')}
|
||||
>
|
||||
{t('usage_stats.total_cost')}{arrow('cost')}
|
||||
</button>
|
||||
</th>
|
||||
)}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</thead>
|
||||
<tbody>
|
||||
{sorted.map((stat) => (
|
||||
<tr key={stat.model}>
|
||||
<td className={styles.modelCell}>{stat.model}</td>
|
||||
<td>
|
||||
<span className={styles.requestCountCell}>
|
||||
<span>{stat.requests.toLocaleString()}</span>
|
||||
<span className={styles.requestBreakdown}>
|
||||
(<span className={styles.statSuccess}>{stat.successCount.toLocaleString()}</span>{' '}
|
||||
<span className={styles.statFailure}>{stat.failureCount.toLocaleString()}</span>)
|
||||
</span>
|
||||
</span>
|
||||
</td>
|
||||
<td>{formatCompactNumber(stat.tokens)}</td>
|
||||
<td>
|
||||
<span
|
||||
className={
|
||||
stat.successRate >= 95
|
||||
? styles.statSuccess
|
||||
: stat.successRate >= 80
|
||||
? styles.statNeutral
|
||||
: styles.statFailure
|
||||
}
|
||||
>
|
||||
{stat.successRate.toFixed(1)}%
|
||||
</span>
|
||||
</td>
|
||||
{hasPrices && <td>{stat.cost > 0 ? formatUsd(stat.cost) : '--'}</td>}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className={styles.hint}>{t('usage_stats.no_data')}</div>
|
||||
|
||||
@@ -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
src/components/usage/ServiceHealthCard.tsx
Normal file
180
src/components/usage/ServiceHealthCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -3,7 +3,7 @@ import { useTranslation } from 'react-i18next';
|
||||
import { Line } from 'react-chartjs-2';
|
||||
import { IconDiamond, IconDollarSign, IconSatellite, IconTimer, IconTrendingUp } from '@/components/ui/icons';
|
||||
import {
|
||||
formatTokensInMillions,
|
||||
formatCompactNumber,
|
||||
formatPerMinuteValue,
|
||||
formatUsd,
|
||||
calculateTokenBreakdown,
|
||||
@@ -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>
|
||||
</>
|
||||
@@ -81,14 +81,14 @@ export function StatCards({ usage, loading, modelPrices, sparklines }: StatCards
|
||||
accent: '#8b5cf6',
|
||||
accentSoft: 'rgba(139, 92, 246, 0.18)',
|
||||
accentBorder: 'rgba(139, 92, 246, 0.35)',
|
||||
value: loading ? '-' : formatTokensInMillions(usage?.total_tokens ?? 0),
|
||||
value: loading ? '-' : formatCompactNumber(usage?.total_tokens ?? 0),
|
||||
meta: (
|
||||
<>
|
||||
<span className={styles.statMetaItem}>
|
||||
{t('usage_stats.cached_tokens')}: {loading ? '-' : formatTokensInMillions(tokenBreakdown.cachedTokens)}
|
||||
{t('usage_stats.cached_tokens')}: {loading ? '-' : formatCompactNumber(tokenBreakdown.cachedTokens)}
|
||||
</span>
|
||||
<span className={styles.statMetaItem}>
|
||||
{t('usage_stats.reasoning_tokens')}: {loading ? '-' : formatTokensInMillions(tokenBreakdown.reasoningTokens)}
|
||||
{t('usage_stats.reasoning_tokens')}: {loading ? '-' : formatCompactNumber(tokenBreakdown.reasoningTokens)}
|
||||
</span>
|
||||
</>
|
||||
),
|
||||
@@ -119,7 +119,7 @@ export function StatCards({ usage, loading, modelPrices, sparklines }: StatCards
|
||||
value: loading ? '-' : formatPerMinuteValue(rateStats.tpm),
|
||||
meta: (
|
||||
<span className={styles.statMetaItem}>
|
||||
{t('usage_stats.total_tokens')}: {loading ? '-' : formatTokensInMillions(rateStats.tokenCount)}
|
||||
{t('usage_stats.total_tokens')}: {loading ? '-' : formatCompactNumber(rateStats.tokenCount)}
|
||||
</span>
|
||||
),
|
||||
trend: sparklines.tpm
|
||||
@@ -135,7 +135,7 @@ export function StatCards({ usage, loading, modelPrices, sparklines }: StatCards
|
||||
meta: (
|
||||
<>
|
||||
<span className={styles.statMetaItem}>
|
||||
{t('usage_stats.total_tokens')}: {loading ? '-' : formatTokensInMillions(usage?.total_tokens ?? 0)}
|
||||
{t('usage_stats.total_tokens')}: {loading ? '-' : formatCompactNumber(usage?.total_tokens ?? 0)}
|
||||
</span>
|
||||
{!hasPrices && (
|
||||
<span className={`${styles.statMetaItem} ${styles.statSubtle}`}>
|
||||
|
||||
145
src/components/usage/TokenBreakdownChart.tsx
Normal file
145
src/components/usage/TokenBreakdownChart.tsx
Normal file
@@ -0,0 +1,145 @@
|
||||
import { useState, useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Line } from 'react-chartjs-2';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import {
|
||||
buildHourlyTokenBreakdown,
|
||||
buildDailyTokenBreakdown,
|
||||
type TokenCategory
|
||||
} from '@/utils/usage';
|
||||
import { buildChartOptions, getHourChartMinWidth } from '@/utils/usage/chartConfig';
|
||||
import type { UsagePayload } from './hooks/useUsageData';
|
||||
import styles from '@/pages/UsagePage.module.scss';
|
||||
|
||||
const TOKEN_COLORS: Record<TokenCategory, { border: string; bg: string }> = {
|
||||
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)' }
|
||||
};
|
||||
|
||||
const CATEGORIES: TokenCategory[] = ['input', 'output', 'cached', 'reasoning'];
|
||||
|
||||
export interface TokenBreakdownChartProps {
|
||||
usage: UsagePayload | null;
|
||||
loading: boolean;
|
||||
isDark: boolean;
|
||||
isMobile: boolean;
|
||||
hourWindowHours?: number;
|
||||
}
|
||||
|
||||
export function TokenBreakdownChart({
|
||||
usage,
|
||||
loading,
|
||||
isDark,
|
||||
isMobile,
|
||||
hourWindowHours
|
||||
}: TokenBreakdownChartProps) {
|
||||
const { t } = useTranslation();
|
||||
const [period, setPeriod] = useState<'hour' | 'day'>('hour');
|
||||
|
||||
const { chartData, chartOptions } = useMemo(() => {
|
||||
const series =
|
||||
period === 'hour'
|
||||
? buildHourlyTokenBreakdown(usage, hourWindowHours)
|
||||
: buildDailyTokenBreakdown(usage);
|
||||
const categoryLabels: Record<TokenCategory, string> = {
|
||||
input: t('usage_stats.input_tokens'),
|
||||
output: t('usage_stats.output_tokens'),
|
||||
cached: t('usage_stats.cached_tokens'),
|
||||
reasoning: t('usage_stats.reasoning_tokens')
|
||||
};
|
||||
|
||||
const data = {
|
||||
labels: series.labels,
|
||||
datasets: CATEGORIES.map((cat) => ({
|
||||
label: categoryLabels[cat],
|
||||
data: series.dataByCategory[cat],
|
||||
borderColor: TOKEN_COLORS[cat].border,
|
||||
backgroundColor: TOKEN_COLORS[cat].bg,
|
||||
pointBackgroundColor: TOKEN_COLORS[cat].border,
|
||||
pointBorderColor: TOKEN_COLORS[cat].border,
|
||||
fill: true,
|
||||
tension: 0.35
|
||||
}))
|
||||
};
|
||||
|
||||
const baseOptions = buildChartOptions({ period, labels: series.labels, isDark, isMobile });
|
||||
const options = {
|
||||
...baseOptions,
|
||||
scales: {
|
||||
...baseOptions.scales,
|
||||
y: {
|
||||
...baseOptions.scales?.y,
|
||||
stacked: true
|
||||
},
|
||||
x: {
|
||||
...baseOptions.scales?.x,
|
||||
stacked: true
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return { chartData: data, chartOptions: options };
|
||||
}, [usage, period, isDark, isMobile, hourWindowHours, t]);
|
||||
|
||||
return (
|
||||
<Card
|
||||
title={t('usage_stats.token_breakdown')}
|
||||
extra={
|
||||
<div className={styles.periodButtons}>
|
||||
<Button
|
||||
variant={period === 'hour' ? 'primary' : 'secondary'}
|
||||
size="sm"
|
||||
onClick={() => setPeriod('hour')}
|
||||
>
|
||||
{t('usage_stats.by_hour')}
|
||||
</Button>
|
||||
<Button
|
||||
variant={period === 'day' ? 'primary' : 'secondary'}
|
||||
size="sm"
|
||||
onClick={() => setPeriod('day')}
|
||||
>
|
||||
{t('usage_stats.by_day')}
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{loading ? (
|
||||
<div className={styles.hint}>{t('common.loading')}</div>
|
||||
) : chartData.labels.length > 0 ? (
|
||||
<div className={styles.chartWrapper}>
|
||||
<div className={styles.chartLegend} aria-label="Chart legend">
|
||||
{chartData.datasets.map((dataset, index) => (
|
||||
<div
|
||||
key={`${dataset.label}-${index}`}
|
||||
className={styles.legendItem}
|
||||
title={dataset.label}
|
||||
>
|
||||
<span className={styles.legendDot} style={{ backgroundColor: dataset.borderColor }} />
|
||||
<span className={styles.legendLabel}>{dataset.label}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className={styles.chartArea}>
|
||||
<div className={styles.chartScroller}>
|
||||
<div
|
||||
className={styles.chartCanvas}
|
||||
style={
|
||||
period === 'hour'
|
||||
? { minWidth: getHourChartMinWidth(chartData.labels.length, isMobile) }
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<Line data={chartData} options={chartOptions} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className={styles.hint}>{t('usage_stats.no_data')}</div>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -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]
|
||||
);
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ export interface UseUsageDataReturn {
|
||||
usage: UsagePayload | null;
|
||||
loading: boolean;
|
||||
error: string;
|
||||
lastRefreshedAt: Date | null;
|
||||
modelPrices: Record<string, ModelPrice>;
|
||||
setModelPrices: (prices: Record<string, ModelPrice>) => void;
|
||||
loadUsage: () => Promise<void>;
|
||||
@@ -38,6 +39,7 @@ export function useUsageData(): UseUsageDataReturn {
|
||||
const [modelPrices, setModelPrices] = useState<Record<string, ModelPrice>>({});
|
||||
const [exporting, setExporting] = useState(false);
|
||||
const [importing, setImporting] = useState(false);
|
||||
const [lastRefreshedAt, setLastRefreshedAt] = useState<Date | null>(null);
|
||||
const importInputRef = useRef<HTMLInputElement | null>(null);
|
||||
|
||||
const loadUsage = useCallback(async () => {
|
||||
@@ -47,6 +49,7 @@ export function useUsageData(): UseUsageDataReturn {
|
||||
const data = await usageApi.getUsage();
|
||||
const payload = (data?.usage ?? data) as unknown;
|
||||
setUsage(payload && typeof payload === 'object' ? (payload as UsagePayload) : null);
|
||||
setLastRefreshedAt(new Date());
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof Error ? err.message : t('usage_stats.loading_error');
|
||||
setError(message);
|
||||
@@ -140,6 +143,7 @@ export function useUsageData(): UseUsageDataReturn {
|
||||
usage,
|
||||
loading,
|
||||
error,
|
||||
lastRefreshedAt,
|
||||
modelPrices,
|
||||
setModelPrices: handleSetModelPrices,
|
||||
loadUsage,
|
||||
|
||||
@@ -26,3 +26,15 @@ export type { ModelStatsCardProps, ModelStat } from './ModelStatsCard';
|
||||
|
||||
export { PriceSettingsCard } from './PriceSettingsCard';
|
||||
export type { PriceSettingsCardProps } from './PriceSettingsCard';
|
||||
|
||||
export { CredentialStatsCard } from './CredentialStatsCard';
|
||||
export type { CredentialStatsCardProps } from './CredentialStatsCard';
|
||||
|
||||
export { TokenBreakdownChart } from './TokenBreakdownChart';
|
||||
export type { TokenBreakdownChartProps } from './TokenBreakdownChart';
|
||||
|
||||
export { CostTrendChart } from './CostTrendChart';
|
||||
export type { CostTrendChartProps } from './CostTrendChart';
|
||||
|
||||
export { ServiceHealthCard } from './ServiceHealthCard';
|
||||
export type { ServiceHealthCardProps } from './ServiceHealthCard';
|
||||
|
||||
216
src/features/authFiles/components/AuthFileCard.tsx
Normal file
216
src/features/authFiles/components/AuthFileCard.tsx
Normal file
@@ -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>
|
||||
);
|
||||
}
|
||||
47
src/features/authFiles/components/AuthFileDetailModal.tsx
Normal file
47
src/features/authFiles/components/AuthFileDetailModal.tsx
Normal file
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
91
src/features/authFiles/components/AuthFileModelsModal.tsx
Normal file
91
src/features/authFiles/components/AuthFileModelsModal.tsx
Normal file
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
124
src/features/authFiles/components/AuthFileQuotaSection.tsx
Normal file
124
src/features/authFiles/components/AuthFileQuotaSection.tsx
Normal file
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
65
src/features/authFiles/components/OAuthExcludedCard.tsx
Normal file
65
src/features/authFiles/components/OAuthExcludedCard.tsx
Normal file
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
152
src/features/authFiles/components/OAuthModelAliasCard.tsx
Normal file
152
src/features/authFiles/components/OAuthModelAliasCard.tsx
Normal file
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
28
src/features/authFiles/components/QuotaProgressBar.tsx
Normal file
28
src/features/authFiles/components/QuotaProgressBar.tsx
Normal file
@@ -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
src/features/authFiles/constants.ts
Normal file
235
src/features/authFiles/constants.ts
Normal 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();
|
||||
});
|
||||
};
|
||||
319
src/features/authFiles/hooks/useAuthFilesData.ts
Normal file
319
src/features/authFiles/hooks/useAuthFilesData.ts
Normal file
@@ -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
|
||||
};
|
||||
}
|
||||
86
src/features/authFiles/hooks/useAuthFilesModels.ts
Normal file
86
src/features/authFiles/hooks/useAuthFilesModels.ts
Normal file
@@ -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
|
||||
};
|
||||
}
|
||||
|
||||
504
src/features/authFiles/hooks/useAuthFilesOauth.tsx
Normal file
504
src/features/authFiles/hooks/useAuthFilesOauth.tsx
Normal file
@@ -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
|
||||
};
|
||||
}
|
||||
|
||||
254
src/features/authFiles/hooks/useAuthFilesPrefixProxyEditor.ts
Normal file
254
src/features/authFiles/hooks/useAuthFilesPrefixProxyEditor.ts
Normal file
@@ -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
|
||||
};
|
||||
}
|
||||
35
src/features/authFiles/hooks/useAuthFilesStats.ts
Normal file
35
src/features/authFiles/hooks/useAuthFilesStats.ts
Normal file
@@ -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 };
|
||||
}
|
||||
|
||||
28
src/features/authFiles/hooks/useAuthFilesStatusBarCache.ts
Normal file
28
src/features/authFiles/hooks/useAuthFilesStatusBarCache.ts
Normal file
@@ -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
src/features/authFiles/uiState.ts
Normal file
30
src/features/authFiles/uiState.ts
Normal 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
|
||||
}
|
||||
};
|
||||
|
||||
@@ -529,18 +529,66 @@ export function useVisualConfig() {
|
||||
}
|
||||
|
||||
export const VISUAL_CONFIG_PROTOCOL_OPTIONS = [
|
||||
{ value: '', label: '默认' },
|
||||
{ value: 'openai', label: 'OpenAI' },
|
||||
{ value: 'openai-response', label: 'OpenAI Response' },
|
||||
{ value: 'gemini', label: 'Gemini' },
|
||||
{ value: 'claude', label: 'Claude' },
|
||||
{ value: 'codex', label: 'Codex' },
|
||||
{ value: 'antigravity', label: 'Antigravity' },
|
||||
{
|
||||
value: '',
|
||||
labelKey: 'config_management.visual.payload_rules.provider_default',
|
||||
defaultLabel: 'Default',
|
||||
},
|
||||
{
|
||||
value: 'openai',
|
||||
labelKey: 'config_management.visual.payload_rules.provider_openai',
|
||||
defaultLabel: 'OpenAI',
|
||||
},
|
||||
{
|
||||
value: 'openai-response',
|
||||
labelKey: 'config_management.visual.payload_rules.provider_openai_response',
|
||||
defaultLabel: 'OpenAI Response',
|
||||
},
|
||||
{
|
||||
value: 'gemini',
|
||||
labelKey: 'config_management.visual.payload_rules.provider_gemini',
|
||||
defaultLabel: 'Gemini',
|
||||
},
|
||||
{
|
||||
value: 'claude',
|
||||
labelKey: 'config_management.visual.payload_rules.provider_claude',
|
||||
defaultLabel: 'Claude',
|
||||
},
|
||||
{
|
||||
value: 'codex',
|
||||
labelKey: 'config_management.visual.payload_rules.provider_codex',
|
||||
defaultLabel: 'Codex',
|
||||
},
|
||||
{
|
||||
value: 'antigravity',
|
||||
labelKey: 'config_management.visual.payload_rules.provider_antigravity',
|
||||
defaultLabel: 'Antigravity',
|
||||
},
|
||||
] as const;
|
||||
|
||||
export const VISUAL_CONFIG_PAYLOAD_VALUE_TYPE_OPTIONS = [
|
||||
{ value: 'string', label: '字符串' },
|
||||
{ value: 'number', label: '数字' },
|
||||
{ value: 'boolean', label: '布尔' },
|
||||
{ value: 'json', label: 'JSON' },
|
||||
] as const satisfies ReadonlyArray<{ value: PayloadParamValueType; label: string }>;
|
||||
{
|
||||
value: 'string',
|
||||
labelKey: 'config_management.visual.payload_rules.value_type_string',
|
||||
defaultLabel: 'String',
|
||||
},
|
||||
{
|
||||
value: 'number',
|
||||
labelKey: 'config_management.visual.payload_rules.value_type_number',
|
||||
defaultLabel: 'Number',
|
||||
},
|
||||
{
|
||||
value: 'boolean',
|
||||
labelKey: 'config_management.visual.payload_rules.value_type_boolean',
|
||||
defaultLabel: 'Boolean',
|
||||
},
|
||||
{
|
||||
value: 'json',
|
||||
labelKey: 'config_management.visual.payload_rules.value_type_json',
|
||||
defaultLabel: 'JSON',
|
||||
},
|
||||
] as const satisfies ReadonlyArray<{
|
||||
value: PayloadParamValueType;
|
||||
labelKey: string;
|
||||
defaultLabel: string;
|
||||
}>;
|
||||
|
||||
@@ -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?",
|
||||
@@ -419,15 +425,25 @@
|
||||
"status_toggle_label": "Enabled",
|
||||
"status_enabled_success": "\"{{name}}\" enabled",
|
||||
"status_disabled_success": "\"{{name}}\" disabled",
|
||||
"prefix_proxy_button": "Edit prefix/proxy_url",
|
||||
"prefix_proxy_loading": "Loading credential...",
|
||||
"prefix_proxy_source_label": "Credential JSON",
|
||||
"prefix_label": "prefix",
|
||||
"proxy_url_label": "proxy_url",
|
||||
"prefix_proxy_button": "Edit Auth Fields",
|
||||
"auth_field_editor_title": "Edit Auth Fields - {{name}}",
|
||||
"prefix_proxy_loading": "Loading auth file...",
|
||||
"prefix_proxy_source_label": "Auth file JSON (preview)",
|
||||
"prefix_label": "Prefix (prefix)",
|
||||
"proxy_url_label": "Proxy URL (proxy_url)",
|
||||
"prefix_placeholder": "",
|
||||
"proxy_url_placeholder": "socks5://username:password@proxy_ip:port/",
|
||||
"prefix_proxy_invalid_json": "This credential is not a JSON object and cannot be edited.",
|
||||
"prefix_proxy_saved_success": "Updated \"{{name}}\" successfully",
|
||||
"priority_label": "Priority (priority)",
|
||||
"priority_placeholder": "e.g. 10 or -1",
|
||||
"priority_hint": "Integers only. Invalid values are ignored. Larger value means higher priority.",
|
||||
"excluded_models_label": "Excluded models (excluded_models)",
|
||||
"excluded_models_placeholder": "Comma or newline separated, e.g. model-a, gpt-5-*, *-preview",
|
||||
"excluded_models_hint": "Saved as an array and normalized by trim/lowercase/dedup/sort.",
|
||||
"disable_cooling_label": "Disable cooling (disable_cooling)",
|
||||
"disable_cooling_placeholder": "e.g. true / false / 1 / 0",
|
||||
"disable_cooling_hint": "Supports booleans, numeric 0/non-0, and strings like true/false/1/0; unparseable values are ignored.",
|
||||
"prefix_proxy_invalid_json": "This auth file is not a JSON object, so fields cannot be edited.",
|
||||
"prefix_proxy_saved_success": "Updated auth file \"{{name}}\" successfully",
|
||||
"quota_refresh_success": "Quota refreshed for \"{{name}}\"",
|
||||
"quota_refresh_failed": "Failed to refresh quota for \"{{name}}\": {{message}}"
|
||||
},
|
||||
@@ -480,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",
|
||||
@@ -802,12 +820,29 @@
|
||||
"cost_axis_label": "Cost ($)",
|
||||
"cost_need_price": "Set a model price to view cost stats",
|
||||
"cost_need_usage": "No usage data available to calculate cost",
|
||||
"cost_no_data": "No cost data yet"
|
||||
"cost_no_data": "No cost data yet",
|
||||
"credential_stats": "Credential Statistics",
|
||||
"credential_name": "Credential",
|
||||
"token_breakdown": "Token Type Breakdown",
|
||||
"input_tokens": "Input Tokens",
|
||||
"output_tokens": "Output Tokens",
|
||||
"last_updated": "Updated"
|
||||
},
|
||||
"stats": {
|
||||
"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",
|
||||
@@ -1000,6 +1035,17 @@
|
||||
"add_param": "Add Parameter",
|
||||
"no_rules": "No rules",
|
||||
"add_rule": "Add Rule",
|
||||
"provider_default": "Default",
|
||||
"provider_openai": "OpenAI",
|
||||
"provider_openai_response": "OpenAI Response",
|
||||
"provider_gemini": "Gemini",
|
||||
"provider_claude": "Claude",
|
||||
"provider_codex": "Codex",
|
||||
"provider_antigravity": "Antigravity",
|
||||
"value_type_string": "String",
|
||||
"value_type_number": "Number",
|
||||
"value_type_boolean": "Boolean",
|
||||
"value_type_json": "JSON",
|
||||
"value_string": "String value",
|
||||
"value_number": "Number value (e.g., 0.7)",
|
||||
"value_boolean": "true or false",
|
||||
|
||||
@@ -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?",
|
||||
@@ -419,15 +425,25 @@
|
||||
"status_toggle_label": "Включено",
|
||||
"status_enabled_success": "\"{{name}}\" включён",
|
||||
"status_disabled_success": "\"{{name}}\" отключён",
|
||||
"prefix_proxy_button": "Изменить prefix/proxy_url",
|
||||
"prefix_proxy_loading": "Загрузка учётных данных...",
|
||||
"prefix_proxy_source_label": "JSON учётных данных",
|
||||
"prefix_label": "prefix",
|
||||
"proxy_url_label": "proxy_url",
|
||||
"prefix_proxy_button": "Редактировать поля файла авторизации",
|
||||
"auth_field_editor_title": "Редактировать поля файла авторизации - {{name}}",
|
||||
"prefix_proxy_loading": "Загрузка файла авторизации...",
|
||||
"prefix_proxy_source_label": "JSON файла авторизации (предпросмотр)",
|
||||
"prefix_label": "Префикс (prefix)",
|
||||
"proxy_url_label": "URL прокси (proxy_url)",
|
||||
"prefix_placeholder": "",
|
||||
"proxy_url_placeholder": "socks5://username:password@proxy_ip:port/",
|
||||
"prefix_proxy_invalid_json": "Эти учётные данные не являются JSON-объектом и не могут быть изменены.",
|
||||
"prefix_proxy_saved_success": "\"{{name}}\" успешно обновлён",
|
||||
"priority_label": "Приоритет (priority)",
|
||||
"priority_placeholder": "например: 10 или -1",
|
||||
"priority_hint": "Только целые числа. Некорректные значения игнорируются. Чем больше число, тем выше приоритет.",
|
||||
"excluded_models_label": "Исключённые модели (excluded_models)",
|
||||
"excluded_models_placeholder": "Через запятую или с новой строки, например: model-a, gpt-5-*, *-preview",
|
||||
"excluded_models_hint": "Сохраняется как массив; значения trim/нижний регистр/без дублей/с сортировкой.",
|
||||
"disable_cooling_label": "Отключение охлаждения (disable_cooling)",
|
||||
"disable_cooling_placeholder": "например: true / false / 1 / 0",
|
||||
"disable_cooling_hint": "Поддерживает boolean, числа 0/не 0 и строки true/false/1/0; непарсируемые значения игнорируются.",
|
||||
"prefix_proxy_invalid_json": "Этот файл авторизации не является JSON-объектом, поэтому поля нельзя редактировать.",
|
||||
"prefix_proxy_saved_success": "Файл авторизации \"{{name}}\" успешно обновлён",
|
||||
"card_tools_title": "Инструменты",
|
||||
"quota_refresh_single": "Обновить квоту",
|
||||
"quota_refresh_hint": "Обновить квоту только для этих учётных данных",
|
||||
@@ -483,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",
|
||||
@@ -805,12 +823,29 @@
|
||||
"cost_axis_label": "Стоимость ($)",
|
||||
"cost_need_price": "Задайте стоимость модели, чтобы увидеть статистику затрат",
|
||||
"cost_need_usage": "Нет данных использования для расчёта стоимости",
|
||||
"cost_no_data": "Данных о стоимости ещё нет"
|
||||
"cost_no_data": "Данных о стоимости ещё нет",
|
||||
"credential_stats": "Статистика учётных данных",
|
||||
"credential_name": "Учётные данные",
|
||||
"token_breakdown": "Распределение типов токенов",
|
||||
"input_tokens": "Входные токены",
|
||||
"output_tokens": "Выходные токены",
|
||||
"last_updated": "Обновлено"
|
||||
},
|
||||
"stats": {
|
||||
"success": "Успех",
|
||||
"failure": "Сбой"
|
||||
},
|
||||
"status_bar": {
|
||||
"success_short": "✓",
|
||||
"failure_short": "✗",
|
||||
"no_requests": "Нет запросов"
|
||||
},
|
||||
"service_health": {
|
||||
"title": "Состояние сервиса",
|
||||
"window": "Последние 7 дней",
|
||||
"oldest": "Старые",
|
||||
"newest": "Новые"
|
||||
},
|
||||
"logs": {
|
||||
"title": "Просмотр журналов",
|
||||
"refresh_button": "Обновить журналы",
|
||||
@@ -1005,6 +1040,17 @@
|
||||
"add_param": "Добавить параметр",
|
||||
"no_rules": "Правил нет",
|
||||
"add_rule": "Добавить правило",
|
||||
"provider_default": "По умолчанию",
|
||||
"provider_openai": "OpenAI",
|
||||
"provider_openai_response": "OpenAI Response",
|
||||
"provider_gemini": "Gemini",
|
||||
"provider_claude": "Claude",
|
||||
"provider_codex": "Codex",
|
||||
"provider_antigravity": "Antigravity",
|
||||
"value_type_string": "Строка",
|
||||
"value_type_number": "Число",
|
||||
"value_type_boolean": "Булево",
|
||||
"value_type_json": "JSON",
|
||||
"value_string": "Строковое значение",
|
||||
"value_number": "Числовое значение (например, 0.7)",
|
||||
"value_boolean": "true или false",
|
||||
|
||||
@@ -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密钥吗?",
|
||||
@@ -419,15 +425,25 @@
|
||||
"status_toggle_label": "启用",
|
||||
"status_enabled_success": "已启用 \"{{name}}\"",
|
||||
"status_disabled_success": "已停用 \"{{name}}\"",
|
||||
"prefix_proxy_button": "配置 prefix/proxy_url",
|
||||
"prefix_proxy_loading": "正在加载凭证文件...",
|
||||
"prefix_proxy_source_label": "凭证 JSON",
|
||||
"prefix_label": "prefix",
|
||||
"proxy_url_label": "proxy_url",
|
||||
"prefix_proxy_button": "编辑认证文件字段",
|
||||
"auth_field_editor_title": "编辑认证文件字段 - {{name}}",
|
||||
"prefix_proxy_loading": "正在加载认证文件...",
|
||||
"prefix_proxy_source_label": "认证文件 JSON(预览)",
|
||||
"prefix_label": "前缀(prefix)",
|
||||
"proxy_url_label": "代理 URL(proxy_url)",
|
||||
"prefix_placeholder": "",
|
||||
"proxy_url_placeholder": "socks5://username:password@proxy_ip:port/",
|
||||
"prefix_proxy_invalid_json": "该凭证文件不是 JSON 对象,无法编辑。",
|
||||
"prefix_proxy_saved_success": "已更新 \"{{name}}\"",
|
||||
"priority_label": "优先级(priority)",
|
||||
"priority_placeholder": "例如: 10 或 -1",
|
||||
"priority_hint": "仅支持整数;非法值会被忽略。数值越大优先级越高。",
|
||||
"excluded_models_label": "排除模型(excluded_models)",
|
||||
"excluded_models_placeholder": "用逗号或换行分隔,例如: model-a, gpt-5-*, *-preview",
|
||||
"excluded_models_hint": "保存为数组;会自动 trim、小写、去重并排序。",
|
||||
"disable_cooling_label": "禁用冷却(disable_cooling)",
|
||||
"disable_cooling_placeholder": "例如: true / false / 1 / 0",
|
||||
"disable_cooling_hint": "支持布尔值、0/非0 数字或字符串 true/false/1/0;无法解析时忽略。",
|
||||
"prefix_proxy_invalid_json": "该认证文件不是 JSON 对象,无法编辑字段。",
|
||||
"prefix_proxy_saved_success": "已更新认证文件 \"{{name}}\"",
|
||||
"quota_refresh_success": "已刷新 \"{{name}}\" 的额度",
|
||||
"quota_refresh_failed": "刷新 \"{{name}}\" 的额度失败:{{message}}"
|
||||
},
|
||||
@@ -480,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",
|
||||
@@ -802,12 +820,29 @@
|
||||
"cost_axis_label": "花费 ($)",
|
||||
"cost_need_price": "请先设置模型价格",
|
||||
"cost_need_usage": "暂无使用数据,无法计算花费",
|
||||
"cost_no_data": "没有可计算的花费数据"
|
||||
"cost_no_data": "没有可计算的花费数据",
|
||||
"credential_stats": "凭证统计",
|
||||
"credential_name": "凭证",
|
||||
"token_breakdown": "Token 类型分布",
|
||||
"input_tokens": "输入 Tokens",
|
||||
"output_tokens": "输出 Tokens",
|
||||
"last_updated": "更新于"
|
||||
},
|
||||
"stats": {
|
||||
"success": "成功",
|
||||
"failure": "失败"
|
||||
},
|
||||
"status_bar": {
|
||||
"success_short": "✓",
|
||||
"failure_short": "✗",
|
||||
"no_requests": "无请求"
|
||||
},
|
||||
"service_health": {
|
||||
"title": "服务健康监测",
|
||||
"window": "最近 7 天",
|
||||
"oldest": "最早",
|
||||
"newest": "最新"
|
||||
},
|
||||
"logs": {
|
||||
"title": "日志查看",
|
||||
"refresh_button": "刷新日志",
|
||||
@@ -1000,6 +1035,17 @@
|
||||
"add_param": "添加参数",
|
||||
"no_rules": "暂无规则",
|
||||
"add_rule": "添加规则",
|
||||
"provider_default": "默认",
|
||||
"provider_openai": "OpenAI",
|
||||
"provider_openai_response": "OpenAI Response",
|
||||
"provider_gemini": "Gemini",
|
||||
"provider_claude": "Claude",
|
||||
"provider_codex": "Codex",
|
||||
"provider_antigravity": "Antigravity",
|
||||
"value_type_string": "字符串",
|
||||
"value_type_number": "数字",
|
||||
"value_type_boolean": "布尔",
|
||||
"value_type_json": "JSON",
|
||||
"value_string": "字符串值",
|
||||
"value_number": "数字值 (如 0.7)",
|
||||
"value_boolean": "true 或 false",
|
||||
|
||||
@@ -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 }))}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
@@ -80,12 +80,13 @@
|
||||
|
||||
.filterTag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
align-items: baseline;
|
||||
gap: 8px;
|
||||
padding: 6px 14px;
|
||||
border-radius: 20px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
line-height: 1;
|
||||
border: 1px solid transparent;
|
||||
cursor: pointer;
|
||||
transition: all $transition-fast;
|
||||
@@ -101,12 +102,19 @@
|
||||
}
|
||||
|
||||
.filterTagLabel {
|
||||
display: inline-flex;
|
||||
align-items: baseline;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.filterTagCount {
|
||||
display: inline-flex;
|
||||
align-items: baseline;
|
||||
justify-content: flex-end;
|
||||
min-width: 2ch;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
font-variant-numeric: tabular-nums;
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
@@ -374,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 {
|
||||
@@ -435,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;
|
||||
@@ -443,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;
|
||||
}
|
||||
@@ -578,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);
|
||||
}
|
||||
|
||||
// 状态监测栏
|
||||
@@ -597,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;
|
||||
@@ -653,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 {
|
||||
@@ -679,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;
|
||||
}
|
||||
@@ -1089,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);
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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() {
|
||||
@@ -400,6 +377,8 @@ export function LogsPage() {
|
||||
startY: number;
|
||||
fired: boolean;
|
||||
} | null>(null);
|
||||
const logRequestInFlightRef = useRef(false);
|
||||
const pendingFullReloadRef = useRef(false);
|
||||
|
||||
// 保存最新时间戳用于增量获取
|
||||
const latestTimestampRef = useRef<number>(0);
|
||||
@@ -424,6 +403,15 @@ export function LogsPage() {
|
||||
return;
|
||||
}
|
||||
|
||||
if (logRequestInFlightRef.current) {
|
||||
if (!incremental) {
|
||||
pendingFullReloadRef.current = true;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
logRequestInFlightRef.current = true;
|
||||
|
||||
if (!incremental) {
|
||||
setLoading(true);
|
||||
}
|
||||
@@ -474,6 +462,11 @@ export function LogsPage() {
|
||||
if (!incremental) {
|
||||
setLoading(false);
|
||||
}
|
||||
logRequestInFlightRef.current = false;
|
||||
if (pendingFullReloadRef.current) {
|
||||
pendingFullReloadRef.current = false;
|
||||
void loadLogs(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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 +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) => {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -25,6 +25,12 @@
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.lastRefreshed {
|
||||
font-size: 11px;
|
||||
color: var(--text-tertiary);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.timeRangeGroup {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@@ -38,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 {
|
||||
@@ -143,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);
|
||||
@@ -164,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;
|
||||
@@ -185,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 {
|
||||
@@ -208,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;
|
||||
@@ -360,11 +270,11 @@
|
||||
}
|
||||
|
||||
.statSuccess {
|
||||
color: var(--success-color, #22c55e);
|
||||
color: var(--success-color);
|
||||
}
|
||||
|
||||
.statFailure {
|
||||
color: var(--danger-color, #ef4444);
|
||||
color: var(--danger-color);
|
||||
}
|
||||
|
||||
.statNeutral {
|
||||
@@ -423,6 +333,42 @@
|
||||
}
|
||||
|
||||
// API List (80%比例)
|
||||
.apiSortBar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.apiSortBtn {
|
||||
padding: 4px 10px;
|
||||
border-radius: $radius-full;
|
||||
border: 1px solid var(--border-color);
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text-secondary);
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.15s ease, border-color 0.15s ease, color 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--primary-color);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
}
|
||||
|
||||
.apiSortBtnActive {
|
||||
border-color: rgba($primary-color, 0.5);
|
||||
background: rgba($primary-color, 0.1);
|
||||
color: var(--text-primary);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.apiList {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -437,16 +383,27 @@
|
||||
}
|
||||
|
||||
.apiHeader {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 10px;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--bg-tertiary);
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--primary-color);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
}
|
||||
|
||||
.apiInfo {
|
||||
@@ -527,6 +484,26 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Fixed-height cards with internal scrolling (API details / model stats)
|
||||
.detailsFixedCard {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 520px;
|
||||
overflow: hidden;
|
||||
|
||||
@include mobile {
|
||||
height: 420px;
|
||||
}
|
||||
}
|
||||
|
||||
.detailsScroll {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
|
||||
// Table (80%比例)
|
||||
.tableWrapper {
|
||||
overflow-x: auto;
|
||||
@@ -550,6 +527,15 @@
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
th.sortableHeader {
|
||||
user-select: none;
|
||||
transition: color 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
}
|
||||
|
||||
td {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
@@ -559,12 +545,44 @@
|
||||
}
|
||||
}
|
||||
|
||||
.sortHeaderButton {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
border: 0;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--primary-color);
|
||||
outline-offset: 2px;
|
||||
border-radius: $radius-sm;
|
||||
}
|
||||
}
|
||||
|
||||
.modelCell {
|
||||
font-weight: 500;
|
||||
max-width: 240px;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.credentialType {
|
||||
display: inline-block;
|
||||
margin-left: 6px;
|
||||
padding: 1px 6px;
|
||||
border-radius: $radius-full;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
background: var(--bg-tertiary);
|
||||
border: 1px solid var(--border-color);
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.requestCountCell {
|
||||
display: inline-flex;
|
||||
align-items: baseline;
|
||||
@@ -631,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;
|
||||
@@ -717,6 +717,12 @@
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.editModalBody {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
// Chart Section (80%比例)
|
||||
.chartSection {
|
||||
display: flex;
|
||||
@@ -904,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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,10 +13,10 @@ 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 } from '@/stores';
|
||||
import { useThemeStore, useConfigStore } from '@/stores';
|
||||
import {
|
||||
StatCards,
|
||||
UsageChart,
|
||||
@@ -24,6 +24,10 @@ import {
|
||||
ApiDetailsCard,
|
||||
ModelStatsCard,
|
||||
PriceSettingsCard,
|
||||
CredentialStatsCard,
|
||||
TokenBreakdownChart,
|
||||
CostTrendChart,
|
||||
ServiceHealthCard,
|
||||
useUsageData,
|
||||
useSparklines,
|
||||
useChartData
|
||||
@@ -115,25 +119,14 @@ export function UsagePage() {
|
||||
const isMobile = useMediaQuery('(max-width: 768px)');
|
||||
const resolvedTheme = useThemeStore((state) => state.resolvedTheme);
|
||||
const isDark = resolvedTheme === 'dark';
|
||||
|
||||
// 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]);
|
||||
const config = useConfigStore((state) => state.config);
|
||||
|
||||
// Data hook
|
||||
const {
|
||||
usage,
|
||||
loading,
|
||||
error,
|
||||
lastRefreshedAt,
|
||||
modelPrices,
|
||||
setModelPrices,
|
||||
loadUsage,
|
||||
@@ -151,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]
|
||||
@@ -233,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"
|
||||
@@ -305,6 +277,11 @@ export function UsagePage() {
|
||||
style={{ display: 'none' }}
|
||||
onChange={handleImportChange}
|
||||
/>
|
||||
{lastRefreshedAt && (
|
||||
<span className={styles.lastRefreshed}>
|
||||
{t('usage_stats.last_updated')}: {lastRefreshedAt.toLocaleTimeString()}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -332,6 +309,9 @@ export function UsagePage() {
|
||||
onChange={handleChartLinesChange}
|
||||
/>
|
||||
|
||||
{/* Service Health */}
|
||||
<ServiceHealthCard usage={usage} loading={loading} />
|
||||
|
||||
{/* Charts Grid */}
|
||||
<div className={styles.chartsGrid}>
|
||||
<UsageChart
|
||||
@@ -356,12 +336,42 @@ export function UsagePage() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Token Breakdown Chart */}
|
||||
<TokenBreakdownChart
|
||||
usage={filteredUsage}
|
||||
loading={loading}
|
||||
isDark={isDark}
|
||||
isMobile={isMobile}
|
||||
hourWindowHours={hourWindowHours}
|
||||
/>
|
||||
|
||||
{/* Cost Trend Chart */}
|
||||
<CostTrendChart
|
||||
usage={filteredUsage}
|
||||
loading={loading}
|
||||
isDark={isDark}
|
||||
isMobile={isMobile}
|
||||
modelPrices={modelPrices}
|
||||
hourWindowHours={hourWindowHours}
|
||||
/>
|
||||
|
||||
{/* Details Grid */}
|
||||
<div className={styles.detailsGrid}>
|
||||
<ApiDetailsCard apiStats={apiStats} loading={loading} hasPrices={hasPrices} />
|
||||
<ModelStatsCard modelStats={modelStats} loading={loading} hasPrices={hasPrices} />
|
||||
</div>
|
||||
|
||||
{/* Credential Stats */}
|
||||
<CredentialStatsCard
|
||||
usage={filteredUsage}
|
||||
loading={loading}
|
||||
geminiKeys={config?.geminiApiKeys || []}
|
||||
claudeConfigs={config?.claudeApiKeys || []}
|
||||
codexConfigs={config?.codexApiKeys || []}
|
||||
vertexConfigs={config?.vertexApiKeys || []}
|
||||
openaiProviders={config?.openaiCompatibility || []}
|
||||
/>
|
||||
|
||||
{/* Price Settings */}
|
||||
<PriceSettingsCard
|
||||
modelNames={modelNames}
|
||||
|
||||
@@ -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[]
|
||||
|
||||
@@ -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`;
|
||||
};
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -20,6 +20,7 @@ export interface GeminiKeyConfig {
|
||||
apiKey: string;
|
||||
prefix?: string;
|
||||
baseUrl?: string;
|
||||
proxyUrl?: string;
|
||||
headers?: Record<string, string>;
|
||||
excludedModels?: string[];
|
||||
}
|
||||
|
||||
@@ -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
src/utils/clipboard.ts
Normal file
49
src/utils/clipboard.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -387,17 +387,6 @@ export function maskUsageSensitiveValue(value: unknown, masker: (val: string) =>
|
||||
return masked;
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化 tokens 为百万单位
|
||||
*/
|
||||
export function formatTokensInMillions(value: number): string {
|
||||
const num = Number(value);
|
||||
if (!Number.isFinite(num)) {
|
||||
return '0.00M';
|
||||
}
|
||||
return `${(num / 1_000_000).toFixed(2)}M`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化每分钟数值
|
||||
*/
|
||||
@@ -1014,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)' },
|
||||
@@ -1128,20 +1117,34 @@ 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算状态栏数据(最近1小时,分为20个5分钟的时间块)
|
||||
* 注意:20个块 × 5分钟 = 100分钟,但我们只使用最近60分钟的数据
|
||||
* 所以实际只有最后12个块可能有数据,前8个块将始终为 idle
|
||||
* 计算状态栏数据(最近200分钟,分为20个10分钟的时间块)
|
||||
* 每个时间块代表窗口内的一个等长区间,用于展示成功/失败趋势
|
||||
*/
|
||||
export function calculateStatusBarData(
|
||||
usageDetails: UsageDetail[],
|
||||
@@ -1150,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;
|
||||
@@ -1194,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
|
||||
@@ -1214,12 +1229,106 @@ export function calculateStatusBarData(
|
||||
|
||||
return {
|
||||
blocks,
|
||||
blockDetails,
|
||||
successRate,
|
||||
totalSuccess,
|
||||
totalFailure
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 服务健康监测数据(最近168小时/7天,7×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) {
|
||||
@@ -1278,3 +1387,208 @@ export function computeKeyStats(usageData: unknown, masker: (val: string) => str
|
||||
byAuthIndex: authIndexStats
|
||||
};
|
||||
}
|
||||
|
||||
export type TokenCategory = 'input' | 'output' | 'cached' | 'reasoning';
|
||||
|
||||
export interface TokenBreakdownSeries {
|
||||
labels: string[];
|
||||
dataByCategory: Record<TokenCategory, number[]>;
|
||||
hasData: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 按 token 类别构建小时级别的堆叠序列
|
||||
*/
|
||||
export function buildHourlyTokenBreakdown(
|
||||
usageData: unknown,
|
||||
hourWindow: number = 24
|
||||
): TokenBreakdownSeries {
|
||||
const hourMs = 60 * 60 * 1000;
|
||||
const resolvedHourWindow =
|
||||
Number.isFinite(hourWindow) && hourWindow > 0
|
||||
? Math.min(Math.max(Math.floor(hourWindow), 1), 24 * 31)
|
||||
: 24;
|
||||
const now = new Date();
|
||||
const currentHour = new Date(now);
|
||||
currentHour.setMinutes(0, 0, 0);
|
||||
|
||||
const earliestBucket = new Date(currentHour);
|
||||
earliestBucket.setHours(earliestBucket.getHours() - (resolvedHourWindow - 1));
|
||||
const earliestTime = earliestBucket.getTime();
|
||||
|
||||
const labels: string[] = [];
|
||||
for (let i = 0; i < resolvedHourWindow; i++) {
|
||||
labels.push(formatHourLabel(new Date(earliestTime + i * hourMs)));
|
||||
}
|
||||
|
||||
const dataByCategory: Record<TokenCategory, number[]> = {
|
||||
input: new Array(labels.length).fill(0),
|
||||
output: new Array(labels.length).fill(0),
|
||||
cached: new Array(labels.length).fill(0),
|
||||
reasoning: new Array(labels.length).fill(0),
|
||||
};
|
||||
|
||||
const details = collectUsageDetails(usageData);
|
||||
let hasData = false;
|
||||
|
||||
details.forEach((detail) => {
|
||||
const timestamp = Date.parse(detail.timestamp);
|
||||
if (Number.isNaN(timestamp)) return;
|
||||
const normalized = new Date(timestamp);
|
||||
normalized.setMinutes(0, 0, 0);
|
||||
const bucketStart = normalized.getTime();
|
||||
const lastBucketTime = earliestTime + (labels.length - 1) * hourMs;
|
||||
if (bucketStart < earliestTime || bucketStart > lastBucketTime) return;
|
||||
const bucketIndex = Math.floor((bucketStart - earliestTime) / hourMs);
|
||||
if (bucketIndex < 0 || bucketIndex >= labels.length) return;
|
||||
|
||||
const tokens = detail.tokens;
|
||||
const input = typeof tokens.input_tokens === 'number' ? Math.max(tokens.input_tokens, 0) : 0;
|
||||
const output = typeof tokens.output_tokens === 'number' ? Math.max(tokens.output_tokens, 0) : 0;
|
||||
const cached = Math.max(
|
||||
typeof tokens.cached_tokens === 'number' ? Math.max(tokens.cached_tokens, 0) : 0,
|
||||
typeof tokens.cache_tokens === 'number' ? Math.max(tokens.cache_tokens, 0) : 0,
|
||||
);
|
||||
const reasoning = typeof tokens.reasoning_tokens === 'number' ? Math.max(tokens.reasoning_tokens, 0) : 0;
|
||||
|
||||
dataByCategory.input[bucketIndex] += input;
|
||||
dataByCategory.output[bucketIndex] += output;
|
||||
dataByCategory.cached[bucketIndex] += cached;
|
||||
dataByCategory.reasoning[bucketIndex] += reasoning;
|
||||
hasData = true;
|
||||
});
|
||||
|
||||
return { labels, dataByCategory, hasData };
|
||||
}
|
||||
|
||||
/**
|
||||
* 按 token 类别构建日级别的堆叠序列
|
||||
*/
|
||||
export function buildDailyTokenBreakdown(usageData: unknown): TokenBreakdownSeries {
|
||||
const details = collectUsageDetails(usageData);
|
||||
const dayMap: Record<string, Record<TokenCategory, number>> = {};
|
||||
let hasData = false;
|
||||
|
||||
details.forEach((detail) => {
|
||||
const timestamp = Date.parse(detail.timestamp);
|
||||
if (Number.isNaN(timestamp)) return;
|
||||
const dayLabel = formatDayLabel(new Date(timestamp));
|
||||
if (!dayLabel) return;
|
||||
|
||||
if (!dayMap[dayLabel]) {
|
||||
dayMap[dayLabel] = { input: 0, output: 0, cached: 0, reasoning: 0 };
|
||||
}
|
||||
|
||||
const tokens = detail.tokens;
|
||||
const input = typeof tokens.input_tokens === 'number' ? Math.max(tokens.input_tokens, 0) : 0;
|
||||
const output = typeof tokens.output_tokens === 'number' ? Math.max(tokens.output_tokens, 0) : 0;
|
||||
const cached = Math.max(
|
||||
typeof tokens.cached_tokens === 'number' ? Math.max(tokens.cached_tokens, 0) : 0,
|
||||
typeof tokens.cache_tokens === 'number' ? Math.max(tokens.cache_tokens, 0) : 0,
|
||||
);
|
||||
const reasoning = typeof tokens.reasoning_tokens === 'number' ? Math.max(tokens.reasoning_tokens, 0) : 0;
|
||||
|
||||
dayMap[dayLabel].input += input;
|
||||
dayMap[dayLabel].output += output;
|
||||
dayMap[dayLabel].cached += cached;
|
||||
dayMap[dayLabel].reasoning += reasoning;
|
||||
hasData = true;
|
||||
});
|
||||
|
||||
const labels = Object.keys(dayMap).sort();
|
||||
const dataByCategory: Record<TokenCategory, number[]> = {
|
||||
input: labels.map((l) => dayMap[l].input),
|
||||
output: labels.map((l) => dayMap[l].output),
|
||||
cached: labels.map((l) => dayMap[l].cached),
|
||||
reasoning: labels.map((l) => dayMap[l].reasoning),
|
||||
};
|
||||
|
||||
return { labels, dataByCategory, hasData };
|
||||
}
|
||||
|
||||
export interface CostSeries {
|
||||
labels: string[];
|
||||
data: number[];
|
||||
hasData: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 按小时构建费用时间序列
|
||||
*/
|
||||
export function buildHourlyCostSeries(
|
||||
usageData: unknown,
|
||||
modelPrices: Record<string, ModelPrice>,
|
||||
hourWindow: number = 24
|
||||
): CostSeries {
|
||||
const hourMs = 60 * 60 * 1000;
|
||||
const resolvedHourWindow =
|
||||
Number.isFinite(hourWindow) && hourWindow > 0
|
||||
? Math.min(Math.max(Math.floor(hourWindow), 1), 24 * 31)
|
||||
: 24;
|
||||
const now = new Date();
|
||||
const currentHour = new Date(now);
|
||||
currentHour.setMinutes(0, 0, 0);
|
||||
|
||||
const earliestBucket = new Date(currentHour);
|
||||
earliestBucket.setHours(earliestBucket.getHours() - (resolvedHourWindow - 1));
|
||||
const earliestTime = earliestBucket.getTime();
|
||||
|
||||
const labels: string[] = [];
|
||||
for (let i = 0; i < resolvedHourWindow; i++) {
|
||||
labels.push(formatHourLabel(new Date(earliestTime + i * hourMs)));
|
||||
}
|
||||
|
||||
const data = new Array(labels.length).fill(0);
|
||||
const details = collectUsageDetails(usageData);
|
||||
let hasData = false;
|
||||
|
||||
details.forEach((detail) => {
|
||||
const timestamp = Date.parse(detail.timestamp);
|
||||
if (Number.isNaN(timestamp)) return;
|
||||
const normalized = new Date(timestamp);
|
||||
normalized.setMinutes(0, 0, 0);
|
||||
const bucketStart = normalized.getTime();
|
||||
const lastBucketTime = earliestTime + (labels.length - 1) * hourMs;
|
||||
if (bucketStart < earliestTime || bucketStart > lastBucketTime) return;
|
||||
const bucketIndex = Math.floor((bucketStart - earliestTime) / hourMs);
|
||||
if (bucketIndex < 0 || bucketIndex >= labels.length) return;
|
||||
|
||||
const cost = calculateCost(detail, modelPrices);
|
||||
if (cost > 0) {
|
||||
data[bucketIndex] += cost;
|
||||
hasData = true;
|
||||
}
|
||||
});
|
||||
|
||||
return { labels, data, hasData };
|
||||
}
|
||||
|
||||
/**
|
||||
* 按天构建费用时间序列
|
||||
*/
|
||||
export function buildDailyCostSeries(
|
||||
usageData: unknown,
|
||||
modelPrices: Record<string, ModelPrice>
|
||||
): CostSeries {
|
||||
const details = collectUsageDetails(usageData);
|
||||
const dayMap: Record<string, number> = {};
|
||||
let hasData = false;
|
||||
|
||||
details.forEach((detail) => {
|
||||
const timestamp = Date.parse(detail.timestamp);
|
||||
if (Number.isNaN(timestamp)) return;
|
||||
const dayLabel = formatDayLabel(new Date(timestamp));
|
||||
if (!dayLabel) return;
|
||||
|
||||
const cost = calculateCost(detail, modelPrices);
|
||||
if (cost > 0) {
|
||||
dayMap[dayLabel] = (dayMap[dayLabel] || 0) + cost;
|
||||
hasData = true;
|
||||
}
|
||||
});
|
||||
|
||||
const labels = Object.keys(dayMap).sort();
|
||||
const data = labels.map((l) => dayMap[l]);
|
||||
|
||||
return { labels, data, hasData };
|
||||
}
|
||||
|
||||
@@ -63,7 +63,7 @@ export default defineConfig({
|
||||
}
|
||||
},
|
||||
build: {
|
||||
target: 'es2015',
|
||||
target: 'es2020',
|
||||
outDir: 'dist',
|
||||
assetsInlineLimit: 100000000,
|
||||
chunkSizeWarningLimit: 100000000,
|
||||
|
||||
Reference in New Issue
Block a user