Compare commits

...

21 Commits

Author SHA1 Message Date
Supra4E8C
b026285e65 feat: enhance provider item display with improved base URL styling and layout adjustments 2025-12-03 00:53:34 +08:00
Supra4E8C
fc8b02f58e feat: add error log selection and download functionality with UI updates and internationalization support 2025-12-03 00:49:27 +08:00
Supra4E8C
c77527cd13 feat: enhance excluded models management with UI components, internationalization, and data handling 2025-12-03 00:27:45 +08:00
Supra4E8C
d3630373ed feat: add OAuth excluded models management with UI integration and internationalization support 2025-12-03 00:01:16 +08:00
Supra4E8C
0114dad58d feat: implement endpoint cost calculation in API stats table with pricing support 2025-11-27 19:49:06 +08:00
Supra4E8C
ca14ab4917 feat: add cost period selection and update cost chart functionality 2025-11-27 19:39:59 +08:00
Supra4E8C
fd1956cb94 feat: implement model pricing functionality with UI elements, storage management, and cost calculation 2025-11-27 19:19:17 +08:00
Supra4E8C
b5d8d003e1 docs: update usage instructions in README files for clarity on static file access after build 2025-11-27 18:16:10 +08:00
Supra4E8C
96961d7b79 feat: add cached and reasoning token metrics with internationalization support 2025-11-27 18:04:47 +08:00
Supra4E8C
5415a61ad7 feat: add RPM and TPM metrics for the last 30 minutes with internationalization support 2025-11-27 17:59:47 +08:00
Supra4E8C
63a8b32c26 feat: expose manager instance to global scope for inline event handlers 2025-11-27 12:26:30 +08:00
hkfires
d8c06c7f6c feat: cap log fetch size and add limit query param 2025-11-24 20:53:37 +08:00
Supra4E8C
e3a2a34b70 更新 README_CN.md 2025-11-23 21:59:19 +08:00
Supra4E8C
f898d789da 更新 README.md 2025-11-23 21:58:30 +08:00
Supra4E8C
02faf18ceb refactor(docs): update README files with improved structure, feature descriptions, and usage instructions for better clarity and accessibility 2025-11-23 21:48:55 +08:00
Supra4E8C
efc6cb3863 feat(cookie-login): add iFlow Cookie login functionality with UI elements and internationalization support 2025-11-23 18:07:57 +08:00
Supra4E8C
970297f3ae feat(antigravity): implement Antigravity OAuth integration with UI elements and functionality 2025-11-23 17:56:17 +08:00
Supra4E8C
6962667171 style: increase max-height for key list to display more records at once 2025-11-23 17:15:40 +08:00
Supra4E8C
ef1be66cd6 style: enhance key table layout and adjust padding for improved aesthetics 2025-11-23 17:10:22 +08:00
Supra4E8C
ceddf7925f feat(api-keys): enhance API key display with new layout and styling 2025-11-23 12:31:17 +08:00
Supra4E8C
55c1cd84b3 feat(i18n): add support for 'antigravity' file type and update UI elements 2025-11-21 20:59:05 +08:00
15 changed files with 2610 additions and 396 deletions

216
README.md
View File

@@ -1,150 +1,49 @@
# Cli-Proxy-API-Management-Center # Cli-Proxy-API-Management-Center
This is a modern web interface for managing the CLI Proxy API. This is the modern WebUI for managing the CLI Proxy API.
[中文文档](README_CN.md) [中文文档](README_CN.md)
Main Project: Main Project: https://github.com/router-for-me/CLIProxyAPI
https://github.com/router-for-me/CLIProxyAPI Example URL: https://remote.router-for.me/
Minimum required version: ≥ 6.3.0 (recommended ≥ 6.5.0)
Example URL: Since 6.0.19 the WebUI ships with the main program; access it via `/management.html` on the API port once the service is running.
https://remote.router-for.me/
Minimum required version: ≥ 6.0.0
Recommended version: ≥ 6.2.32
Since version 6.0.19, the WebUI has been rolled into the main program. You can access it by going to `/management.html` on the external port after firing up the main project.
## Features ## Features
### Authentication Management ### Capabilities
- Supports management key authentication - **Login & UX**: Auto-detects the current address (manual override/reset supported), encrypted auto-login, language/theme toggles, responsive layout with mobile sidebar.
- Configurable API base address - **Basic Settings**: Debug, proxy URL, request retries, quota fallback (auto-switch project/preview models), usage-statistics toggle, request logging & logging-to-file switches, WebSocket `/ws/*` auth switch.
- Real-time connection status detection - **Keys & Providers**: Manage proxy auth keys, Gemini/Codex/Claude configs, OpenAI-compatible providers (custom base URLs/headers/proxy/model aliases), Vertex AI credential import from service-account JSON with optional location.
- Auto-login with saved credentials - **Auth Files & OAuth**: Upload/download/search/paginate JSON credentials; type filters (Qwen/Gemini/GeminiCLI/AIStudio/Claude/Codex/Antigravity/iFlow/Vertex/Empty); delete-all; OAuth/Device flows for Codex, Anthropic (Claude), Antigravity (Google), Gemini CLI (optional project), Qwen; iFlow OAuth and cookie login.
- Language and theme switching - **Logs**: Live viewer with auto-refresh/incremental updates, download and clear; section appears when logging-to-file is enabled.
- **Usage Analytics**: Overview cards, hourly/daily toggles, up to three model lines per chart, per-API stats table (Chart.js).
### Basic Settings - **Config Management**: In-browser CodeMirror YAML editor for `/config.yaml` with reload/save, syntax highlighting, and status feedback.
- **Debug Mode**: Enable/disable debugging - **System Info & Versioning**: Connection/config cache status, last refresh time, server version/build date, and UI version in the footer.
- **Proxy Settings**: Configure proxy server URL - **Security & Preferences**: Masked secrets, secure local storage, persistent theme/language/sidebar state, real-time status feedback.
- **Request Retries**: Set the number of request retries
- **Quota Management**: Configure behavior when the quota is exceeded
- Auto-switch project when quota exceeded
- Switch to preview models when quota exceeded
### API Key Management
- **Proxy Service Authentication Key**: Manage API keys for the proxy service
- **Gemini API**: Manage Google Gemini generative language API keys
- **Codex API**: Manage OpenAI Codex API configuration
- **Claude API**: Manage Anthropic Claude API configuration
- **OpenAI-Compatible Providers**: Manage OpenAI-compatible third-party providers
### Authentication File Management
- Upload authentication JSON files
- Download existing authentication files
- Delete single or all authentication files
- Display file details
### Usage Statistics
- **Real-time Analytics**: Track API usage with interactive charts
- **Request Trends**: Visualize request patterns by hour/day
- **Token Usage**: Monitor token consumption over time
- **API Details**: Detailed statistics for each API endpoint
- **Success/Failure Rates**: Track API reliability metrics
### System Information
- **Connection Status**: Real-time connection monitoring
- **Configuration Status**: Track configuration loading state
- **Server Information**: Display server address and management key
- **Last Update**: Show when data was last refreshed
## How to Use ## How to Use
### 1. Using After CLI Proxy API Program Launch (Recommended) 1) **After CLI Proxy API is running (recommended)**
Once the CLI Proxy API program is up and running, you can access the WebUI at `http://your-server-IP:8317/management.html`. Visit `http://your-server:8317/management.html`.
### 2. Direct Use 2) **Direct static use after build**
Simply open the `index.html` file directly in your browser to use it. The single file `dist/index.html` generated by `npm run build`
### 3. Use a Local Server 3) **Local server**
#### Option A: Using Node.js (npm)
```bash ```bash
# Install dependencies
npm install npm install
npm start # http://localhost:3000
# Start the server on the default port (3000) npm run dev # optional dev port: 3090
npm start # or
```
#### Option B: Using Python
```bash
# Python 3.x
python -m http.server 8000 python -m http.server 8000
``` ```
Then open the corresponding localhost URL.
Then open `http://localhost:8000` in your browser. 4) **Configure connection**
The login page shows the detected address; you can override it, enter the management key, and click Connect. Saved credentials use encrypted local storage for auto-login.
### 3. Configure Connection Tip: The Logs navigation item appears after enabling "Logging to file" in Basic Settings.
1. Open the management interface.
2. On the login screen, enter:
- **Remote Address**: The current version automatically picks up the remote address from where you're connecting. But you can also set your own address if you prefer.
- **Management Key**: Your management key
3. Click the "Connect" button.
4. Once connected successfully, all features will be available.
## Interface Description
### Navigation Menu
- **Basic Settings**: Basic configurations like debugging, proxy, retries, etc.
- **API Keys**: Management of keys for various API services.
- **AI Providers**: Configuration for AI service providers.
- **Auth Files**: Upload and download management for authentication files.
- **Usage Stats**: Real-time analytics and usage statistics with interactive charts.
- **System Info**: Connection status and system information.
### Login Interface
- **Auto-connection**: Automatically attempts to connect using saved credentials
- **Custom Connection**: Manual configuration of API base address
- **Current Address Detection**: Automatically detects and uses current access address
- **Language Switching**: Support for multiple languages (English/Chinese)
- **Theme Switching**: Light and dark theme support
## Feature Highlights
### Modern UI
- Responsive design, supports all screen sizes
- Beautiful gradient colors and shadow effects
- Smooth animations and transition effects
- Intuitive icons and status indicators
- Dark/Light theme support with system preference detection
- Mobile-friendly sidebar with overlay
### Real-time Updates
- Configuration changes take effect immediately
- Real-time status feedback
- Automatic data refresh
- Live usage statistics with interactive charts
- Real-time connection status monitoring
### Security Features
- Masked display for keys
- Secure credential storage
- Auto-login with encrypted local storage
### Responsive Design
- Perfectly adapts to desktop and mobile devices
- Adaptive layout with collapsible sidebar
- Touch-friendly interactions
- Mobile menu with overlay
### Analytics & Monitoring
- Interactive charts powered by Chart.js
- Real-time usage statistics
- Request trend visualization
- Token consumption tracking
- API performance metrics
## Tech Stack ## Tech Stack
@@ -152,11 +51,17 @@ Then open `http://localhost:8000` in your browser.
- **Styling**: CSS3 + Flexbox/Grid with CSS Variables - **Styling**: CSS3 + Flexbox/Grid with CSS Variables
- **Icons**: Font Awesome 6.4.0 - **Icons**: Font Awesome 6.4.0
- **Charts**: Chart.js for interactive data visualization - **Charts**: Chart.js for interactive data visualization
- **Editor/Parsing**: CodeMirror + js-yaml
- **Fonts**: Segoe UI system font - **Fonts**: Segoe UI system font
- **API**: RESTful API calls with automatic authentication - **Internationalization**: Custom i18n (EN/CN) and theme system (light/dark)
- **Internationalization**: Custom i18n system with English/Chinese support - **API**: RESTful management endpoints with automatic authentication
- **Theme System**: CSS custom properties for dynamic theming - **Storage**: LocalStorage with lightweight encryption for preferences/credentials
- **Storage**: LocalStorage for user preferences and credentials
## Build & Development
- `npm run build` bundles everything into `dist/index.html` via webpack (`build.cjs`, `bundle-entry.js`, `build-scripts/prepare-html.js`).
- External CDNs remain for Font Awesome, Chart.js, and CodeMirror to keep the bundle lean.
- Development servers: `npm start` (3000) or `npm run dev` (3090); Python `http.server` also works for static hosting.
## Troubleshooting ## Troubleshooting
@@ -171,38 +76,31 @@ Then open `http://localhost:8000` in your browser.
2. Check your network connection. 2. Check your network connection.
3. Check the browser's console for any error messages. 3. Check the browser's console for any error messages.
## Development Information ### Logs & Config Editor
- Logs: Requires server-side logging-to-file; 404 indicates the server build is too old or logging is disabled.
- Config editor: Requires `/config.yaml` endpoint; keep YAML valid before saving.
### File Structure ### Usage Stats
- Enable "Usage statistics" if charts stay empty; data resets on server restart.
## Project Structure
``` ```
webui/ ├── index.html
├── index.html # Main page with responsive layout ├── styles.css
├── styles.css # Stylesheet with theme support ├── app.js
├── app.js # Application logic and API management ├── i18n.js
├── i18n.js # Internationalization support (EN/CN) ├── src/ # Core/modules/utils source code
├── package.json # Project configuration ├── build.cjs # Webpack build script
├── build.js # Build script for production ├── bundle-entry.js # Bundling entry
├── bundle-entry.js # Entry point for bundling
├── build-scripts/ # Build utilities ├── build-scripts/ # Build utilities
│ └── prepare-html.js # HTML preparation script │ └── prepare-html.js
├── logo.jpg # Application logo ├── dist/ # Bundled single-file output
├── LICENSE # MIT License ├── BUILD_RELEASE.md
├── README.md # English documentation ├── LICENSE
├── README_CN.md # Chinese documentation ├── README.md
└── BUILD_RELEASE.md # Build and release notes └── README_CN.md
``` ```
### API Calls
All API calls are handled through the `makeRequest` method of the `ManagerAPI` class, which includes:
- Automatic addition of authentication headers
- Error handling
- JSON response parsing
### State Management
- API address and key are saved in local storage
- Connection status is maintained in memory
- Real-time data refresh mechanism
## Contributing ## Contributing
We welcome Issues and Pull Requests to improve this project! We encourage more developers to contribute to the enhancement of this WebUI! We welcome Issues and Pull Requests to improve this project! We encourage more developers to contribute to the enhancement of this WebUI!

View File

@@ -1,149 +1,49 @@
# Cli-Proxy-API-Management-Center # Cli-Proxy-API-Management-Center
这是一个用于管理 CLI Proxy API 的现代化 Web 界面。 这是一个用于管理 CLI Proxy API 的现代化 Web 界面。
主项目 [English](README.md)
https://github.com/router-for-me/CLIProxyAPI
示例网站: 主项目: https://github.com/router-for-me/CLIProxyAPI
https://remote.router-for.me/ 示例网站: https://remote.router-for.me/
最低版本 ≥ 6.3.0(推荐 ≥ 6.5.0
最低可用版本 ≥ 6.0.0 自 6.0.19 起 WebUI 已集成到主程序中,启动后可通过 `/management.html` 访问。
推荐版本 ≥ 6.2.32
自6.0.19起WebUI已经集成在主程序中 可以通过主项目开启的外部端口的`/management.html`访问
## 功能特点 ## 功能特点
### 认证管理 ### 主要能力
- 支持管理密钥认证 - **登录与体验**: 自动检测当前地址(可自定义/重置),加密自动登录,语言/主题切换,响应式布局与移动端侧边栏。
- 可配置 API 基础地址 - **基础设置**: 调试、代理 URL、请求重试配额溢出自动切换项目/预览模型使用统计开关请求日志与文件日志开关WebSocket `/ws/*` 鉴权开关。
- 实时连接状态检测 - **密钥与提供商**: 管理代理服务密钥Gemini/Codex/Claude 配置OpenAI 兼容提供商(自定义 Base URL/Headers/Proxy/模型别名Vertex AI 服务账号导入(可选区域)。
- 自动登录保存的凭据 - **认证文件与 OAuth**: 上传/下载/搜索/分页 JSON 凭据类型筛选Qwen/Gemini/GeminiCLI/AIStudio/Claude/Codex/Antigravity/iFlow/Vertex/Empty一键删除全部Codex、Anthropic(Claude)、Antigravity(Google)、Gemini CLI可选项目、Qwen 设备码、iFlow OAuth 与 Cookie 登录。
- 语言和主题切换 - **日志**: 实时查看并增量刷新,支持下载和清空;启用“写入日志文件”后出现日志栏目。
- **使用统计**: 概览卡片、小时/天切换、最多三条模型曲线、按 API 统计表Chart.js
### 基础设置 - **配置管理**: 内置 CodeMirror YAML 编辑器,在线读取/保存 `/config.yaml`,语法高亮与状态提示。
- **调试模式**: 开启/关闭调试功能 - **系统与版本**: 连接/配置缓存状态、最后刷新时间,底栏显示服务版本、构建时间与 UI 版本。
- **代理设置**: 配置代理服务器 URL - **安全与偏好**: 密钥遮蔽、加密本地存储,主题/语言/侧边栏状态持久化,实时状态反馈。
- **请求重试**: 设置请求重试次数
- **配额管理**: 配置超出配额时的行为
- 超出配额时自动切换项目
- 超出配额时切换到预览模型
### API 密钥管理
- **代理服务认证密钥**: 管理用于代理服务的 API 密钥
- **Gemini API**: 管理 Google Gemini 生成式语言 API 密钥
- **Codex API**: 管理 OpenAI Codex API 配置
- **Claude API**: 管理 Anthropic Claude API 配置
- **OpenAI 兼容提供商**: 管理 OpenAI 兼容的第三方提供商
### 认证文件管理
- 上传认证 JSON 文件
- 下载现有认证文件
- 删除单个或所有认证文件
- 显示文件详细信息
### 使用统计
- **实时分析**: 通过交互式图表跟踪 API 使用情况
- **请求趋势**: 按小时/天可视化请求模式
- **Token 使用**: 监控 Token 消耗随时间变化
- **API 详情**: 每个 API 端点的详细统计
- **成功率/失败率**: 跟踪 API 可靠性指标
### 系统信息
- **连接状态**: 实时连接监控
- **配置状态**: 跟踪配置加载状态
- **服务器信息**: 显示服务器地址和管理密钥
- **最后更新**: 显示数据最后刷新时间
## 使用方法 ## 使用方法
### 1. 在CLI Proxy API程序启动后使用 (推荐) 1) **主程序启动后使用(推荐)**
在启动了CLI Proxy API程序后 访问`http://您的服务器IP:8317/management.html`使用 访问 `http://您的服务器:8317/management.html`
### 2. 直接使用 2) **构建后直接静态打开**
直接用浏览器打开 `index.html` 文件即可使用。 `npm run build` 生成的 `dist/index.html` 文件
### 3. 使用本地服务器 3) **本地服务器**
#### 方法A使用 Node.js (npm)
```bash ```bash
# 安装依赖
npm install npm install
npm start # 默认 http://localhost:3000
# 使用默认端口(3000 npm run dev # 可选开发端口 3090
npm start # 或
```
#### 方法B使用 Python
```bash
# Python 3.x
python -m http.server 8000 python -m http.server 8000
``` ```
然后在浏览器打开对应的 localhost 地址。
然后在浏览器中打开 `http://localhost:8000` 4) **配置连接**
登录页会显示自动检测的地址,可自行修改,填入管理密钥后点击连接。凭据将加密保存以便下次自动登录。
### 3. 配置连接 提示: 开启“写入日志文件”后才会显示“日志查看”导航。
1. 打开管理界面
2. 在登录界面上输入:
- **远程地址**: 现版本远程地址将会自动从您的访问地址中获取 当然您也可以自定义
- **管理密钥**: 您的管理密钥
3. 点击"连接"按钮
4. 连接成功后即可使用所有功能
## 界面说明
### 导航菜单
- **基础设置**: 调试、代理、重试等基本配置
- **API 密钥**: 各种 API 服务的密钥管理
- **AI 提供商**: AI 服务提供商配置
- **认证文件**: 认证文件的上传下载管理
- **使用统计**: 实时分析和使用统计,包含交互式图表
- **系统信息**: 连接状态和系统信息
### 登录界面
- **自动连接**: 使用保存的凭据自动尝试连接
- **自定义连接**: 手动配置 API 基础地址
- **当前地址检测**: 自动检测并使用当前访问地址
- **语言切换**: 支持多种语言(英文/中文)
- **主题切换**: 支持明暗主题
## 特性亮点
### 现代化 UI
- 响应式设计,支持各种屏幕尺寸
- 美观的渐变色彩和阴影效果
- 流畅的动画和过渡效果
- 直观的图标和状态指示
- 明暗主题支持,自动检测系统偏好
- 移动端友好的侧边栏和遮罩
### 实时更新
- 配置更改立即生效
- 实时状态反馈
- 自动数据刷新
- 实时使用统计和交互式图表
- 实时连接状态监控
### 安全特性
- 密钥遮蔽显示
- 安全凭据存储
- 加密本地存储自动登录
### 响应式设计
- 完美适配桌面和移动设备
- 自适应布局,可折叠侧边栏
- 触摸友好的交互
- 移动端菜单和遮罩
### 分析与监控
- Chart.js 驱动的交互式图表
- 实时使用统计
- 请求趋势可视化
- Token 消耗跟踪
- API 性能指标
## 技术栈 ## 技术栈
@@ -151,11 +51,16 @@ python -m http.server 8000
- **样式**: CSS3 + Flexbox/Grid支持 CSS 变量 - **样式**: CSS3 + Flexbox/Grid支持 CSS 变量
- **图标**: Font Awesome 6.4.0 - **图标**: Font Awesome 6.4.0
- **图表**: Chart.js 交互式数据可视化 - **图表**: Chart.js 交互式数据可视化
- **字体**: Segoe UI 系统字体 - **编辑/解析**: CodeMirror + js-yaml
- **API**: RESTful API 调用,自动认证 - **国际化**: 自定义 i18n中/英)与主题系统(明/暗)
- **国际化**: 自定义 i18n 系统,支持中英文 - **API**: RESTful 管理接口,自动附加认证
- **主题系统**: CSS 自定义属性动态主题 - **存储**: LocalStorage 轻量加密存储偏好与凭据
- **存储**: LocalStorage 用户偏好和凭据存储
## 构建与开发
- `npm run build` 通过 webpack`build.cjs``bundle-entry.js``build-scripts/prepare-html.js`)打包为 `dist/index.html`
- Font Awesome、Chart.js、CodeMirror 仍走 CDN减小打包体积。
- 开发可用 `npm start` (3000) / `npm run dev` (3090) 或 `python -m http.server` 静态托管。
## 故障排除 ## 故障排除
@@ -170,39 +75,32 @@ python -m http.server 8000
2. 检查网络连接 2. 检查网络连接
3. 查看浏览器控制台错误信息 3. 查看浏览器控制台错误信息
## 开发说明 ### 日志与配置编辑
- 日志: 需要服务端开启写文件日志;返回 404 说明版本过旧或未启用。
- 配置编辑: 依赖 `/config.yaml` 接口,保存前请确保 YAML 语法正确。
### 文件结构 ### 使用统计
- 若图表为空,请开启“使用统计”;数据在服务重启后会清空。
## 项目结构
``` ```
webui/ ├── index.html
├── index.html # 主页面,响应式布局 ├── styles.css
├── styles.css # 样式文件,支持主题 ├── app.js
├── app.js # 应用逻辑和 API 管理 ├── i18n.js
├── i18n.js # 国际化支持(中英文) ├── src/ # 核心/模块/工具源码
├── package.json # 项目配置 ├── build.cjs # Webpack 构建脚本
├── build.js # 生产环境构建脚本 ├── bundle-entry.js # 打包入口
├── bundle-entry.js # 打包入口文件
├── build-scripts/ # 构建工具 ├── build-scripts/ # 构建工具
│ └── prepare-html.js # HTML 准备脚本 │ └── prepare-html.js
├── logo.jpg # 应用图标 ├── dist/ # 打包输出单文件
├── LICENSE # MIT 许可证 ├── BUILD_RELEASE.md
├── README.md # 英文文档 ├── LICENSE
├── README_CN.md # 中文文档 ├── README.md
└── BUILD_RELEASE.md # 构建和发布说明 └── README_CN.md
``` ```
### API 调用
所有 API 调用都通过 `ManagerAPI` 类的 `makeRequest` 方法处理,包含:
- 自动添加认证头
- 错误处理
- JSON 响应解析
### 状态管理
- 本地存储保存 API 地址和密钥
- 内存中维护连接状态
- 实时数据刷新机制
## 贡献 ## 贡献
欢迎提交 Issue 和 Pull Request 来改进这个项目我们欢迎更多的大佬来对这个WebUI进行更新 欢迎提交 Issue 和 Pull Request 来改进这个项目!我们欢迎更多的大佬来对这个 WebUI 进行更新!
本项目采用MIT许可 本项目采用 MIT 许可

81
app.js
View File

@@ -14,12 +14,13 @@ import { aiProvidersModule } from './src/modules/ai-providers.js';
// 工具函数导入 // 工具函数导入
import { escapeHtml } from './src/utils/html.js'; import { escapeHtml } from './src/utils/html.js';
import { maskApiKey } from './src/utils/string.js'; import { maskApiKey, formatFileSize } from './src/utils/string.js';
import { normalizeArrayResponse } from './src/utils/array.js'; import { normalizeArrayResponse } from './src/utils/array.js';
import { debounce } from './src/utils/dom.js'; import { debounce } from './src/utils/dom.js';
import { import {
CACHE_EXPIRY_MS, CACHE_EXPIRY_MS,
MAX_LOG_LINES, MAX_LOG_LINES,
LOG_FETCH_LIMIT,
DEFAULT_AUTH_FILES_PAGE_SIZE, DEFAULT_AUTH_FILES_PAGE_SIZE,
MIN_AUTH_FILES_PAGE_SIZE, MIN_AUTH_FILES_PAGE_SIZE,
MAX_AUTH_FILES_PAGE_SIZE, MAX_AUTH_FILES_PAGE_SIZE,
@@ -78,6 +79,7 @@ class CLIProxyManager {
// 当前展示的日志行 // 当前展示的日志行
this.displayedLogLines = []; this.displayedLogLines = [];
this.maxDisplayLogLines = MAX_LOG_LINES; this.maxDisplayLogLines = MAX_LOG_LINES;
this.logFetchLimit = LOG_FETCH_LIMIT;
// 日志时间戳(用于增量加载) // 日志时间戳(用于增量加载)
this.latestLogTimestamp = null; this.latestLogTimestamp = null;
@@ -95,6 +97,10 @@ class CLIProxyManager {
this.authFilesPageSizeKey = STORAGE_KEY_AUTH_FILES_PAGE_SIZE; this.authFilesPageSizeKey = STORAGE_KEY_AUTH_FILES_PAGE_SIZE;
this.loadAuthFilePreferences(); this.loadAuthFilePreferences();
// OAuth 模型排除列表状态
this.oauthExcludedModels = {};
this._oauthExcludedLoading = false;
// Vertex AI credential import state // Vertex AI credential import state
this.vertexImportState = { this.vertexImportState = {
file: null, file: null,
@@ -221,6 +227,7 @@ class CLIProxyManager {
const cardText = card.textContent || ''; const cardText = card.textContent || '';
if (cardText.includes('Codex OAuth') || if (cardText.includes('Codex OAuth') ||
cardText.includes('Anthropic OAuth') || cardText.includes('Anthropic OAuth') ||
cardText.includes('Antigravity OAuth') ||
cardText.includes('Gemini CLI OAuth') || cardText.includes('Gemini CLI OAuth') ||
cardText.includes('Qwen OAuth') || cardText.includes('Qwen OAuth') ||
cardText.includes('iFlow OAuth')) { cardText.includes('iFlow OAuth')) {
@@ -322,6 +329,7 @@ class CLIProxyManager {
// 日志查看 // 日志查看
const refreshLogs = document.getElementById('refresh-logs'); const refreshLogs = document.getElementById('refresh-logs');
const selectErrorLog = document.getElementById('select-error-log');
const downloadLogs = document.getElementById('download-logs'); const downloadLogs = document.getElementById('download-logs');
const clearLogs = document.getElementById('clear-logs'); const clearLogs = document.getElementById('clear-logs');
const logsAutoRefreshToggle = document.getElementById('logs-auto-refresh-toggle'); const logsAutoRefreshToggle = document.getElementById('logs-auto-refresh-toggle');
@@ -329,6 +337,9 @@ class CLIProxyManager {
if (refreshLogs) { if (refreshLogs) {
refreshLogs.addEventListener('click', () => this.refreshLogs()); refreshLogs.addEventListener('click', () => this.refreshLogs());
} }
if (selectErrorLog) {
selectErrorLog.addEventListener('click', () => this.openErrorLogsModal());
}
if (downloadLogs) { if (downloadLogs) {
downloadLogs.addEventListener('click', () => this.downloadLogs()); downloadLogs.addEventListener('click', () => this.downloadLogs());
} }
@@ -382,6 +393,17 @@ class CLIProxyManager {
this.bindAuthFilesPageSizeControl(); this.bindAuthFilesPageSizeControl();
this.syncAuthFileControls(); this.syncAuthFileControls();
// OAuth 排除列表
const oauthExcludedAdd = document.getElementById('oauth-excluded-add');
const oauthExcludedRefresh = document.getElementById('oauth-excluded-refresh');
if (oauthExcludedAdd) {
oauthExcludedAdd.addEventListener('click', () => this.openOauthExcludedEditor());
}
if (oauthExcludedRefresh) {
oauthExcludedRefresh.addEventListener('click', () => this.loadOauthExcludedModels(true));
}
// Vertex AI credential import // Vertex AI credential import
const vertexSelectFile = document.getElementById('vertex-select-file'); const vertexSelectFile = document.getElementById('vertex-select-file');
const vertexFileInput = document.getElementById('vertex-file-input'); const vertexFileInput = document.getElementById('vertex-file-input');
@@ -430,6 +452,21 @@ class CLIProxyManager {
anthropicCopyLink.addEventListener('click', () => this.copyAnthropicLink()); anthropicCopyLink.addEventListener('click', () => this.copyAnthropicLink());
} }
// Antigravity OAuth
const antigravityOauthBtn = document.getElementById('antigravity-oauth-btn');
const antigravityOpenLink = document.getElementById('antigravity-open-link');
const antigravityCopyLink = document.getElementById('antigravity-copy-link');
if (antigravityOauthBtn) {
antigravityOauthBtn.addEventListener('click', () => this.startAntigravityOAuth());
}
if (antigravityOpenLink) {
antigravityOpenLink.addEventListener('click', () => this.openAntigravityLink());
}
if (antigravityCopyLink) {
antigravityCopyLink.addEventListener('click', () => this.copyAntigravityLink());
}
// Gemini CLI OAuth // Gemini CLI OAuth
const geminiCliOauthBtn = document.getElementById('gemini-cli-oauth-btn'); const geminiCliOauthBtn = document.getElementById('gemini-cli-oauth-btn');
const geminiCliOpenLink = document.getElementById('gemini-cli-open-link'); const geminiCliOpenLink = document.getElementById('gemini-cli-open-link');
@@ -464,6 +501,7 @@ class CLIProxyManager {
const iflowOauthBtn = document.getElementById('iflow-oauth-btn'); const iflowOauthBtn = document.getElementById('iflow-oauth-btn');
const iflowOpenLink = document.getElementById('iflow-open-link'); const iflowOpenLink = document.getElementById('iflow-open-link');
const iflowCopyLink = document.getElementById('iflow-copy-link'); const iflowCopyLink = document.getElementById('iflow-copy-link');
const iflowCookieSubmit = document.getElementById('iflow-cookie-submit');
if (iflowOauthBtn) { if (iflowOauthBtn) {
iflowOauthBtn.addEventListener('click', () => this.startIflowOAuth()); iflowOauthBtn.addEventListener('click', () => this.startIflowOAuth());
@@ -474,6 +512,9 @@ class CLIProxyManager {
if (iflowCopyLink) { if (iflowCopyLink) {
iflowCopyLink.addEventListener('click', () => this.copyIflowLink()); iflowCopyLink.addEventListener('click', () => this.copyIflowLink());
} }
if (iflowCookieSubmit) {
iflowCookieSubmit.addEventListener('click', () => this.submitIflowCookieLogin());
}
// 使用统计 // 使用统计
const refreshUsageStats = document.getElementById('refresh-usage-stats'); const refreshUsageStats = document.getElementById('refresh-usage-stats');
@@ -481,7 +522,12 @@ class CLIProxyManager {
const requestsDayBtn = document.getElementById('requests-day-btn'); const requestsDayBtn = document.getElementById('requests-day-btn');
const tokensHourBtn = document.getElementById('tokens-hour-btn'); const tokensHourBtn = document.getElementById('tokens-hour-btn');
const tokensDayBtn = document.getElementById('tokens-day-btn'); const tokensDayBtn = document.getElementById('tokens-day-btn');
const costHourBtn = document.getElementById('cost-hour-btn');
const costDayBtn = document.getElementById('cost-day-btn');
const chartLineSelects = document.querySelectorAll('.chart-line-select'); const chartLineSelects = document.querySelectorAll('.chart-line-select');
const modelPriceForm = document.getElementById('model-price-form');
const resetModelPricesBtn = document.getElementById('reset-model-prices');
const modelPriceSelect = document.getElementById('model-price-model-select');
if (refreshUsageStats) { if (refreshUsageStats) {
refreshUsageStats.addEventListener('click', () => this.loadUsageStats()); refreshUsageStats.addEventListener('click', () => this.loadUsageStats());
@@ -498,6 +544,12 @@ class CLIProxyManager {
if (tokensDayBtn) { if (tokensDayBtn) {
tokensDayBtn.addEventListener('click', () => this.switchTokensPeriod('day')); tokensDayBtn.addEventListener('click', () => this.switchTokensPeriod('day'));
} }
if (costHourBtn) {
costHourBtn.addEventListener('click', () => this.switchCostPeriod('hour'));
}
if (costDayBtn) {
costDayBtn.addEventListener('click', () => this.switchCostPeriod('day'));
}
if (chartLineSelects.length) { if (chartLineSelects.length) {
chartLineSelects.forEach(select => { chartLineSelects.forEach(select => {
select.addEventListener('change', (event) => { select.addEventListener('change', (event) => {
@@ -506,6 +558,18 @@ class CLIProxyManager {
}); });
}); });
} }
if (modelPriceForm) {
modelPriceForm.addEventListener('submit', (event) => {
event.preventDefault();
this.handleModelPriceSubmit();
});
}
if (resetModelPricesBtn) {
resetModelPricesBtn.addEventListener('click', () => this.handleModelPriceReset());
}
if (modelPriceSelect) {
modelPriceSelect.addEventListener('change', () => this.prefillModelPriceInputs());
}
// 模态框 // 模态框
const closeBtn = document.querySelector('.close'); const closeBtn = document.querySelector('.close');
@@ -595,6 +659,7 @@ class CLIProxyManager {
// 使用统计状态 // 使用统计状态
requestsChart = null; requestsChart = null;
tokensChart = null; tokensChart = null;
costChart = null;
currentUsageData = null; currentUsageData = null;
chartLineSelections = ['none', 'none', 'none']; chartLineSelections = ['none', 'none', 'none'];
chartLineSelectIds = ['chart-line-select-0', 'chart-line-select-1', 'chart-line-select-2']; chartLineSelectIds = ['chart-line-select-0', 'chart-line-select-1', 'chart-line-select-2'];
@@ -603,6 +668,9 @@ class CLIProxyManager {
{ borderColor: '#a855f7', backgroundColor: 'rgba(168, 85, 247, 0.15)' }, { borderColor: '#a855f7', backgroundColor: 'rgba(168, 85, 247, 0.15)' },
{ borderColor: '#10b981', backgroundColor: 'rgba(16, 185, 129, 0.15)' } { borderColor: '#10b981', backgroundColor: 'rgba(16, 185, 129, 0.15)' }
]; ];
modelPriceStorageKey = 'cli-proxy-model-prices-v2';
modelPrices = {};
modelPriceInitialized = false;
showModal() { showModal() {
const modal = document.getElementById('modal'); const modal = document.getElementById('modal');
@@ -641,12 +709,22 @@ Object.assign(
// 将工具函数绑定到原型上,供模块使用 // 将工具函数绑定到原型上,供模块使用
CLIProxyManager.prototype.escapeHtml = escapeHtml; CLIProxyManager.prototype.escapeHtml = escapeHtml;
CLIProxyManager.prototype.maskApiKey = maskApiKey; CLIProxyManager.prototype.maskApiKey = maskApiKey;
CLIProxyManager.prototype.formatFileSize = formatFileSize;
CLIProxyManager.prototype.normalizeArrayResponse = normalizeArrayResponse; CLIProxyManager.prototype.normalizeArrayResponse = normalizeArrayResponse;
CLIProxyManager.prototype.debounce = debounce; CLIProxyManager.prototype.debounce = debounce;
// 全局管理器实例 // 全局管理器实例
let manager; let manager;
// 让内联事件处理器可以访问到 manager 实例
function exposeManagerInstance(instance) {
if (typeof window !== 'undefined') {
window.manager = instance;
} else if (typeof globalThis !== 'undefined') {
globalThis.manager = instance;
}
}
// 尝试自动加载根目录 Logo支持多种常见文件名/扩展名) // 尝试自动加载根目录 Logo支持多种常见文件名/扩展名)
function setupSiteLogo() { function setupSiteLogo() {
const img = document.getElementById('site-logo'); const img = document.getElementById('site-logo');
@@ -701,4 +779,5 @@ document.addEventListener('DOMContentLoaded', () => {
setupSiteLogo(); setupSiteLogo();
manager = new CLIProxyManager(); manager = new CLIProxyManager();
exposeManagerInstance(manager);
}); });

View File

@@ -169,42 +169,44 @@ function build() {
console.log(`使用版本号: ${version}`); console.log(`使用版本号: ${version}`);
html = html.replace(/__VERSION__/g, version); html = html.replace(/__VERSION__/g, version);
html = html.replace( html = html.replace(
'<link rel="stylesheet" href="styles.css">', '<link rel="stylesheet" href="styles.css">',
`<style> () => `<style>
${css} ${css}
</style>` </style>`
); );
html = html.replace(
'<script src="i18n.js"></script>',
() => `<script>
${i18n}
</script>`
);
html = html.replace( const scriptTagRegex = /<script[^>]*src="app\.js"[^>]*><\/script>/i;
'<script src="i18n.js"></script>', if (scriptTagRegex.test(html)) {
`<script> html = html.replace(
${i18n} scriptTagRegex,
</script>` () => `<script>
); ${app}
</script>`
const scriptTagRegex = /<script[^>]*src="app\.js"[^>]*><\/script>/i; );
if (scriptTagRegex.test(html)) {
html = html.replace(
scriptTagRegex,
`<script>
${app}
</script>`
);
} else { } else {
console.warn('未找到 app.js 脚本标签,未内联应用代码。'); console.warn('未找到 app.js 脚本标签,未内联应用代码。');
} }
const logoDataUrl = loadLogoDataUrl(); const logoDataUrl = loadLogoDataUrl();
if (logoDataUrl) { if (logoDataUrl) {
const logoScript = `<script>window.__INLINE_LOGO__ = "${logoDataUrl}";</script>`; const logoScript = `<script>window.__INLINE_LOGO__ = "${logoDataUrl}";</script>`;
if (html.includes('</body>')) { const closingBodyTag = '</body>';
html = html.replace('</body>', `${logoScript}\n</body>`); const closingBodyIndex = html.lastIndexOf(closingBodyTag);
} else { if (closingBodyIndex !== -1) {
html += `\n${logoScript}`; html = `${html.slice(0, closingBodyIndex)}${logoScript}\n${closingBodyTag}${html.slice(closingBodyIndex + closingBodyTag.length)}`;
} } else {
} else { html += `\n${logoScript}`;
console.warn('未找到可内联的 Logo 文件,将保持运行时加载。'); }
} else {
console.warn('未找到可内联的 Logo 文件,将保持运行时加载。');
} }
const outputPath = path.join(distDir, 'index.html'); const outputPath = path.join(distDir, 'index.html');

198
i18n.js
View File

@@ -149,6 +149,10 @@ const i18n = {
'ai_providers.gemini_edit_modal_title': '编辑Gemini API密钥', 'ai_providers.gemini_edit_modal_title': '编辑Gemini API密钥',
'ai_providers.gemini_edit_modal_key_label': 'API密钥:', 'ai_providers.gemini_edit_modal_key_label': 'API密钥:',
'ai_providers.gemini_delete_confirm': '确定要删除这个Gemini密钥吗', 'ai_providers.gemini_delete_confirm': '确定要删除这个Gemini密钥吗',
'ai_providers.excluded_models_label': '排除的模型 (可选):',
'ai_providers.excluded_models_placeholder': '用逗号或换行分隔,例如: gemini-1.5-pro, gemini-1.5-flash',
'ai_providers.excluded_models_hint': '留空表示不过滤;保存时会自动去重并忽略空白。',
'ai_providers.excluded_models_count': '排除 {count} 个模型',
'ai_providers.codex_title': 'Codex API 配置', 'ai_providers.codex_title': 'Codex API 配置',
'ai_providers.codex_add_button': '添加配置', 'ai_providers.codex_add_button': '添加配置',
@@ -272,6 +276,7 @@ const i18n = {
'auth_files.filter_aistudio': 'AIStudio', 'auth_files.filter_aistudio': 'AIStudio',
'auth_files.filter_claude': 'Claude', 'auth_files.filter_claude': 'Claude',
'auth_files.filter_codex': 'Codex', 'auth_files.filter_codex': 'Codex',
'auth_files.filter_antigravity': 'Antigravity',
'auth_files.filter_iflow': 'iFlow', 'auth_files.filter_iflow': 'iFlow',
'auth_files.filter_vertex': 'Vertex', 'auth_files.filter_vertex': 'Vertex',
'auth_files.filter_empty': '空文件', 'auth_files.filter_empty': '空文件',
@@ -282,6 +287,7 @@ const i18n = {
'auth_files.type_aistudio': 'AIStudio', 'auth_files.type_aistudio': 'AIStudio',
'auth_files.type_claude': 'Claude', 'auth_files.type_claude': 'Claude',
'auth_files.type_codex': 'Codex', 'auth_files.type_codex': 'Codex',
'auth_files.type_antigravity': 'Antigravity',
'auth_files.type_iflow': 'iFlow', 'auth_files.type_iflow': 'iFlow',
'auth_files.type_vertex': 'Vertex', 'auth_files.type_vertex': 'Vertex',
'auth_files.type_empty': '空文件', 'auth_files.type_empty': '空文件',
@@ -304,6 +310,40 @@ const i18n = {
'vertex_import.result_location': '区域', 'vertex_import.result_location': '区域',
'vertex_import.result_file': '存储文件', 'vertex_import.result_file': '存储文件',
// OAuth 排除模型
'oauth_excluded.title': 'OAuth 排除列表',
'oauth_excluded.description': '按提供商分列展示,点击卡片编辑或删除;支持 * 通配符,范围跟随上方的配置文件过滤标签。',
'oauth_excluded.add': '新增排除',
'oauth_excluded.add_title': '新增提供商排除列表',
'oauth_excluded.edit_title': '编辑 {provider} 的排除列表',
'oauth_excluded.refresh': '刷新',
'oauth_excluded.refreshing': '刷新中...',
'oauth_excluded.provider_label': '提供商',
'oauth_excluded.provider_auto': '跟随当前过滤',
'oauth_excluded.provider_placeholder': '例如 gemini-cli / openai',
'oauth_excluded.provider_hint': '默认选中当前筛选的提供商,也可直接输入或选择其他名称。',
'oauth_excluded.models_label': '排除的模型',
'oauth_excluded.models_placeholder': 'gpt-4.1-mini\n*-preview',
'oauth_excluded.models_hint': '逗号或换行分隔;留空保存将删除该提供商记录;支持 * 通配符。',
'oauth_excluded.save': '保存/更新',
'oauth_excluded.saving': '正在保存...',
'oauth_excluded.save_success': '排除列表已更新',
'oauth_excluded.save_failed': '更新排除列表失败',
'oauth_excluded.delete': '删除提供商',
'oauth_excluded.delete_confirm': '确定要删除 {provider} 的排除列表吗?',
'oauth_excluded.delete_success': '已删除该提供商的排除列表',
'oauth_excluded.delete_failed': '删除排除列表失败',
'oauth_excluded.deleting': '正在删除...',
'oauth_excluded.no_models': '未配置排除模型',
'oauth_excluded.model_count': '排除 {count} 个模型',
'oauth_excluded.list_empty_all': '暂无任何提供商的排除列表,点击“新增排除”创建。',
'oauth_excluded.list_empty_filtered': '当前筛选下没有排除项,点击“新增排除”添加。',
'oauth_excluded.disconnected': '请先连接服务器以查看排除列表',
'oauth_excluded.load_failed': '加载排除列表失败',
'oauth_excluded.provider_required': '请先填写提供商名称',
'oauth_excluded.scope_all': '当前范围:全局(显示所有提供商)',
'oauth_excluded.scope_provider': '当前范围:{provider}',
// Codex OAuth // Codex OAuth
'auth_login.codex_oauth_title': 'Codex OAuth', 'auth_login.codex_oauth_title': 'Codex OAuth',
@@ -331,6 +371,19 @@ const i18n = {
'auth_login.anthropic_oauth_start_error': '启动 Anthropic OAuth 失败:', 'auth_login.anthropic_oauth_start_error': '启动 Anthropic OAuth 失败:',
'auth_login.anthropic_oauth_polling_error': '检查认证状态失败:', 'auth_login.anthropic_oauth_polling_error': '检查认证状态失败:',
// Antigravity OAuth
'auth_login.antigravity_oauth_title': 'Antigravity OAuth',
'auth_login.antigravity_oauth_button': '开始 Antigravity 登录',
'auth_login.antigravity_oauth_hint': '通过 OAuth 流程登录 AntigravityGoogle 账号)服务,自动获取并保存认证文件。',
'auth_login.antigravity_oauth_url_label': '授权链接:',
'auth_login.antigravity_open_link': '打开链接',
'auth_login.antigravity_copy_link': '复制链接',
'auth_login.antigravity_oauth_status_waiting': '等待认证中...',
'auth_login.antigravity_oauth_status_success': '认证成功!',
'auth_login.antigravity_oauth_status_error': '认证失败:',
'auth_login.antigravity_oauth_start_error': '启动 Antigravity OAuth 失败:',
'auth_login.antigravity_oauth_polling_error': '检查认证状态失败:',
// Gemini CLI OAuth // Gemini CLI OAuth
'auth_login.gemini_cli_oauth_title': 'Gemini CLI OAuth', 'auth_login.gemini_cli_oauth_title': 'Gemini CLI OAuth',
'auth_login.gemini_cli_oauth_button': '开始 Gemini CLI 登录', 'auth_login.gemini_cli_oauth_button': '开始 Gemini CLI 登录',
@@ -373,6 +426,20 @@ const i18n = {
'auth_login.iflow_oauth_status_error': '认证失败:', 'auth_login.iflow_oauth_status_error': '认证失败:',
'auth_login.iflow_oauth_start_error': '启动 iFlow OAuth 失败:', 'auth_login.iflow_oauth_start_error': '启动 iFlow OAuth 失败:',
'auth_login.iflow_oauth_polling_error': '检查认证状态失败:', 'auth_login.iflow_oauth_polling_error': '检查认证状态失败:',
'auth_login.iflow_cookie_title': 'iFlow Cookie 登录',
'auth_login.iflow_cookie_label': 'Cookie 内容:',
'auth_login.iflow_cookie_placeholder': '粘贴浏览器中的 Cookie例如 sessionid=...;',
'auth_login.iflow_cookie_hint': '直接提交 Cookie 以完成登录(无需打开授权链接),服务端将自动保存凭据。',
'auth_login.iflow_cookie_button': '提交 Cookie 登录',
'auth_login.iflow_cookie_status_success': 'Cookie 登录成功,凭据已保存。',
'auth_login.iflow_cookie_status_error': 'Cookie 登录失败:',
'auth_login.iflow_cookie_start_error': '提交 Cookie 登录失败:',
'auth_login.iflow_cookie_required': '请先填写 Cookie 内容',
'auth_login.iflow_cookie_result_title': 'Cookie 登录结果',
'auth_login.iflow_cookie_result_email': '账号',
'auth_login.iflow_cookie_result_expired': '过期时间',
'auth_login.iflow_cookie_result_path': '保存路径',
'auth_login.iflow_cookie_result_type': '类型',
// 使用统计 // 使用统计
'usage_stats.title': '使用统计', 'usage_stats.title': '使用统计',
@@ -380,6 +447,10 @@ const i18n = {
'usage_stats.success_requests': '成功请求', 'usage_stats.success_requests': '成功请求',
'usage_stats.failed_requests': '失败请求', 'usage_stats.failed_requests': '失败请求',
'usage_stats.total_tokens': '总Token数', 'usage_stats.total_tokens': '总Token数',
'usage_stats.cached_tokens': '缓存 Token 数',
'usage_stats.reasoning_tokens': '思考 Token 数',
'usage_stats.rpm_30m': 'RPM近30分钟',
'usage_stats.tpm_30m': 'TPM近30分钟',
'usage_stats.requests_trend': '请求趋势', 'usage_stats.requests_trend': '请求趋势',
'usage_stats.tokens_trend': 'Token 使用趋势', 'usage_stats.tokens_trend': 'Token 使用趋势',
'usage_stats.api_details': 'API 详细统计', 'usage_stats.api_details': 'API 详细统计',
@@ -397,6 +468,25 @@ const i18n = {
'usage_stats.tokens_count': 'Token数量', 'usage_stats.tokens_count': 'Token数量',
'usage_stats.models': '模型统计', 'usage_stats.models': '模型统计',
'usage_stats.success_rate': '成功率', 'usage_stats.success_rate': '成功率',
'usage_stats.total_cost': '总花费',
'usage_stats.total_cost_hint': '基于已设置的模型单价',
'usage_stats.model_price_title': '模型价格',
'usage_stats.model_price_reset': '清除价格',
'usage_stats.model_price_model_label': '选择模型',
'usage_stats.model_price_select_placeholder': '选择模型',
'usage_stats.model_price_select_hint': '模型列表来自使用统计明细',
'usage_stats.model_price_prompt': '提示价格 ($/1M tokens)',
'usage_stats.model_price_completion': '补全价格 ($/1M tokens)',
'usage_stats.model_price_save': '保存价格',
'usage_stats.model_price_empty': '暂未设置任何模型价格',
'usage_stats.model_price_model': '模型',
'usage_stats.model_price_saved': '模型价格已保存',
'usage_stats.model_price_model_required': '请选择要设置价格的模型',
'usage_stats.cost_trend': '花费统计',
'usage_stats.cost_axis_label': '花费 ($)',
'usage_stats.cost_need_price': '请先设置模型价格',
'usage_stats.cost_need_usage': '暂无使用数据,无法计算花费',
'usage_stats.cost_no_data': '没有可计算的花费数据',
'stats.success': '成功', 'stats.success': '成功',
'stats.failure': '失败', 'stats.failure': '失败',
@@ -405,6 +495,15 @@ const i18n = {
'logs.refresh_button': '刷新日志', 'logs.refresh_button': '刷新日志',
'logs.clear_button': '清空日志', 'logs.clear_button': '清空日志',
'logs.download_button': '下载日志', 'logs.download_button': '下载日志',
'logs.error_log_button': '选择错误日志',
'logs.error_logs_modal_title': '错误请求日志',
'logs.error_logs_description': '请选择要下载的错误请求日志文件(仅在关闭请求日志时生成)。',
'logs.error_logs_empty': '暂无错误请求日志文件',
'logs.error_logs_load_error': '加载错误日志列表失败',
'logs.error_logs_size': '大小',
'logs.error_logs_modified': '最后修改',
'logs.error_logs_download': '下载',
'logs.error_log_download_success': '错误日志下载成功',
'logs.empty_title': '暂无日志记录', 'logs.empty_title': '暂无日志记录',
'logs.empty_desc': '当启用"日志记录到文件"功能后,日志将显示在这里', 'logs.empty_desc': '当启用"日志记录到文件"功能后,日志将显示在这里',
'logs.log_content': '日志内容', 'logs.log_content': '日志内容',
@@ -669,6 +768,10 @@ const i18n = {
'ai_providers.gemini_edit_modal_title': 'Edit Gemini API Key', 'ai_providers.gemini_edit_modal_title': 'Edit Gemini API Key',
'ai_providers.gemini_edit_modal_key_label': 'API Key:', 'ai_providers.gemini_edit_modal_key_label': 'API Key:',
'ai_providers.gemini_delete_confirm': 'Are you sure you want to delete this Gemini key?', 'ai_providers.gemini_delete_confirm': 'Are you sure you want to delete this Gemini key?',
'ai_providers.excluded_models_label': 'Excluded models (optional):',
'ai_providers.excluded_models_placeholder': 'Comma or newline separated, e.g. gemini-1.5-pro, gemini-1.5-flash',
'ai_providers.excluded_models_hint': 'Leave empty to allow all models; values are trimmed and deduplicated automatically.',
'ai_providers.excluded_models_count': 'Excluding {count} models',
'ai_providers.codex_title': 'Codex API Configuration', 'ai_providers.codex_title': 'Codex API Configuration',
'ai_providers.codex_add_button': 'Add Configuration', 'ai_providers.codex_add_button': 'Add Configuration',
@@ -792,6 +895,7 @@ const i18n = {
'auth_files.filter_aistudio': 'AIStudio', 'auth_files.filter_aistudio': 'AIStudio',
'auth_files.filter_claude': 'Claude', 'auth_files.filter_claude': 'Claude',
'auth_files.filter_codex': 'Codex', 'auth_files.filter_codex': 'Codex',
'auth_files.filter_antigravity': 'Antigravity',
'auth_files.filter_iflow': 'iFlow', 'auth_files.filter_iflow': 'iFlow',
'auth_files.filter_vertex': 'Vertex', 'auth_files.filter_vertex': 'Vertex',
'auth_files.filter_empty': 'Empty', 'auth_files.filter_empty': 'Empty',
@@ -802,6 +906,7 @@ const i18n = {
'auth_files.type_aistudio': 'AIStudio', 'auth_files.type_aistudio': 'AIStudio',
'auth_files.type_claude': 'Claude', 'auth_files.type_claude': 'Claude',
'auth_files.type_codex': 'Codex', 'auth_files.type_codex': 'Codex',
'auth_files.type_antigravity': 'Antigravity',
'auth_files.type_iflow': 'iFlow', 'auth_files.type_iflow': 'iFlow',
'auth_files.type_vertex': 'Vertex', 'auth_files.type_vertex': 'Vertex',
'auth_files.type_empty': 'Empty', 'auth_files.type_empty': 'Empty',
@@ -824,6 +929,40 @@ const i18n = {
'vertex_import.result_location': 'Region', 'vertex_import.result_location': 'Region',
'vertex_import.result_file': 'Persisted file', 'vertex_import.result_file': 'Persisted file',
// OAuth excluded models
'oauth_excluded.title': 'OAuth Excluded Models',
'oauth_excluded.description': 'Per-provider exclusions are shown as cards; click edit to adjust. Wildcards * are supported and the scope follows the auth file filter.',
'oauth_excluded.add': 'Add Exclusion',
'oauth_excluded.add_title': 'Add provider exclusion',
'oauth_excluded.edit_title': 'Edit exclusions for {provider}',
'oauth_excluded.refresh': 'Refresh',
'oauth_excluded.refreshing': 'Refreshing...',
'oauth_excluded.provider_label': 'Provider',
'oauth_excluded.provider_auto': 'Follow current filter',
'oauth_excluded.provider_placeholder': 'e.g. gemini-cli',
'oauth_excluded.provider_hint': 'Defaults to the current filter; pick an existing provider or type a new name.',
'oauth_excluded.models_label': 'Models to exclude',
'oauth_excluded.models_placeholder': 'gpt-4.1-mini\n*-preview',
'oauth_excluded.models_hint': 'Separate by commas or new lines; saving an empty list removes that provider. * wildcards are supported.',
'oauth_excluded.save': 'Save/Update',
'oauth_excluded.saving': 'Saving...',
'oauth_excluded.save_success': 'Excluded models updated',
'oauth_excluded.save_failed': 'Failed to update excluded models',
'oauth_excluded.delete': 'Delete Provider',
'oauth_excluded.delete_confirm': 'Delete the exclusion list for {provider}?',
'oauth_excluded.delete_success': 'Exclusion list removed',
'oauth_excluded.delete_failed': 'Failed to delete exclusion list',
'oauth_excluded.deleting': 'Deleting...',
'oauth_excluded.no_models': 'No excluded models',
'oauth_excluded.model_count': '{count} models excluded',
'oauth_excluded.list_empty_all': 'No exclusions yet—use “Add Exclusion” to create one.',
'oauth_excluded.list_empty_filtered': 'No exclusions in this scope; click “Add Exclusion” to add.',
'oauth_excluded.disconnected': 'Connect to the server to view exclusions',
'oauth_excluded.load_failed': 'Failed to load exclusion list',
'oauth_excluded.provider_required': 'Please enter a provider first',
'oauth_excluded.scope_all': 'Scope: All providers',
'oauth_excluded.scope_provider': 'Scope: {provider}',
// Codex OAuth // Codex OAuth
'auth_login.codex_oauth_title': 'Codex OAuth', 'auth_login.codex_oauth_title': 'Codex OAuth',
'auth_login.codex_oauth_button': 'Start Codex Login', 'auth_login.codex_oauth_button': 'Start Codex Login',
@@ -850,6 +989,19 @@ const i18n = {
'auth_login.anthropic_oauth_start_error': 'Failed to start Anthropic OAuth:', 'auth_login.anthropic_oauth_start_error': 'Failed to start Anthropic OAuth:',
'auth_login.anthropic_oauth_polling_error': 'Failed to check authentication status:', 'auth_login.anthropic_oauth_polling_error': 'Failed to check authentication status:',
// Antigravity OAuth
'auth_login.antigravity_oauth_title': 'Antigravity OAuth',
'auth_login.antigravity_oauth_button': 'Start Antigravity Login',
'auth_login.antigravity_oauth_hint': 'Login to Antigravity service (Google account) through OAuth flow, automatically obtain and save authentication files.',
'auth_login.antigravity_oauth_url_label': 'Authorization URL:',
'auth_login.antigravity_open_link': 'Open Link',
'auth_login.antigravity_copy_link': 'Copy Link',
'auth_login.antigravity_oauth_status_waiting': 'Waiting for authentication...',
'auth_login.antigravity_oauth_status_success': 'Authentication successful!',
'auth_login.antigravity_oauth_status_error': 'Authentication failed:',
'auth_login.antigravity_oauth_start_error': 'Failed to start Antigravity OAuth:',
'auth_login.antigravity_oauth_polling_error': 'Failed to check authentication status:',
// Gemini CLI OAuth // Gemini CLI OAuth
'auth_login.gemini_cli_oauth_title': 'Gemini CLI OAuth', 'auth_login.gemini_cli_oauth_title': 'Gemini CLI OAuth',
'auth_login.gemini_cli_oauth_button': 'Start Gemini CLI Login', 'auth_login.gemini_cli_oauth_button': 'Start Gemini CLI Login',
@@ -892,6 +1044,20 @@ const i18n = {
'auth_login.iflow_oauth_status_error': 'Authentication failed:', 'auth_login.iflow_oauth_status_error': 'Authentication failed:',
'auth_login.iflow_oauth_start_error': 'Failed to start iFlow OAuth:', 'auth_login.iflow_oauth_start_error': 'Failed to start iFlow OAuth:',
'auth_login.iflow_oauth_polling_error': 'Failed to check authentication status:', 'auth_login.iflow_oauth_polling_error': 'Failed to check authentication status:',
'auth_login.iflow_cookie_title': 'iFlow Cookie Login',
'auth_login.iflow_cookie_label': 'Cookie Value:',
'auth_login.iflow_cookie_placeholder': 'Paste browser cookie, e.g. sessionid=...;',
'auth_login.iflow_cookie_hint': 'Submit an existing cookie to finish login without opening the authorization link; the credential file will be saved automatically.',
'auth_login.iflow_cookie_button': 'Submit Cookie Login',
'auth_login.iflow_cookie_status_success': 'Cookie login succeeded and credentials are saved.',
'auth_login.iflow_cookie_status_error': 'Cookie login failed:',
'auth_login.iflow_cookie_start_error': 'Failed to submit cookie login:',
'auth_login.iflow_cookie_required': 'Please provide the Cookie value first.',
'auth_login.iflow_cookie_result_title': 'Cookie Login Result',
'auth_login.iflow_cookie_result_email': 'Account',
'auth_login.iflow_cookie_result_expired': 'Expires At',
'auth_login.iflow_cookie_result_path': 'Saved Path',
'auth_login.iflow_cookie_result_type': 'Type',
// Usage Statistics // Usage Statistics
'usage_stats.title': 'Usage Statistics', 'usage_stats.title': 'Usage Statistics',
@@ -899,6 +1065,10 @@ const i18n = {
'usage_stats.success_requests': 'Success Requests', 'usage_stats.success_requests': 'Success Requests',
'usage_stats.failed_requests': 'Failed Requests', 'usage_stats.failed_requests': 'Failed Requests',
'usage_stats.total_tokens': 'Total Tokens', 'usage_stats.total_tokens': 'Total Tokens',
'usage_stats.cached_tokens': 'Cached Tokens',
'usage_stats.reasoning_tokens': 'Reasoning Tokens',
'usage_stats.rpm_30m': 'RPM (last 30 min)',
'usage_stats.tpm_30m': 'TPM (last 30 min)',
'usage_stats.requests_trend': 'Request Trends', 'usage_stats.requests_trend': 'Request Trends',
'usage_stats.tokens_trend': 'Token Usage Trends', 'usage_stats.tokens_trend': 'Token Usage Trends',
'usage_stats.api_details': 'API Details', 'usage_stats.api_details': 'API Details',
@@ -916,6 +1086,25 @@ const i18n = {
'usage_stats.tokens_count': 'Token Count', 'usage_stats.tokens_count': 'Token Count',
'usage_stats.models': 'Model Statistics', 'usage_stats.models': 'Model Statistics',
'usage_stats.success_rate': 'Success Rate', 'usage_stats.success_rate': 'Success Rate',
'usage_stats.total_cost': 'Total Cost',
'usage_stats.total_cost_hint': 'Based on configured model pricing',
'usage_stats.model_price_title': 'Model Pricing',
'usage_stats.model_price_reset': 'Clear Prices',
'usage_stats.model_price_model_label': 'Model',
'usage_stats.model_price_select_placeholder': 'Choose a model',
'usage_stats.model_price_select_hint': 'Models come from usage details',
'usage_stats.model_price_prompt': 'Prompt price ($/1M tokens)',
'usage_stats.model_price_completion': 'Completion price ($/1M tokens)',
'usage_stats.model_price_save': 'Save Price',
'usage_stats.model_price_empty': 'No model prices set',
'usage_stats.model_price_model': 'Model',
'usage_stats.model_price_saved': 'Model price saved',
'usage_stats.model_price_model_required': 'Please choose a model to set pricing',
'usage_stats.cost_trend': 'Cost Overview',
'usage_stats.cost_axis_label': 'Cost ($)',
'usage_stats.cost_need_price': 'Set a model price to view cost stats',
'usage_stats.cost_need_usage': 'No usage data available to calculate cost',
'usage_stats.cost_no_data': 'No cost data yet',
'stats.success': 'Success', 'stats.success': 'Success',
'stats.failure': 'Failure', 'stats.failure': 'Failure',
@@ -924,6 +1113,15 @@ const i18n = {
'logs.refresh_button': 'Refresh Logs', 'logs.refresh_button': 'Refresh Logs',
'logs.clear_button': 'Clear Logs', 'logs.clear_button': 'Clear Logs',
'logs.download_button': 'Download Logs', 'logs.download_button': 'Download Logs',
'logs.error_log_button': 'Select Error Log',
'logs.error_logs_modal_title': 'Error Request Logs',
'logs.error_logs_description': 'Pick an error request log file to download (only generated when request logging is off).',
'logs.error_logs_empty': 'No error request log files found',
'logs.error_logs_load_error': 'Failed to load error log list',
'logs.error_logs_size': 'Size',
'logs.error_logs_modified': 'Last modified',
'logs.error_logs_download': 'Download',
'logs.error_log_download_success': 'Error log downloaded successfully',
'logs.empty_title': 'No Logs Available', 'logs.empty_title': 'No Logs Available',
'logs.empty_desc': 'When "Enable logging to file" is enabled, logs will be displayed here', 'logs.empty_desc': 'When "Enable logging to file" is enabled, logs will be displayed here',
'logs.log_content': 'Log Content', 'logs.log_content': 'Log Content',

View File

@@ -502,6 +502,7 @@
<button class="filter-btn" data-type="aistudio" data-i18n-text="auth_files.filter_aistudio">AIStudio</button> <button class="filter-btn" data-type="aistudio" data-i18n-text="auth_files.filter_aistudio">AIStudio</button>
<button class="filter-btn" data-type="claude" data-i18n-text="auth_files.filter_claude">Claude</button> <button class="filter-btn" data-type="claude" data-i18n-text="auth_files.filter_claude">Claude</button>
<button class="filter-btn" data-type="codex" data-i18n-text="auth_files.filter_codex">Codex</button> <button class="filter-btn" data-type="codex" data-i18n-text="auth_files.filter_codex">Codex</button>
<button class="filter-btn" data-type="antigravity" data-i18n-text="auth_files.filter_antigravity">Antigravity</button>
<button class="filter-btn" data-type="iflow" data-i18n-text="auth_files.filter_iflow">iFlow</button> <button class="filter-btn" data-type="iflow" data-i18n-text="auth_files.filter_iflow">iFlow</button>
<button class="filter-btn" data-type="vertex" data-i18n-text="auth_files.filter_vertex">Vertex</button> <button class="filter-btn" data-type="vertex" data-i18n-text="auth_files.filter_vertex">Vertex</button>
<button class="filter-btn" data-type="empty" data-i18n-text="auth_files.filter_empty">Empty</button> <button class="filter-btn" data-type="empty" data-i18n-text="auth_files.filter_empty">Empty</button>
@@ -556,6 +557,31 @@
</div> </div>
</div> </div>
<!-- OAuth 排除列表 -->
<div class="card" id="oauth-excluded-card">
<div class="card-header card-header-with-filter">
<div class="header-left">
<h3><i class="fas fa-ban"></i> <span data-i18n="oauth_excluded.title">OAuth 排除列表</span></h3>
<div class="oauth-excluded-scope" id="oauth-excluded-scope"></div>
</div>
<div class="card-actions">
<button id="oauth-excluded-refresh" class="btn btn-secondary">
<i class="fas fa-sync-alt"></i> <span data-i18n="oauth_excluded.refresh">刷新</span>
</button>
<button id="oauth-excluded-add" class="btn btn-primary">
<i class="fas fa-plus"></i> <span data-i18n="oauth_excluded.add">新增排除</span>
</button>
</div>
</div>
<div class="card-content">
<p class="form-hint" data-i18n="oauth_excluded.description">为 OAuth/文件凭据配置模型黑名单,支持通配符。</p>
<div id="oauth-excluded-status" class="form-hint subtle"></div>
<div id="oauth-excluded-list" class="oauth-excluded-list oauth-excluded-grid provider-list">
<div class="loading-placeholder" data-i18n="common.loading">正在加载...</div>
</div>
</div>
</div>
<!-- Codex OAuth --> <!-- Codex OAuth -->
<div class="card" id="codex-oauth-card"> <div class="card" id="codex-oauth-card">
<div class="card-header"> <div class="card-header">
@@ -627,6 +653,42 @@
</div> </div>
</div> </div>
<!-- Antigravity OAuth -->
<div class="card" id="antigravity-oauth-card">
<div class="card-header">
<h3><i class="fas fa-rocket"></i> <span
data-i18n="auth_login.antigravity_oauth_title">Antigravity OAuth</span></h3>
<button id="antigravity-oauth-btn" class="btn btn-primary">
<i class="fas fa-sign-in-alt"></i> <span
data-i18n="auth_login.antigravity_oauth_button">开始 Antigravity 登录</span>
</button>
</div>
<div class="card-content">
<p class="form-hint" style="margin-bottom: 20px;"
data-i18n="auth_login.antigravity_oauth_hint">
通过 OAuth 流程登录 AntigravityGoogle 账号)服务,自动获取并保存认证文件。
</p>
<div id="antigravity-oauth-content" style="display: none;">
<div class="form-group">
<label data-i18n="auth_login.antigravity_oauth_url_label">授权链接:</label>
<div class="input-group">
<input type="text" id="antigravity-oauth-url" readonly>
<button id="antigravity-open-link" class="btn btn-primary">
<i class="fas fa-external-link-alt"></i> <span
data-i18n="auth_login.antigravity_open_link">打开链接</span>
</button>
<button id="antigravity-copy-link" class="btn btn-secondary">
<i class="fas fa-copy"></i> <span
data-i18n="auth_login.antigravity_copy_link">复制链接</span>
</button>
</div>
</div>
<div id="antigravity-oauth-status" class="form-hint" style="margin-top: 10px;">
</div>
</div>
</div>
</div>
<!-- Gemini CLI OAuth --> <!-- Gemini CLI OAuth -->
<div class="card" id="gemini-cli-oauth-card"> <div class="card" id="gemini-cli-oauth-card">
<div class="card-header"> <div class="card-header">
@@ -741,6 +803,34 @@
<div id="iflow-oauth-status" class="form-hint" style="margin-top: 10px;"></div> <div id="iflow-oauth-status" class="form-hint" style="margin-top: 10px;"></div>
</div> </div>
</div> </div>
<div class="card-content" style="border-top: 1px solid var(--border-color); margin-top: 16px; padding-top: 16px;">
<h4 style="margin-bottom: 10px;" data-i18n="auth_login.iflow_cookie_title">iFlow Cookie 登录</h4>
<p class="form-hint" data-i18n="auth_login.iflow_cookie_hint">
直接提交 Cookie 完成登录并保存凭据,无需打开授权链接。
</p>
<div class="form-group">
<label for="iflow-cookie-input" data-i18n="auth_login.iflow_cookie_label">Cookie 内容:</label>
<textarea id="iflow-cookie-input" rows="3" data-i18n-placeholder="auth_login.iflow_cookie_placeholder" placeholder="粘贴浏览器中的 Cookie"></textarea>
</div>
<div class="form-actions">
<button id="iflow-cookie-submit" class="btn btn-primary">
<i class="fas fa-cookie-bite"></i> <span data-i18n="auth_login.iflow_cookie_button">提交 Cookie 登录</span>
</button>
</div>
<div id="iflow-cookie-status" class="form-hint" style="margin-top: 10px;"></div>
<div id="iflow-cookie-result" class="vertex-import-result" style="display: none;">
<div class="vertex-import-result-header">
<i class="fas fa-check-circle"></i>
<span data-i18n="auth_login.iflow_cookie_result_title">Cookie 登录结果</span>
</div>
<ul>
<li><span data-i18n="auth_login.iflow_cookie_result_email">账号</span>: <code id="iflow-cookie-result-email">-</code></li>
<li><span data-i18n="auth_login.iflow_cookie_result_expired">过期时间</span>: <code id="iflow-cookie-result-expired">-</code></li>
<li><span data-i18n="auth_login.iflow_cookie_result_path">保存路径</span>: <code id="iflow-cookie-result-path">-</code></li>
<li><span data-i18n="auth_login.iflow_cookie_result_type">类型</span>: <code id="iflow-cookie-result-type">-</code></li>
</ul>
</div>
</div>
</div> </div>
</section> </section>
@@ -757,16 +847,19 @@
<input type="checkbox" id="logs-auto-refresh-toggle"> <input type="checkbox" id="logs-auto-refresh-toggle">
<span class="slider"></span> <span class="slider"></span>
</label> </label>
<span class="toggle-label" data-i18n="logs.auto_refresh" style="font-size: 0.9em;">自动刷新</span> <span class="toggle-label" data-i18n="logs.auto_refresh" style="font-size: 0.9em;">自动刷新</span>
</div> </div>
<button id="refresh-logs" class="btn btn-primary"> <button id="refresh-logs" class="btn btn-primary">
<i class="fas fa-sync-alt"></i> <span data-i18n="logs.refresh_button">刷新日志</span> <i class="fas fa-sync-alt"></i> <span data-i18n="logs.refresh_button">刷新日志</span>
</button> </button>
<button id="download-logs" class="btn btn-secondary"> <button id="select-error-log" class="btn btn-secondary">
<i class="fas fa-download"></i> <span data-i18n="logs.download_button">下载日志</span> <i class="fas fa-file-circle-exclamation"></i> <span data-i18n="logs.error_log_button">选择错误日志</span>
</button> </button>
<button id="clear-logs" class="btn btn-danger"> <button id="download-logs" class="btn btn-secondary">
<i class="fas fa-trash"></i> <span data-i18n="logs.clear_button">清空日志</span> <i class="fas fa-download"></i> <span data-i18n="logs.download_button">下载日志</span>
</button>
<button id="clear-logs" class="btn btn-danger">
<i class="fas fa-trash"></i> <span data-i18n="logs.clear_button">清空日志</span>
</button> </button>
</div> </div>
</div> </div>
@@ -821,6 +914,45 @@
<div class="stat-content"> <div class="stat-content">
<div class="stat-number" id="total-tokens">0</div> <div class="stat-number" id="total-tokens">0</div>
<div class="stat-label" data-i18n="usage_stats.total_tokens">总Token数</div> <div class="stat-label" data-i18n="usage_stats.total_tokens">总Token数</div>
<div class="stat-subtext">
<span data-i18n="usage_stats.cached_tokens">缓存 Token 数</span>:
<span id="cached-tokens">0</span>
</div>
<div class="stat-subtext">
<span data-i18n="usage_stats.reasoning_tokens">思考 Token 数</span>:
<span id="reasoning-tokens">0</span>
</div>
</div>
</div>
<div class="stat-card">
<div class="stat-icon">
<i class="fas fa-gauge-high"></i>
</div>
<div class="stat-content">
<div class="stat-number" id="rpm-30m">0</div>
<div class="stat-label" data-i18n="usage_stats.rpm_30m">RPM近30分钟</div>
</div>
</div>
<div class="stat-card">
<div class="stat-icon">
<i class="fas fa-stopwatch"></i>
</div>
<div class="stat-content">
<div class="stat-number" id="tpm-30m">0</div>
<div class="stat-label" data-i18n="usage_stats.tpm_30m">TPM近30分钟</div>
</div>
</div>
<div class="stat-card cost-summary-card">
<div class="stat-icon">
<i class="fas fa-dollar-sign"></i>
</div>
<div class="stat-content">
<div class="stat-number" id="total-cost">--</div>
<div class="stat-label" data-i18n="usage_stats.total_cost">总花费</div>
<div class="stat-subtext" id="total-cost-hint" data-i18n="usage_stats.total_cost_hint">基于已设置的模型单价</div>
</div> </div>
</div> </div>
</div> </div>
@@ -891,6 +1023,26 @@
</div> </div>
</div> </div>
</div> </div>
<div class="card chart-card cost-chart-card">
<div class="card-header">
<h3><i class="fas fa-sack-dollar"></i> <span data-i18n="usage_stats.cost_trend">花费统计</span></h3>
<div class="chart-controls">
<button class="btn btn-small" data-period="hour" id="cost-hour-btn">
<span data-i18n="usage_stats.by_hour">按小时</span>
</button>
<button class="btn btn-small active" data-period="day" id="cost-day-btn">
<span data-i18n="usage_stats.by_day">按天</span>
</button>
</div>
</div>
<div class="card-content">
<div class="chart-container">
<canvas id="cost-chart"></canvas>
<div id="cost-chart-placeholder" class="chart-placeholder" data-i18n="usage_stats.cost_need_price">请先设置模型价格</div>
</div>
</div>
</div>
</div> </div>
<!-- API详细统计 --> <!-- API详细统计 -->
@@ -908,6 +1060,46 @@
</div> </div>
</div> </div>
</div> </div>
<div class="card cost-config-card">
<div class="card-header">
<h3><i class="fas fa-tags"></i> <span data-i18n="usage_stats.model_price_title">模型价格</span></h3>
<div class="card-actions">
<button type="button" id="reset-model-prices" class="btn btn-secondary">
<i class="fas fa-rotate-left"></i> <span data-i18n="usage_stats.model_price_reset">清除价格</span>
</button>
</div>
</div>
<div class="card-content">
<form id="model-price-form" class="model-price-form">
<div class="form-group">
<label for="model-price-model-select" data-i18n="usage_stats.model_price_model_label">选择模型</label>
<select id="model-price-model-select" class="model-filter-select">
<option value="" data-i18n="usage_stats.model_price_select_placeholder">选择模型</option>
</select>
<p class="form-hint" data-i18n="usage_stats.model_price_select_hint">模型列表来自使用统计</p>
</div>
<div class="price-input-grid">
<div class="form-group">
<label for="model-price-prompt" data-i18n="usage_stats.model_price_prompt">提示价格 ($/1M tokens)</label>
<input type="number" step="0.0001" min="0" id="model-price-prompt" placeholder="0.0000">
</div>
<div class="form-group">
<label for="model-price-completion" data-i18n="usage_stats.model_price_completion">补全价格 ($/1M tokens)</label>
<input type="number" step="0.0001" min="0" id="model-price-completion" placeholder="0.0000">
</div>
</div>
<div class="price-form-actions">
<button type="submit" class="btn btn-primary">
<i class="fas fa-save"></i> <span data-i18n="usage_stats.model_price_save">保存价格</span>
</button>
</div>
</form>
<div class="model-price-list" id="model-price-list">
<div class="loading-placeholder" data-i18n="common.loading">正在加载...</div>
</div>
</div>
</div>
</section> </section>
<!-- 配置管理 --> <!-- 配置管理 -->

View File

@@ -59,6 +59,59 @@ const normalizeModelList = (payload) => {
return []; return [];
}; };
const normalizeExcludedModels = (input) => {
const rawList = Array.isArray(input)
? input
: (typeof input === 'string' ? input.split(/[\n,]/) : []);
const seen = new Set();
const normalized = [];
rawList.forEach(item => {
if (item === undefined || item === null) {
return;
}
const trimmed = String(item).trim();
if (!trimmed) return;
const key = trimmed.toLowerCase();
if (seen.has(key)) return;
seen.add(key);
normalized.push(trimmed);
});
return normalized;
};
export function collectExcludedModels(textareaId) {
const textarea = document.getElementById(textareaId);
if (!textarea) return [];
return normalizeExcludedModels(textarea.value);
}
export function setExcludedModelsValue(textareaId, models = []) {
const textarea = document.getElementById(textareaId);
if (!textarea) return;
textarea.value = normalizeExcludedModels(models).join('\n');
}
export function renderExcludedModelBadges(models) {
const normalized = normalizeExcludedModels(models);
if (!normalized.length) {
return '';
}
const badges = normalized.map(model => `
<span class="provider-model-tag excluded-model-tag">
<span class="model-name">${this.escapeHtml(model)}</span>
</span>
`).join('');
return `
<div class="item-subtitle">${i18n.t('ai_providers.excluded_models_count', { count: normalized.length })}</div>
<div class="provider-models excluded-models">
${badges}
</div>
`;
}
export async function loadGeminiKeys() { export async function loadGeminiKeys() {
try { try {
const config = await this.getConfig(); const config = await this.getConfig();
@@ -144,6 +197,7 @@ export async function renderGeminiKeys(keys, keyStats = null) {
const configJson = JSON.stringify(config).replace(/"/g, '&quot;'); const configJson = JSON.stringify(config).replace(/"/g, '&quot;');
const apiKeyJson = JSON.stringify(rawKey || '').replace(/"/g, '&quot;'); const apiKeyJson = JSON.stringify(rawKey || '').replace(/"/g, '&quot;');
const baseUrl = config['base-url'] || config['base_url'] || ''; const baseUrl = config['base-url'] || config['base_url'] || '';
const excludedModelsHtml = this.renderExcludedModelBadges(config['excluded-models']);
return ` return `
<div class="key-item"> <div class="key-item">
<div class="item-content"> <div class="item-content">
@@ -151,6 +205,7 @@ export async function renderGeminiKeys(keys, keyStats = null) {
<div class="item-subtitle">${i18n.t('common.api_key')}: ${maskedDisplay}</div> <div class="item-subtitle">${i18n.t('common.api_key')}: ${maskedDisplay}</div>
${baseUrl ? `<div class="item-subtitle">${i18n.t('common.base_url')}: ${this.escapeHtml(baseUrl)}</div>` : ''} ${baseUrl ? `<div class="item-subtitle">${i18n.t('common.base_url')}: ${this.escapeHtml(baseUrl)}</div>` : ''}
${this.renderHeaderBadges(config.headers)} ${this.renderHeaderBadges(config.headers)}
${excludedModelsHtml}
<div class="item-stats"> <div class="item-stats">
<span class="stat-badge stat-success"> <span class="stat-badge stat-success">
<i class="fas fa-check-circle"></i> ${i18n.t('stats.success')}: ${usageStats.success} <i class="fas fa-check-circle"></i> ${i18n.t('stats.success')}: ${usageStats.success}
@@ -191,6 +246,11 @@ export function showAddGeminiKeyModal() {
<div id="new-gemini-headers-wrapper" class="header-input-list"></div> <div id="new-gemini-headers-wrapper" class="header-input-list"></div>
<button type="button" class="btn btn-secondary" onclick="manager.addHeaderField('new-gemini-headers-wrapper')">${i18n.t('common.custom_headers_add')}</button> <button type="button" class="btn btn-secondary" onclick="manager.addHeaderField('new-gemini-headers-wrapper')">${i18n.t('common.custom_headers_add')}</button>
</div> </div>
<div class="form-group">
<label for="new-gemini-excluded-models">${i18n.t('ai_providers.excluded_models_label')}</label>
<p class="form-hint">${i18n.t('ai_providers.excluded_models_hint')}</p>
<textarea id="new-gemini-excluded-models" rows="3" data-i18n-placeholder="ai_providers.excluded_models_placeholder" placeholder="${i18n.t('ai_providers.excluded_models_placeholder')}"></textarea>
</div>
<div class="modal-actions"> <div class="modal-actions">
<button class="btn btn-secondary" onclick="manager.closeModal()">${i18n.t('common.cancel')}</button> <button class="btn btn-secondary" onclick="manager.closeModal()">${i18n.t('common.cancel')}</button>
<button class="btn btn-primary" onclick="manager.addGeminiKey()">${i18n.t('common.add')}</button> <button class="btn btn-primary" onclick="manager.addGeminiKey()">${i18n.t('common.add')}</button>
@@ -200,11 +260,13 @@ export function showAddGeminiKeyModal() {
modal.style.display = 'block'; modal.style.display = 'block';
this.populateGeminiKeyFields('new-gemini-keys-wrapper'); this.populateGeminiKeyFields('new-gemini-keys-wrapper');
this.populateHeaderFields('new-gemini-headers-wrapper'); this.populateHeaderFields('new-gemini-headers-wrapper');
this.setExcludedModelsValue('new-gemini-excluded-models');
} }
export async function addGeminiKey() { export async function addGeminiKey() {
const entries = this.collectGeminiKeyFieldInputs('new-gemini-keys-wrapper'); const entries = this.collectGeminiKeyFieldInputs('new-gemini-keys-wrapper');
const headers = this.collectHeaderInputs('new-gemini-headers-wrapper'); const headers = this.collectHeaderInputs('new-gemini-headers-wrapper');
const excludedModels = this.collectExcludedModels('new-gemini-excluded-models');
if (!entries.length) { if (!entries.length) {
this.showNotification(i18n.t('notification.gemini_multi_input_required'), 'error'); this.showNotification(i18n.t('notification.gemini_multi_input_required'), 'error');
@@ -245,6 +307,7 @@ export async function addGeminiKey() {
} else { } else {
delete newConfig['base-url']; delete newConfig['base-url'];
} }
newConfig['excluded-models'] = excludedModels;
this.applyHeadersToConfig(newConfig, headers); this.applyHeadersToConfig(newConfig, headers);
const nextKeys = [...currentKeys, newConfig]; const nextKeys = [...currentKeys, newConfig];
@@ -371,6 +434,11 @@ export function editGeminiKey(index, config) {
<div id="edit-gemini-headers-wrapper" class="header-input-list"></div> <div id="edit-gemini-headers-wrapper" class="header-input-list"></div>
<button type="button" class="btn btn-secondary" onclick="manager.addHeaderField('edit-gemini-headers-wrapper')">${i18n.t('common.custom_headers_add')}</button> <button type="button" class="btn btn-secondary" onclick="manager.addHeaderField('edit-gemini-headers-wrapper')">${i18n.t('common.custom_headers_add')}</button>
</div> </div>
<div class="form-group">
<label for="edit-gemini-excluded-models">${i18n.t('ai_providers.excluded_models_label')}</label>
<p class="form-hint">${i18n.t('ai_providers.excluded_models_hint')}</p>
<textarea id="edit-gemini-excluded-models" rows="3" data-i18n-placeholder="ai_providers.excluded_models_placeholder" placeholder="${i18n.t('ai_providers.excluded_models_placeholder')}"></textarea>
</div>
<div class="modal-actions"> <div class="modal-actions">
<button class="btn btn-secondary" onclick="manager.closeModal()">${i18n.t('common.cancel')}</button> <button class="btn btn-secondary" onclick="manager.closeModal()">${i18n.t('common.cancel')}</button>
<button class="btn btn-primary" onclick="manager.updateGeminiKey(${index})">${i18n.t('common.update')}</button> <button class="btn btn-primary" onclick="manager.updateGeminiKey(${index})">${i18n.t('common.update')}</button>
@@ -380,6 +448,7 @@ export function editGeminiKey(index, config) {
modal.style.display = 'block'; modal.style.display = 'block';
this.populateGeminiKeyFields('edit-gemini-keys-wrapper', [config], { allowRemoval: false }); this.populateGeminiKeyFields('edit-gemini-keys-wrapper', [config], { allowRemoval: false });
this.populateHeaderFields('edit-gemini-headers-wrapper', config.headers || null); this.populateHeaderFields('edit-gemini-headers-wrapper', config.headers || null);
this.setExcludedModelsValue('edit-gemini-excluded-models', config['excluded-models'] || []);
} }
export async function updateGeminiKey(index) { export async function updateGeminiKey(index) {
@@ -392,6 +461,7 @@ export async function updateGeminiKey(index) {
const newKey = entry['api-key']; const newKey = entry['api-key'];
const baseUrl = entry['base-url'] || ''; const baseUrl = entry['base-url'] || '';
const headers = this.collectHeaderInputs('edit-gemini-headers-wrapper'); const headers = this.collectHeaderInputs('edit-gemini-headers-wrapper');
const excludedModels = this.collectExcludedModels('edit-gemini-excluded-models');
if (!newKey) { if (!newKey) {
this.showNotification(i18n.t('notification.please_enter') + ' ' + i18n.t('notification.gemini_api_key'), 'error'); this.showNotification(i18n.t('notification.please_enter') + ' ' + i18n.t('notification.gemini_api_key'), 'error');
@@ -406,6 +476,7 @@ export async function updateGeminiKey(index) {
} else { } else {
delete newConfig['base-url']; delete newConfig['base-url'];
} }
newConfig['excluded-models'] = excludedModels;
this.applyHeadersToConfig(newConfig, headers); this.applyHeadersToConfig(newConfig, headers);
await this.makeRequest('/gemini-api-key', { await this.makeRequest('/gemini-api-key', {
@@ -477,6 +548,7 @@ export async function renderCodexKeys(keys, keyStats = null) {
const maskedDisplay = this.escapeHtml(masked); const maskedDisplay = this.escapeHtml(masked);
const usageStats = (rawKey && (statsBySource[rawKey] || statsBySource[masked])) || { success: 0, failure: 0 }; const usageStats = (rawKey && (statsBySource[rawKey] || statsBySource[masked])) || { success: 0, failure: 0 };
const deleteArg = JSON.stringify(rawKey).replace(/"/g, '&quot;'); const deleteArg = JSON.stringify(rawKey).replace(/"/g, '&quot;');
const excludedModelsHtml = this.renderExcludedModelBadges(config['excluded-models']);
return ` return `
<div class="provider-item"> <div class="provider-item">
<div class="item-content"> <div class="item-content">
@@ -485,6 +557,7 @@ export async function renderCodexKeys(keys, keyStats = null) {
${config['base-url'] ? `<div class="item-subtitle">${i18n.t('common.base_url')}: ${this.escapeHtml(config['base-url'])}</div>` : ''} ${config['base-url'] ? `<div class="item-subtitle">${i18n.t('common.base_url')}: ${this.escapeHtml(config['base-url'])}</div>` : ''}
${config['proxy-url'] ? `<div class="item-subtitle">${i18n.t('common.proxy_url')}: ${this.escapeHtml(config['proxy-url'])}</div>` : ''} ${config['proxy-url'] ? `<div class="item-subtitle">${i18n.t('common.proxy_url')}: ${this.escapeHtml(config['proxy-url'])}</div>` : ''}
${this.renderHeaderBadges(config.headers)} ${this.renderHeaderBadges(config.headers)}
${excludedModelsHtml}
<div class="item-stats"> <div class="item-stats">
<span class="stat-badge stat-success"> <span class="stat-badge stat-success">
<i class="fas fa-check-circle"></i> ${i18n.t('stats.success')}: ${usageStats.success} <i class="fas fa-check-circle"></i> ${i18n.t('stats.success')}: ${usageStats.success}
@@ -525,6 +598,11 @@ export function showAddCodexKeyModal() {
<label for="new-codex-proxy">${i18n.t('ai_providers.codex_add_modal_proxy_label')}</label> <label for="new-codex-proxy">${i18n.t('ai_providers.codex_add_modal_proxy_label')}</label>
<input type="text" id="new-codex-proxy" placeholder="${i18n.t('ai_providers.codex_add_modal_proxy_placeholder')}"> <input type="text" id="new-codex-proxy" placeholder="${i18n.t('ai_providers.codex_add_modal_proxy_placeholder')}">
</div> </div>
<div class="form-group">
<label for="new-codex-excluded-models">${i18n.t('ai_providers.excluded_models_label')}</label>
<p class="form-hint">${i18n.t('ai_providers.excluded_models_hint')}</p>
<textarea id="new-codex-excluded-models" rows="3" data-i18n-placeholder="ai_providers.excluded_models_placeholder" placeholder="${i18n.t('ai_providers.excluded_models_placeholder')}"></textarea>
</div>
<div class="form-group"> <div class="form-group">
<label>${i18n.t('common.custom_headers_label')}</label> <label>${i18n.t('common.custom_headers_label')}</label>
<p class="form-hint">${i18n.t('common.custom_headers_hint')}</p> <p class="form-hint">${i18n.t('common.custom_headers_hint')}</p>
@@ -539,6 +617,7 @@ export function showAddCodexKeyModal() {
modal.style.display = 'block'; modal.style.display = 'block';
this.populateHeaderFields('new-codex-headers-wrapper'); this.populateHeaderFields('new-codex-headers-wrapper');
this.setExcludedModelsValue('new-codex-excluded-models');
} }
export async function addCodexKey() { export async function addCodexKey() {
@@ -546,6 +625,7 @@ export async function addCodexKey() {
const baseUrl = document.getElementById('new-codex-url').value.trim(); const baseUrl = document.getElementById('new-codex-url').value.trim();
const proxyUrl = document.getElementById('new-codex-proxy').value.trim(); const proxyUrl = document.getElementById('new-codex-proxy').value.trim();
const headers = this.collectHeaderInputs('new-codex-headers-wrapper'); const headers = this.collectHeaderInputs('new-codex-headers-wrapper');
const excludedModels = this.collectExcludedModels('new-codex-excluded-models');
if (!apiKey) { if (!apiKey) {
this.showNotification(i18n.t('notification.field_required'), 'error'); this.showNotification(i18n.t('notification.field_required'), 'error');
@@ -560,7 +640,7 @@ export async function addCodexKey() {
const data = await this.makeRequest('/codex-api-key'); const data = await this.makeRequest('/codex-api-key');
const currentKeys = this.normalizeArrayResponse(data, 'codex-api-key').map(item => ({ ...item })); const currentKeys = this.normalizeArrayResponse(data, 'codex-api-key').map(item => ({ ...item }));
const newConfig = this.buildCodexConfig(apiKey, baseUrl, proxyUrl, {}, headers); const newConfig = this.buildCodexConfig(apiKey, baseUrl, proxyUrl, {}, headers, excludedModels);
currentKeys.push(newConfig); currentKeys.push(newConfig);
@@ -596,6 +676,11 @@ export function editCodexKey(index, config) {
<label for="edit-codex-proxy">${i18n.t('ai_providers.codex_edit_modal_proxy_label')}</label> <label for="edit-codex-proxy">${i18n.t('ai_providers.codex_edit_modal_proxy_label')}</label>
<input type="text" id="edit-codex-proxy" value="${config['proxy-url'] || ''}"> <input type="text" id="edit-codex-proxy" value="${config['proxy-url'] || ''}">
</div> </div>
<div class="form-group">
<label for="edit-codex-excluded-models">${i18n.t('ai_providers.excluded_models_label')}</label>
<p class="form-hint">${i18n.t('ai_providers.excluded_models_hint')}</p>
<textarea id="edit-codex-excluded-models" rows="3" data-i18n-placeholder="ai_providers.excluded_models_placeholder" placeholder="${i18n.t('ai_providers.excluded_models_placeholder')}"></textarea>
</div>
<div class="form-group"> <div class="form-group">
<label>${i18n.t('common.custom_headers_label')}</label> <label>${i18n.t('common.custom_headers_label')}</label>
<p class="form-hint">${i18n.t('common.custom_headers_hint')}</p> <p class="form-hint">${i18n.t('common.custom_headers_hint')}</p>
@@ -610,6 +695,7 @@ export function editCodexKey(index, config) {
modal.style.display = 'block'; modal.style.display = 'block';
this.populateHeaderFields('edit-codex-headers-wrapper', config.headers || null); this.populateHeaderFields('edit-codex-headers-wrapper', config.headers || null);
this.setExcludedModelsValue('edit-codex-excluded-models', config['excluded-models'] || []);
} }
export async function updateCodexKey(index) { export async function updateCodexKey(index) {
@@ -617,6 +703,7 @@ export async function updateCodexKey(index) {
const baseUrl = document.getElementById('edit-codex-url').value.trim(); const baseUrl = document.getElementById('edit-codex-url').value.trim();
const proxyUrl = document.getElementById('edit-codex-proxy').value.trim(); const proxyUrl = document.getElementById('edit-codex-proxy').value.trim();
const headers = this.collectHeaderInputs('edit-codex-headers-wrapper'); const headers = this.collectHeaderInputs('edit-codex-headers-wrapper');
const excludedModels = this.collectExcludedModels('edit-codex-excluded-models');
if (!apiKey) { if (!apiKey) {
this.showNotification(i18n.t('notification.field_required'), 'error'); this.showNotification(i18n.t('notification.field_required'), 'error');
@@ -636,7 +723,7 @@ export async function updateCodexKey(index) {
} }
const original = currentList[index] ? { ...currentList[index] } : {}; const original = currentList[index] ? { ...currentList[index] } : {};
const newConfig = this.buildCodexConfig(apiKey, baseUrl, proxyUrl, original, headers); const newConfig = this.buildCodexConfig(apiKey, baseUrl, proxyUrl, original, headers, excludedModels);
await this.makeRequest('/codex-api-key', { await this.makeRequest('/codex-api-key', {
method: 'PATCH', method: 'PATCH',
@@ -1002,7 +1089,7 @@ export async function renderOpenAIProviders(providers, keyStats = null) {
<div class="provider-item"> <div class="provider-item">
<div class="item-content"> <div class="item-content">
<div class="item-title">${this.escapeHtml(name)}</div> <div class="item-title">${this.escapeHtml(name)}</div>
<div class="item-subtitle">${i18n.t('common.base_url')}: ${this.escapeHtml(baseUrl)}</div> <div class="item-subtitle provider-base-url" title="${this.escapeHtml(baseUrl)}">${i18n.t('common.base_url')}: ${this.escapeHtml(baseUrl)}</div>
${this.renderHeaderBadges(item.headers)} ${this.renderHeaderBadges(item.headers)}
<div class="item-subtitle">${i18n.t('ai_providers.openai_keys_count')}: ${apiKeyEntries.length}</div> <div class="item-subtitle">${i18n.t('ai_providers.openai_keys_count')}: ${apiKeyEntries.length}</div>
<div class="item-subtitle">${i18n.t('ai_providers.openai_models_count')}: ${models.length}</div> <div class="item-subtitle">${i18n.t('ai_providers.openai_models_count')}: ${models.length}</div>
@@ -1607,5 +1694,8 @@ export const aiProvidersModule = {
populateModelFields, populateModelFields,
collectModelInputs, collectModelInputs,
renderModelBadges, renderModelBadges,
renderExcludedModelBadges,
collectExcludedModels,
setExcludedModelsValue,
validateOpenAIProviderInput validateOpenAIProviderInput
}; };

View File

@@ -26,27 +26,34 @@ export const apiKeysModule = {
return; return;
} }
container.innerHTML = keys.map((key, index) => { const rows = keys.map((key, index) => {
const normalizedKey = typeof key === 'string' ? key : String(key ?? ''); const normalizedKey = typeof key === 'string' ? key : String(key ?? '');
const maskedDisplay = this.escapeHtml(this.maskApiKey(normalizedKey)); const maskedDisplay = this.escapeHtml(this.maskApiKey(normalizedKey));
const keyArgument = encodeURIComponent(normalizedKey); const keyArgument = encodeURIComponent(normalizedKey);
return ` return `
<div class="key-item"> <div class="key-table-row">
<div class="item-content"> <div class="key-badge">#${index + 1}</div>
<div class="item-title">${i18n.t('api_keys.item_title')} #${index + 1}</div> <div class="key-table-value">
<div class="item-value">${maskedDisplay}</div> <div class="item-title">${i18n.t('api_keys.item_title')}</div>
</div> <div class="key-value">${maskedDisplay}</div>
<div class="item-actions"> </div>
<button class="btn btn-secondary" data-action="edit-api-key" data-index="${index}" data-key="${keyArgument}"> <div class="item-actions compact">
<i class="fas fa-edit"></i> <button class="btn btn-secondary" data-action="edit-api-key" data-index="${index}" data-key="${keyArgument}">
</button> <i class="fas fa-edit"></i>
<button class="btn btn-danger" data-action="delete-api-key" data-index="${index}"> </button>
<i class="fas fa-trash"></i> <button class="btn btn-danger" data-action="delete-api-key" data-index="${index}">
</button> <i class="fas fa-trash"></i>
</button>
</div>
</div> </div>
`;
}).join('');
container.innerHTML = `
<div class="key-table">
${rows}
</div> </div>
`; `;
}).join('');
this.bindApiKeyListEvents(container); this.bindApiKeyListEvents(container);
}, },
@@ -223,7 +230,7 @@ export const apiKeysModule = {
}, },
// 构造Codex配置保持未展示的字段 // 构造Codex配置保持未展示的字段
buildCodexConfig(apiKey, baseUrl, proxyUrl, original = {}, headers = null) { buildCodexConfig(apiKey, baseUrl, proxyUrl, original = {}, headers = null, excludedModels = null) {
const result = { const result = {
...original, ...original,
'api-key': apiKey, 'api-key': apiKey,
@@ -231,6 +238,9 @@ export const apiKeysModule = {
'proxy-url': proxyUrl || '' 'proxy-url': proxyUrl || ''
}; };
this.applyHeadersToConfig(result, headers); this.applyHeadersToConfig(result, headers);
if (Array.isArray(excludedModels)) {
result['excluded-models'] = excludedModels;
}
return result; return result;
}, },

View File

@@ -172,6 +172,9 @@ export const authFilesModule = {
case 'codex': case 'codex':
typeDisplayKey = 'auth_files.type_codex'; typeDisplayKey = 'auth_files.type_codex';
break; break;
case 'antigravity':
typeDisplayKey = 'auth_files.type_antigravity';
break;
case 'iflow': case 'iflow':
typeDisplayKey = 'auth_files.type_iflow'; typeDisplayKey = 'auth_files.type_iflow';
break; break;
@@ -467,6 +470,7 @@ export const authFilesModule = {
{ type: 'aistudio', labelKey: 'auth_files.filter_aistudio' }, { type: 'aistudio', labelKey: 'auth_files.filter_aistudio' },
{ type: 'claude', labelKey: 'auth_files.filter_claude' }, { type: 'claude', labelKey: 'auth_files.filter_claude' },
{ type: 'codex', labelKey: 'auth_files.filter_codex' }, { type: 'codex', labelKey: 'auth_files.filter_codex' },
{ type: 'antigravity', labelKey: 'auth_files.filter_antigravity' },
{ type: 'iflow', labelKey: 'auth_files.filter_iflow' }, { type: 'iflow', labelKey: 'auth_files.filter_iflow' },
{ type: 'vertex', labelKey: 'auth_files.filter_vertex' }, { type: 'vertex', labelKey: 'auth_files.filter_vertex' },
{ type: 'empty', labelKey: 'auth_files.filter_empty' } { type: 'empty', labelKey: 'auth_files.filter_empty' }
@@ -536,6 +540,7 @@ export const authFilesModule = {
} }
this.refreshFilterButtonTexts(); this.refreshFilterButtonTexts();
this.renderOauthExcludedModels();
}, },
generateDynamicTypeLabel(type) { generateDynamicTypeLabel(type) {
@@ -1027,6 +1032,469 @@ export const authFilesModule = {
} }
}, },
normalizeOauthExcludedMap(payload = {}) {
const raw = (payload && (payload['oauth-excluded-models'] || payload.items)) || payload || {};
if (!raw || typeof raw !== 'object') {
return {};
}
const normalized = {};
Object.entries(raw).forEach(([provider, models]) => {
const key = typeof provider === 'string' ? provider.trim() : '';
if (!key) return;
const list = Array.isArray(models)
? models.map(item => String(item || '').trim()).filter(Boolean)
: [];
normalized[key.toLowerCase()] = list;
});
return normalized;
},
getFilteredOauthExcludedMap(filterType = this.currentAuthFileFilter) {
const map = this.oauthExcludedModels || {};
if (!map || typeof map !== 'object') {
return {};
}
const type = (filterType || 'all').toLowerCase();
if (type === 'all') {
return map;
}
const result = {};
Object.entries(map).forEach(([provider, models]) => {
if ((provider || '').toLowerCase() === type) {
result[provider] = models;
}
});
return result;
},
findOauthExcludedEntry(provider) {
if (!provider || provider === 'all') {
return null;
}
const normalized = provider.toLowerCase();
const map = this.oauthExcludedModels || {};
for (const [key, models] of Object.entries(map)) {
if ((key || '').toLowerCase() === normalized) {
return { provider: key, models: Array.isArray(models) ? models : [] };
}
}
return null;
},
setOauthExcludedForm(provider = '', models = null) {
const providerSelect = document.getElementById('oauth-excluded-provider-select');
const modelsInput = document.getElementById('oauth-excluded-models');
const normalizedProvider = (provider || '').trim();
if (providerSelect) {
const options = Array.from(providerSelect.options || []);
let match = options.find(opt => (opt.value || '').toLowerCase() === normalizedProvider.toLowerCase());
if (!match && normalizedProvider) {
match = new Option(this.generateDynamicTypeLabel(normalizedProvider) || normalizedProvider, normalizedProvider);
providerSelect.appendChild(match);
}
if (normalizedProvider && match) {
providerSelect.value = match.value;
} else {
providerSelect.value = 'auto';
}
}
if (modelsInput && models !== null && models !== undefined) {
const list = Array.isArray(models) ? models : [];
modelsInput.value = list.map(item => item || '').join('\n');
}
},
syncOauthExcludedFormWithFilter(overrideModels = false) {
const filterType = (this.currentAuthFileFilter || 'all').toLowerCase();
const entry = this.findOauthExcludedEntry(filterType);
if (filterType === 'all') {
if (overrideModels) {
if (entry) {
this.setOauthExcludedForm(entry.provider, entry.models);
} else {
this.setOauthExcludedForm('', '');
}
}
return;
}
if (overrideModels) {
this.setOauthExcludedForm(filterType, entry ? entry.models : []);
} else {
this.setOauthExcludedForm(filterType);
}
},
getOauthExcludedProviderValue() {
const providerSelect = document.getElementById('oauth-excluded-provider-select');
const filterFallback = (this.currentAuthFileFilter && this.currentAuthFileFilter !== 'all')
? this.currentAuthFileFilter
: '';
let selected = (providerSelect && providerSelect.value) ? providerSelect.value.trim() : '';
if (!selected || selected === 'auto') {
return filterFallback;
}
return selected;
},
refreshOauthProviderOptions() {
const providerSelect = document.getElementById('oauth-excluded-provider-select');
if (!providerSelect) return;
const allowedProviders = ['gemini-cli', 'vertex', 'aistudio', 'antigravity', 'claude', 'codex', 'qwen', 'iflow'];
const mapProviders = Object.keys(this.oauthExcludedModels || {});
const filterType = (this.currentAuthFileFilter || '').toLowerCase();
const providers = Array.from(new Set([...allowedProviders, ...mapProviders].filter(Boolean)));
const prevValue = providerSelect.value || 'auto';
providerSelect.innerHTML = '';
const addOption = (value, textKey, fallbackText = null) => {
const opt = document.createElement('option');
opt.value = value;
if (textKey) {
opt.setAttribute('data-i18n-text', textKey);
opt.textContent = i18n.t(textKey);
} else {
opt.textContent = fallbackText || value;
}
providerSelect.appendChild(opt);
};
addOption('auto', 'oauth_excluded.provider_auto');
providers.sort((a, b) => a.localeCompare(b)).forEach(item => addOption(item, null, this.generateDynamicTypeLabel(item) || item));
const restoreValue = (() => {
if (prevValue && Array.from(providerSelect.options).some(opt => opt.value === prevValue)) {
return prevValue;
}
if (filterType && Array.from(providerSelect.options).some(opt => opt.value === filterType)) {
return filterType;
}
return 'auto';
})();
providerSelect.value = restoreValue;
},
parseOauthExcludedModelsInput(input = '') {
const tokens = (input || '').split(/[\n,]/).map(token => token.trim()).filter(Boolean);
const unique = [];
tokens.forEach(token => {
if (!unique.includes(token)) {
unique.push(token);
}
});
return unique;
},
openOauthExcludedEditor(provider = '', models = null) {
const modal = document.getElementById('modal');
const modalBody = document.getElementById('modal-body');
if (!modal || !modalBody) return;
const normalizedProvider = (provider || '').trim();
const fallbackProvider = normalizedProvider
|| ((this.currentAuthFileFilter && this.currentAuthFileFilter !== 'all') ? this.currentAuthFileFilter : '');
let targetModels = models;
if ((targetModels === null || targetModels === undefined) && fallbackProvider) {
const existing = this.findOauthExcludedEntry(fallbackProvider);
if (existing) {
targetModels = existing.models;
}
}
modalBody.innerHTML = `
<h3>${fallbackProvider
? i18n.t('oauth_excluded.edit_title', { provider: this.generateDynamicTypeLabel(fallbackProvider) })
: i18n.t('oauth_excluded.add_title')
}</h3>
<div class="provider-item oauth-excluded-editor-card">
<div class="item-content">
<div class="form-group">
<label for="oauth-excluded-provider-select" data-i18n="oauth_excluded.provider_label">${i18n.t('oauth_excluded.provider_label')}</label>
<select id="oauth-excluded-provider-select"></select>
<p class="form-hint" data-i18n="oauth_excluded.provider_hint">${i18n.t('oauth_excluded.provider_hint')}</p>
</div>
<div class="form-group">
<label for="oauth-excluded-models" data-i18n="oauth_excluded.models_label">${i18n.t('oauth_excluded.models_label')}</label>
<textarea id="oauth-excluded-models" rows="5" data-i18n-placeholder="oauth_excluded.models_placeholder" placeholder="${i18n.t('oauth_excluded.models_placeholder')}"></textarea>
<p class="form-hint" data-i18n="oauth_excluded.models_hint">${i18n.t('oauth_excluded.models_hint')}</p>
</div>
</div>
</div>
<div class="modal-actions">
<button class="btn btn-primary" id="oauth-excluded-save">
<i class="fas fa-save"></i> ${i18n.t('oauth_excluded.save')}
</button>
<button class="btn btn-danger" id="oauth-excluded-delete">
<i class="fas fa-trash"></i> ${i18n.t('oauth_excluded.delete')}
</button>
<button class="btn btn-secondary" onclick="manager.closeModal()">
${i18n.t('common.cancel')}
</button>
</div>
`;
this.refreshOauthProviderOptions();
this.setOauthExcludedForm(fallbackProvider, targetModels != null ? targetModels : []);
this.showModal();
const saveBtn = document.getElementById('oauth-excluded-save');
if (saveBtn) {
saveBtn.onclick = () => this.saveOauthExcludedEntry();
}
const deleteBtn = document.getElementById('oauth-excluded-delete');
if (deleteBtn) {
deleteBtn.onclick = () => this.deleteOauthExcludedEntry();
}
const providerSelect = document.getElementById('oauth-excluded-provider-select');
const syncDeleteState = () => {
if (deleteBtn) {
deleteBtn.disabled = !this.getOauthExcludedProviderValue();
}
};
if (providerSelect) {
providerSelect.addEventListener('change', syncDeleteState);
}
syncDeleteState();
this.updateOauthExcludedButtonsState(false);
},
buildOauthExcludedItem(provider, models = []) {
const providerLabel = this.generateDynamicTypeLabel(provider) || provider;
const normalizedModels = Array.isArray(models) ? models.filter(Boolean) : [];
const tags = normalizedModels.length
? normalizedModels.map(model => `<span class="provider-model-tag"><span class="model-name">${this.escapeHtml(String(model))}</span></span>`).join('')
: `<span class="oauth-excluded-empty">${i18n.t('oauth_excluded.no_models')}</span>`;
const modelCount = normalizedModels.length;
return `
<div class="provider-item oauth-excluded-card" data-provider="${this.escapeHtml(provider)}">
<div class="item-content">
<div class="item-title">${this.escapeHtml(providerLabel)}</div>
<div class="item-meta">
<span class="item-subtitle">
${modelCount > 0
? i18n.t('oauth_excluded.model_count', { count: modelCount })
: i18n.t('oauth_excluded.no_models')}
</span>
</div>
<div class="provider-models oauth-excluded-tags">
${tags}
</div>
</div>
<div class="item-actions">
<button class="btn btn-secondary" data-action="edit" data-provider="${this.escapeHtml(provider)}">
<i class="fas fa-pen"></i>
</button>
<button class="btn btn-danger" data-action="delete" data-provider="${this.escapeHtml(provider)}">
<i class="fas fa-trash"></i>
</button>
</div>
</div>
`;
},
renderOauthExcludedModels(filterType = this.currentAuthFileFilter) {
const container = document.getElementById('oauth-excluded-list');
const scopeEl = document.getElementById('oauth-excluded-scope');
if (!container) return;
const currentType = (filterType || 'all').toLowerCase();
const map = this.getFilteredOauthExcludedMap(currentType);
const providers = Object.keys(map || {});
if (scopeEl) {
const label = currentType === 'all'
? i18n.t('oauth_excluded.scope_all')
: i18n.t('oauth_excluded.scope_provider', { provider: this.generateDynamicTypeLabel(currentType) });
scopeEl.textContent = label;
}
if (!this.isConnected) {
container.innerHTML = `<div class="oauth-excluded-empty">${i18n.t('oauth_excluded.disconnected')}</div>`;
return;
}
if (this._oauthExcludedLoading) {
container.innerHTML = `<div class="loading-placeholder">${i18n.t('common.loading')}</div>`;
return;
}
if (!providers.length) {
const emptyKey = currentType === 'all'
? 'oauth_excluded.list_empty_all'
: 'oauth_excluded.list_empty_filtered';
container.innerHTML = `<div class="oauth-excluded-empty">${i18n.t(emptyKey)}</div>`;
return;
}
const itemsHtml = providers
.sort((a, b) => a.localeCompare(b))
.map(provider => this.buildOauthExcludedItem(provider, map[provider]))
.join('');
container.innerHTML = itemsHtml;
this.refreshOauthProviderOptions();
this.bindOauthExcludedActionEvents();
},
bindOauthExcludedActionEvents() {
const container = document.getElementById('oauth-excluded-list');
if (!container) return;
if (container._oauthExcludedListener) {
container.removeEventListener('click', container._oauthExcludedListener);
}
const listener = (event) => {
const button = event.target.closest('button[data-action]');
if (!button || !container.contains(button)) return;
const provider = button.dataset.provider;
if (!provider) return;
const entry = this.findOauthExcludedEntry(provider);
if (button.dataset.action === 'edit') {
this.openOauthExcludedEditor(provider, entry ? entry.models : []);
} else if (button.dataset.action === 'delete') {
this.deleteOauthExcludedEntry(provider);
}
};
container._oauthExcludedListener = listener;
container.addEventListener('click', listener);
},
updateOauthExcludedButtonsState(isLoading = false) {
const refreshBtn = document.getElementById('oauth-excluded-refresh');
const saveBtn = document.getElementById('oauth-excluded-save');
const deleteBtn = document.getElementById('oauth-excluded-delete');
const addBtn = document.getElementById('oauth-excluded-add');
const disabled = isLoading || !this.isConnected;
[refreshBtn, saveBtn, deleteBtn, addBtn].forEach(btn => {
if (btn) {
btn.disabled = disabled;
}
});
},
setOauthExcludedStatus(message = '') {
const statusEl = document.getElementById('oauth-excluded-status');
if (statusEl) {
statusEl.textContent = message || '';
}
},
async loadOauthExcludedModels(forceRefresh = false) {
if (!this.isConnected) {
this.renderOauthExcludedModels();
this.updateOauthExcludedButtonsState();
return;
}
if (this._oauthExcludedLoading) {
return;
}
this._oauthExcludedLoading = true;
this.updateOauthExcludedButtonsState(true);
this.setOauthExcludedStatus(i18n.t('oauth_excluded.refreshing'));
this.renderOauthExcludedModels();
try {
const data = await this.makeRequest('/oauth-excluded-models');
this.oauthExcludedModels = this.normalizeOauthExcludedMap(data);
this.refreshOauthProviderOptions();
this.setOauthExcludedStatus('');
} catch (error) {
console.error('加载 OAuth 排除列表失败:', error);
const message = `${i18n.t('oauth_excluded.load_failed')}: ${error.message}`;
this.setOauthExcludedStatus(message);
this.showNotification(message, 'error');
} finally {
this._oauthExcludedLoading = false;
this.updateOauthExcludedButtonsState(false);
this.renderOauthExcludedModels();
}
},
async saveOauthExcludedEntry() {
if (!this.isConnected) {
this.showNotification(i18n.t('notification.connection_required'), 'error');
return;
}
const modelsInput = document.getElementById('oauth-excluded-models');
if (!modelsInput) return;
const providerValue = this.getOauthExcludedProviderValue();
if (!providerValue) {
this.showNotification(i18n.t('oauth_excluded.provider_required'), 'error');
return;
}
const models = this.parseOauthExcludedModelsInput(modelsInput.value);
this.updateOauthExcludedButtonsState(true);
this.setOauthExcludedStatus(i18n.t('oauth_excluded.saving'));
try {
await this.makeRequest('/oauth-excluded-models', {
method: 'PATCH',
body: JSON.stringify({
provider: providerValue,
models
})
});
const successKey = models.length === 0 ? 'oauth_excluded.delete_success' : 'oauth_excluded.save_success';
this.showNotification(i18n.t(successKey), 'success');
this.clearCache('oauth-excluded-models');
await this.loadOauthExcludedModels(true);
this.closeModal();
} catch (error) {
this.showNotification(`${i18n.t('oauth_excluded.save_failed')}: ${error.message}`, 'error');
} finally {
this.setOauthExcludedStatus('');
this.updateOauthExcludedButtonsState(false);
}
},
async deleteOauthExcludedEntry(providerOverride = null) {
if (!this.isConnected) {
this.showNotification(i18n.t('notification.connection_required'), 'error');
return;
}
const providerValue = (providerOverride || this.getOauthExcludedProviderValue() || '').trim();
if (!providerValue) {
this.showNotification(i18n.t('oauth_excluded.provider_required'), 'error');
return;
}
if (!confirm(i18n.t('oauth_excluded.delete_confirm', { provider: providerValue }))) {
return;
}
this.updateOauthExcludedButtonsState(true);
this.setOauthExcludedStatus(i18n.t('oauth_excluded.deleting'));
try {
await this.makeRequest(`/oauth-excluded-models?provider=${encodeURIComponent(providerValue)}`, { method: 'DELETE' });
this.showNotification(i18n.t('oauth_excluded.delete_success'), 'success');
this.clearCache('oauth-excluded-models');
await this.loadOauthExcludedModels(true);
this.closeModal();
} catch (error) {
this.showNotification(`${i18n.t('oauth_excluded.delete_failed')}: ${error.message}`, 'error');
} finally {
this.setOauthExcludedStatus('');
this.updateOauthExcludedButtonsState(false);
}
},
registerAuthFilesListeners() { registerAuthFilesListeners() {
if (!this.events || typeof this.events.on !== 'function') { if (!this.events || typeof this.events.on !== 'function') {
return; return;
@@ -1039,6 +1507,21 @@ export const authFilesModule = {
} catch (error) { } catch (error) {
console.error('加载认证文件失败:', error); console.error('加载认证文件失败:', error);
} }
try {
await this.loadOauthExcludedModels(true);
} catch (error) {
console.error('加载 OAuth 排除列表失败:', error);
}
});
this.events.on('connection:status-changed', (event) => {
const detail = event?.detail || {};
this.updateOauthExcludedButtonsState(false);
if (detail.isConnected) {
this.loadOauthExcludedModels(true);
} else {
this.renderOauthExcludedModels();
}
}); });
} }
}; };

View File

@@ -101,6 +101,11 @@ export const loginModule = {
this.stopStatusUpdateTimer(); this.stopStatusUpdateTimer();
this.resetVersionInfo(); this.resetVersionInfo();
this.setManagementKey('', { persist: false }); this.setManagementKey('', { persist: false });
this.oauthExcludedModels = {};
this._oauthExcludedLoading = false;
if (typeof this.renderOauthExcludedModels === 'function') {
this.renderOauthExcludedModels('all');
}
localStorage.removeItem('isLoggedIn'); localStorage.removeItem('isLoggedIn');
secureStorage.removeItem('managementKey'); secureStorage.removeItem('managementKey');

View File

@@ -20,8 +20,20 @@ export const logsModule = {
} }
let url = '/logs'; let url = '/logs';
const params = new URLSearchParams();
if (incremental && this.latestLogTimestamp) { if (incremental && this.latestLogTimestamp) {
url += `?after=${this.latestLogTimestamp}`; params.set('after', this.latestLogTimestamp);
}
const logFetchLimit = Number.isFinite(this.logFetchLimit) ? this.logFetchLimit : 2500;
if (logFetchLimit > 0) {
params.set('limit', logFetchLimit);
}
const queryString = params.toString();
if (queryString) {
url += `?${queryString}`;
} }
const response = await this.makeRequest(url, { const response = await this.makeRequest(url, {
@@ -334,6 +346,162 @@ export const logsModule = {
return null; return null;
}, },
async openErrorLogsModal() {
const modalBody = document.getElementById('modal-body');
if (!modalBody) return;
modalBody.innerHTML = `
<h3>${i18n.t('logs.error_logs_modal_title')}</h3>
<div class="provider-item">
<div class="item-content">
<p class="form-hint">${i18n.t('logs.error_logs_description')}</p>
<div class="loading-placeholder">${i18n.t('common.loading')}</div>
</div>
</div>
<div class="modal-actions">
<button class="btn btn-secondary" onclick="manager.closeModal()">${i18n.t('common.close')}</button>
</div>
`;
this.showModal();
try {
const response = await this.makeRequest('/request-error-logs', {
method: 'GET'
});
const files = Array.isArray(response?.files) ? response.files.slice() : [];
if (files.length > 1) {
files.sort((a, b) => (b.modified || 0) - (a.modified || 0));
}
modalBody.innerHTML = this.buildErrorLogsModal(files);
this.showModal();
this.bindErrorLogDownloadButtons();
} catch (error) {
console.error('加载错误日志列表失败:', error);
modalBody.innerHTML = `
<h3>${i18n.t('logs.error_logs_modal_title')}</h3>
<div class="provider-item">
<div class="item-content">
<div class="error-state">
<i class="fas fa-exclamation-triangle"></i>
<p>${i18n.t('logs.error_logs_load_error')}</p>
<p>${this.escapeHtml(error.message || '')}</p>
</div>
</div>
</div>
<div class="modal-actions">
<button class="btn btn-secondary" onclick="manager.closeModal()">${i18n.t('common.close')}</button>
</div>
`;
this.showNotification(`${i18n.t('logs.error_logs_load_error')}: ${error.message}`, 'error');
}
},
buildErrorLogsModal(files) {
const listHtml = Array.isArray(files) && files.length > 0
? files.map(file => this.buildErrorLogCard(file)).join('')
: `
<div class="empty-state">
<i class="fas fa-inbox"></i>
<h3>${i18n.t('logs.error_logs_empty')}</h3>
<p>${i18n.t('logs.error_logs_description')}</p>
</div>
`;
return `
<h3>${i18n.t('logs.error_logs_modal_title')}</h3>
<p class="form-hint">${i18n.t('logs.error_logs_description')}</p>
<div class="provider-list">
${listHtml}
</div>
<div class="modal-actions">
<button class="btn btn-secondary" onclick="manager.closeModal()">${i18n.t('common.close')}</button>
</div>
`;
},
buildErrorLogCard(file) {
const name = file?.name || '';
const size = typeof file?.size === 'number' ? this.formatFileSize(file.size) : '-';
const modified = file?.modified ? this.formatErrorLogTime(file.modified) : '-';
return `
<div class="provider-item">
<div class="item-content">
<div class="item-title">${this.escapeHtml(name)}</div>
<div class="item-subtitle">${i18n.t('logs.error_logs_size')}: ${this.escapeHtml(size)}</div>
<div class="item-subtitle">${i18n.t('logs.error_logs_modified')}: ${this.escapeHtml(modified)}</div>
</div>
<div class="item-actions">
<button class="btn btn-secondary error-log-download-btn" data-log-name="${this.escapeHtml(name)}">
<i class="fas fa-download"></i> ${i18n.t('logs.error_logs_download')}
</button>
</div>
</div>
`;
},
bindErrorLogDownloadButtons() {
const modalBody = document.getElementById('modal-body');
if (!modalBody) return;
const buttons = modalBody.querySelectorAll('.error-log-download-btn');
buttons.forEach(button => {
button.onclick = () => {
const filename = button.getAttribute('data-log-name');
if (filename) {
this.downloadErrorLog(filename);
}
};
});
},
formatErrorLogTime(timestamp) {
const numeric = Number(timestamp);
if (!Number.isFinite(numeric) || numeric <= 0) {
return '-';
}
const date = new Date(numeric * 1000);
if (Number.isNaN(date.getTime())) {
return '-';
}
const locale = i18n?.currentLanguage || undefined;
return date.toLocaleString(locale);
},
async downloadErrorLog(filename) {
if (!filename) return;
try {
const response = await this.apiClient.requestRaw(`/request-error-logs/${encodeURIComponent(filename)}`, {
method: 'GET'
});
if (!response.ok) {
let errorMessage = `HTTP ${response.status}`;
try {
const errorData = await response.json();
if (errorData && errorData.error) {
errorMessage = errorData.error;
}
} catch (parseError) {
// ignore JSON parse error and use default message
}
throw new Error(errorMessage);
}
const blob = await response.blob();
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
window.URL.revokeObjectURL(url);
this.showNotification(i18n.t('logs.error_log_download_success'), 'success');
} catch (error) {
console.error('下载错误日志失败:', error);
this.showNotification(`${i18n.t('notification.download_failed')}: ${error.message}`, 'error');
}
},
async downloadLogs() { async downloadLogs() {
try { try {
const response = await this.makeRequest('/logs', { const response = await this.makeRequest('/logs', {

View File

@@ -300,6 +300,135 @@ export const oauthModule = {
} }
}, },
// ===== Antigravity OAuth 相关方法 =====
// 开始 Antigravity OAuth 流程
async startAntigravityOAuth() {
try {
const response = await this.makeRequest('/antigravity-auth-url?is_webui=1');
const authUrl = response.url;
const state = response.state || this.extractStateFromUrl(authUrl);
// 显示授权链接
const urlInput = document.getElementById('antigravity-oauth-url');
const content = document.getElementById('antigravity-oauth-content');
const status = document.getElementById('antigravity-oauth-status');
if (urlInput) {
urlInput.value = authUrl;
}
if (content) {
content.style.display = 'block';
}
if (status) {
status.textContent = i18n.t('auth_login.antigravity_oauth_status_waiting');
status.style.color = 'var(--warning-text)';
}
// 开始轮询认证状态
this.startAntigravityOAuthPolling(state);
} catch (error) {
this.showNotification(`${i18n.t('auth_login.antigravity_oauth_start_error')} ${error.message}`, 'error');
}
},
// 打开 Antigravity 授权链接
openAntigravityLink() {
const urlInput = document.getElementById('antigravity-oauth-url');
if (urlInput && urlInput.value) {
window.open(urlInput.value, '_blank');
}
},
// 复制 Antigravity 授权链接
async copyAntigravityLink() {
const urlInput = document.getElementById('antigravity-oauth-url');
if (urlInput && urlInput.value) {
try {
await navigator.clipboard.writeText(urlInput.value);
this.showNotification(i18n.t('notification.link_copied'), 'success');
} catch (error) {
urlInput.select();
document.execCommand('copy');
this.showNotification(i18n.t('notification.link_copied'), 'success');
}
}
},
// 开始轮询 Antigravity OAuth 状态
startAntigravityOAuthPolling(state) {
if (!state) {
this.showNotification(i18n.t('auth_login.missing_state'), 'error');
return;
}
const pollInterval = setInterval(async () => {
try {
const response = await this.makeRequest(`/get-auth-status?state=${encodeURIComponent(state)}`);
const status = response.status;
const statusElement = document.getElementById('antigravity-oauth-status');
if (status === 'ok') {
clearInterval(pollInterval);
this.resetAntigravityOAuthUI();
this.showNotification(i18n.t('auth_login.antigravity_oauth_status_success'), 'success');
this.loadAuthFiles();
} else if (status === 'error') {
clearInterval(pollInterval);
const errorMessage = response.error || 'Unknown error';
if (statusElement) {
statusElement.textContent = `${i18n.t('auth_login.antigravity_oauth_status_error')} ${errorMessage}`;
statusElement.style.color = 'var(--error-text)';
}
this.showNotification(`${i18n.t('auth_login.antigravity_oauth_status_error')} ${errorMessage}`, 'error');
setTimeout(() => {
this.resetAntigravityOAuthUI();
}, 3000);
} else if (status === 'wait') {
if (statusElement) {
statusElement.textContent = i18n.t('auth_login.antigravity_oauth_status_waiting');
statusElement.style.color = 'var(--warning-text)';
}
}
} catch (error) {
clearInterval(pollInterval);
const statusElement = document.getElementById('antigravity-oauth-status');
if (statusElement) {
statusElement.textContent = `${i18n.t('auth_login.antigravity_oauth_polling_error')} ${error.message}`;
statusElement.style.color = 'var(--error-text)';
}
this.showNotification(`${i18n.t('auth_login.antigravity_oauth_polling_error')} ${error.message}`, 'error');
setTimeout(() => {
this.resetAntigravityOAuthUI();
}, 3000);
}
}, 2000);
setTimeout(() => {
clearInterval(pollInterval);
}, 5 * 60 * 1000);
},
// 重置 Antigravity OAuth UI 到初始状态
resetAntigravityOAuthUI() {
const urlInput = document.getElementById('antigravity-oauth-url');
const content = document.getElementById('antigravity-oauth-content');
const status = document.getElementById('antigravity-oauth-status');
if (urlInput) {
urlInput.value = '';
}
if (content) {
content.style.display = 'none';
}
if (status) {
status.textContent = '';
status.style.color = '';
status.className = '';
}
},
// ===== Gemini CLI OAuth 相关方法 ===== // ===== Gemini CLI OAuth 相关方法 =====
// 开始 Gemini CLI OAuth 流程 // 开始 Gemini CLI OAuth 流程
@@ -733,6 +862,88 @@ export const oauthModule = {
status.style.color = ''; status.style.color = '';
status.className = ''; status.className = '';
} }
},
// 提交 iFlow Cookie 登录
async submitIflowCookieLogin() {
const cookieInput = document.getElementById('iflow-cookie-input');
const statusEl = document.getElementById('iflow-cookie-status');
const submitBtn = document.getElementById('iflow-cookie-submit');
const cookieValue = cookieInput ? cookieInput.value.trim() : '';
this.renderIflowCookieResult(null);
if (!cookieValue) {
this.showNotification(i18n.t('auth_login.iflow_cookie_required'), 'error');
if (statusEl) {
statusEl.textContent = `${i18n.t('auth_login.iflow_cookie_status_error')} ${i18n.t('auth_login.iflow_cookie_required')}`;
statusEl.style.color = 'var(--error-text)';
}
return;
}
try {
if (submitBtn) {
submitBtn.disabled = true;
}
if (statusEl) {
statusEl.textContent = i18n.t('auth_login.iflow_oauth_status_waiting');
statusEl.style.color = 'var(--warning-text)';
}
const response = await this.makeRequest('/iflow-auth-url', {
method: 'POST',
body: JSON.stringify({ cookie: cookieValue })
});
this.renderIflowCookieResult(response);
if (statusEl) {
statusEl.textContent = i18n.t('auth_login.iflow_cookie_status_success');
statusEl.style.color = 'var(--success-text)';
}
if (cookieInput) {
cookieInput.value = '';
}
this.showNotification(i18n.t('auth_login.iflow_cookie_status_success'), 'success');
this.loadAuthFiles();
} catch (error) {
if (statusEl) {
statusEl.textContent = `${i18n.t('auth_login.iflow_cookie_status_error')} ${error.message}`;
statusEl.style.color = 'var(--error-text)';
}
this.showNotification(`${i18n.t('auth_login.iflow_cookie_start_error')} ${error.message}`, 'error');
} finally {
if (submitBtn) {
submitBtn.disabled = false;
}
}
},
renderIflowCookieResult(result = null) {
const container = document.getElementById('iflow-cookie-result');
const emailEl = document.getElementById('iflow-cookie-result-email');
const expiredEl = document.getElementById('iflow-cookie-result-expired');
const pathEl = document.getElementById('iflow-cookie-result-path');
const typeEl = document.getElementById('iflow-cookie-result-type');
if (!container || !emailEl || !expiredEl || !pathEl || !typeEl) {
return;
}
if (!result) {
container.style.display = 'none';
emailEl.textContent = '-';
expiredEl.textContent = '-';
pathEl.textContent = '-';
typeEl.textContent = '-';
return;
}
emailEl.textContent = result.email || '-';
expiredEl.textContent = result.expired || '-';
pathEl.textContent = result.saved_path || result.savedPath || result.path || '-';
typeEl.textContent = result.type || '-';
container.style.display = 'block';
} }
}; };

View File

@@ -1,3 +1,7 @@
const DEFAULT_MODEL_PRICE_STORAGE_KEY = 'cli-proxy-model-prices-v2';
const LEGACY_MODEL_PRICE_STORAGE_KEY = 'cli-proxy-model-prices';
const TOKENS_PER_PRICE_UNIT = 1_000_000;
// 获取API密钥的统计信息 // 获取API密钥的统计信息
export async function getKeyStats(usageData = null) { export async function getKeyStats(usageData = null) {
try { try {
@@ -83,6 +87,7 @@ export async function loadUsageStats(usageData = null) {
usage = response?.usage || null; usage = response?.usage || null;
} }
this.currentUsageData = usage; this.currentUsageData = usage;
this.ensureModelPriceState();
if (!usage) { if (!usage) {
throw new Error('usage payload missing'); throw new Error('usage payload missing');
@@ -91,16 +96,21 @@ export async function loadUsageStats(usageData = null) {
// 更新概览卡片 // 更新概览卡片
this.updateUsageOverview(usage); this.updateUsageOverview(usage);
this.updateChartLineSelectors(usage); this.updateChartLineSelectors(usage);
this.renderModelPriceOptions(usage);
this.renderSavedModelPrices();
// 读取当前图表周期 // 读取当前图表周期
const requestsHourActive = document.getElementById('requests-hour-btn')?.classList.contains('active'); const requestsHourActive = document.getElementById('requests-hour-btn')?.classList.contains('active');
const tokensHourActive = document.getElementById('tokens-hour-btn')?.classList.contains('active'); const tokensHourActive = document.getElementById('tokens-hour-btn')?.classList.contains('active');
const costHourActive = document.getElementById('cost-hour-btn')?.classList.contains('active');
const requestsPeriod = requestsHourActive ? 'hour' : 'day'; const requestsPeriod = requestsHourActive ? 'hour' : 'day';
const tokensPeriod = tokensHourActive ? 'hour' : 'day'; const tokensPeriod = tokensHourActive ? 'hour' : 'day';
const costPeriod = costHourActive ? 'hour' : 'day';
// 初始化图表(使用当前周期) // 初始化图表(使用当前周期)
this.initializeRequestsChart(requestsPeriod); this.initializeRequestsChart(requestsPeriod);
this.initializeTokensChart(tokensPeriod); this.initializeTokensChart(tokensPeriod);
this.updateCostSummaryAndChart(usage, costPeriod);
// 更新API详细统计表格 // 更新API详细统计表格
this.updateApiStatsTable(usage); this.updateApiStatsTable(usage);
@@ -109,9 +119,13 @@ export async function loadUsageStats(usageData = null) {
console.error('加载使用统计失败:', error); console.error('加载使用统计失败:', error);
this.currentUsageData = null; this.currentUsageData = null;
this.updateChartLineSelectors(null); this.updateChartLineSelectors(null);
this.ensureModelPriceState();
this.renderModelPriceOptions(null);
this.renderSavedModelPrices();
this.updateCostSummaryAndChart(null);
// 清空概览数据 // 清空概览数据
['total-requests', 'success-requests', 'failed-requests', 'total-tokens'].forEach(id => { ['total-requests', 'success-requests', 'failed-requests', 'total-tokens', 'cached-tokens', 'reasoning-tokens', 'rpm-30m', 'tpm-30m'].forEach(id => {
const el = document.getElementById(id); const el = document.getElementById(id);
if (el) el.textContent = '-'; if (el) el.textContent = '-';
}); });
@@ -139,7 +153,48 @@ export function updateUsageOverview(data) {
document.getElementById('total-requests').textContent = safeData.total_requests ?? 0; document.getElementById('total-requests').textContent = safeData.total_requests ?? 0;
document.getElementById('success-requests').textContent = safeData.success_count ?? 0; document.getElementById('success-requests').textContent = safeData.success_count ?? 0;
document.getElementById('failed-requests').textContent = safeData.failure_count ?? 0; document.getElementById('failed-requests').textContent = safeData.failure_count ?? 0;
document.getElementById('total-tokens').textContent = safeData.total_tokens ?? 0; const totalTokensValue = safeData.total_tokens ?? 0;
document.getElementById('total-tokens').textContent = this.formatTokensInMillions(totalTokensValue);
const tokenBreakdown = this.calculateTokenBreakdown(safeData);
const cachedEl = document.getElementById('cached-tokens');
const reasoningEl = document.getElementById('reasoning-tokens');
if (cachedEl) {
cachedEl.textContent = this.formatTokensInMillions(tokenBreakdown.cachedTokens);
}
if (reasoningEl) {
reasoningEl.textContent = this.formatTokensInMillions(tokenBreakdown.reasoningTokens);
}
const recentRate = this.calculateRecentPerMinuteRates(30, safeData);
document.getElementById('rpm-30m').textContent = this.formatPerMinuteValue(recentRate.rpm);
document.getElementById('tpm-30m').textContent = this.formatPerMinuteValue(recentRate.tpm);
}
export function formatTokensInMillions(value) {
const num = Number(value);
if (!Number.isFinite(num)) {
return '0.00M';
}
return `${(num / 1_000_000).toFixed(2)}M`;
}
export function formatPerMinuteValue(value) {
const num = Number(value);
if (!Number.isFinite(num)) {
return '0.00';
}
const abs = Math.abs(num);
if (abs >= 1000) {
return Math.round(num).toLocaleString();
}
if (abs >= 100) {
return num.toFixed(0);
}
if (abs >= 10) {
return num.toFixed(1);
}
return num.toFixed(2);
} }
export function getModelNamesFromUsage(usage) { export function getModelNamesFromUsage(usage) {
@@ -305,6 +360,290 @@ export function collectUsageDetails() {
return this.collectUsageDetailsFromUsage(this.currentUsageData); return this.collectUsageDetailsFromUsage(this.currentUsageData);
} }
export function migrateLegacyModelPrices() {
try {
if (typeof localStorage === 'undefined') {
return;
}
const storageKey = this.modelPriceStorageKey || DEFAULT_MODEL_PRICE_STORAGE_KEY;
const hasCurrent = localStorage.getItem(storageKey);
const legacyRaw = localStorage.getItem(LEGACY_MODEL_PRICE_STORAGE_KEY);
if (!legacyRaw || hasCurrent) {
return;
}
const parsed = JSON.parse(legacyRaw);
if (!parsed || typeof parsed !== 'object') {
return;
}
const migrated = {};
Object.entries(parsed).forEach(([model, price]) => {
if (!model) return;
const prompt = Number(price?.prompt);
const completion = Number(price?.completion);
const hasPrompt = Number.isFinite(prompt);
const hasCompletion = Number.isFinite(completion);
if (!hasPrompt && !hasCompletion) {
return;
}
migrated[model] = {
prompt: hasPrompt && prompt >= 0 ? prompt * 1000 : 0,
completion: hasCompletion && completion >= 0 ? completion * 1000 : 0
};
});
if (Object.keys(migrated).length) {
localStorage.setItem(storageKey, JSON.stringify(migrated));
}
localStorage.removeItem(LEGACY_MODEL_PRICE_STORAGE_KEY);
} catch (error) {
console.warn('迁移模型价格失败:', error);
}
}
export function ensureModelPriceState() {
if (this.modelPriceInitialized) {
return;
}
this.modelPriceStorageKey = this.modelPriceStorageKey || DEFAULT_MODEL_PRICE_STORAGE_KEY;
this.migrateLegacyModelPrices();
this.modelPrices = this.loadModelPricesFromStorage();
this.modelPriceInitialized = true;
}
export function loadModelPricesFromStorage() {
const storageKey = this.modelPriceStorageKey || DEFAULT_MODEL_PRICE_STORAGE_KEY;
try {
if (typeof localStorage === 'undefined') {
return {};
}
const raw = localStorage.getItem(storageKey);
if (!raw) {
return {};
}
const parsed = JSON.parse(raw);
if (!parsed || typeof parsed !== 'object') {
return {};
}
const normalized = {};
Object.entries(parsed).forEach(([model, price]) => {
if (!model) return;
const prompt = Number(price?.prompt);
const completion = Number(price?.completion);
if (!Number.isFinite(prompt) && !Number.isFinite(completion)) {
return;
}
normalized[model] = {
prompt: Number.isFinite(prompt) && prompt >= 0 ? prompt : 0,
completion: Number.isFinite(completion) && completion >= 0 ? completion : 0
};
});
return normalized;
} catch (error) {
console.warn('读取模型价格失败:', error);
return {};
}
}
export function persistModelPrices(prices = {}) {
const storageKey = this.modelPriceStorageKey || DEFAULT_MODEL_PRICE_STORAGE_KEY;
this.modelPrices = prices;
try {
if (typeof localStorage === 'undefined') {
return;
}
localStorage.setItem(storageKey, JSON.stringify(prices));
} catch (error) {
console.warn('保存模型价格失败:', error);
}
}
export function renderModelPriceOptions(usage = null) {
const select = document.getElementById('model-price-model-select');
if (!select) return;
const models = this.getModelNamesFromUsage(usage);
const previousValue = select.value;
select.innerHTML = '';
const placeholderOption = document.createElement('option');
placeholderOption.value = '';
placeholderOption.textContent = i18n.t('usage_stats.model_price_select_placeholder');
select.appendChild(placeholderOption);
models.forEach(name => {
const option = document.createElement('option');
option.value = name;
option.textContent = name;
select.appendChild(option);
});
select.disabled = models.length === 0;
if (models.includes(previousValue)) {
select.value = previousValue;
} else {
select.value = '';
}
this.prefillModelPriceInputs();
}
export function renderSavedModelPrices() {
const container = document.getElementById('model-price-list');
if (!container) return;
const entries = Object.entries(this.modelPrices || {});
if (!entries.length) {
container.innerHTML = `<div class="no-data-message">${i18n.t('usage_stats.model_price_empty')}</div>`;
return;
}
const rows = entries.map(([model, price]) => {
const prompt = Number(price?.prompt) || 0;
const completion = Number(price?.completion) || 0;
return `
<div class="model-price-row">
<span class="model-name">${model}</span>
<span>$${prompt.toFixed(4)} / 1M</span>
<span>$${completion.toFixed(4)} / 1M</span>
</div>
`;
}).join('');
container.innerHTML = `
<div class="model-price-table">
<div class="model-price-header">
<span>${i18n.t('usage_stats.model_price_model')}</span>
<span>${i18n.t('usage_stats.model_price_prompt')}</span>
<span>${i18n.t('usage_stats.model_price_completion')}</span>
</div>
${rows}
</div>
`;
}
export function prefillModelPriceInputs() {
const select = document.getElementById('model-price-model-select');
const promptInput = document.getElementById('model-price-prompt');
const completionInput = document.getElementById('model-price-completion');
if (!select || !promptInput || !completionInput) {
return;
}
const model = (select.value || '').trim();
const price = this.modelPrices?.[model];
if (price) {
promptInput.value = Number.isFinite(price.prompt) ? price.prompt : '';
completionInput.value = Number.isFinite(price.completion) ? price.completion : '';
} else {
promptInput.value = '';
completionInput.value = '';
}
}
export function normalizePriceValue(value) {
const parsed = Number(value);
if (!Number.isFinite(parsed) || parsed < 0) {
return 0;
}
return Number(parsed.toFixed(6));
}
export function handleModelPriceSubmit() {
this.ensureModelPriceState();
const select = document.getElementById('model-price-model-select');
const promptInput = document.getElementById('model-price-prompt');
const completionInput = document.getElementById('model-price-completion');
if (!select || !promptInput || !completionInput) {
return;
}
const model = (select.value || '').trim();
if (!model) {
this.showNotification(i18n.t('usage_stats.model_price_model_required'), 'warning');
return;
}
const prompt = this.normalizePriceValue(promptInput.value);
const completion = this.normalizePriceValue(completionInput.value);
const next = { ...(this.modelPrices || {}) };
next[model] = { prompt, completion };
this.persistModelPrices(next);
this.renderSavedModelPrices();
this.updateCostSummaryAndChart(this.currentUsageData, this.getCostChartPeriod());
this.showNotification(i18n.t('usage_stats.model_price_saved'), 'success');
}
export function handleModelPriceReset() {
this.persistModelPrices({});
if (typeof localStorage !== 'undefined') {
const key = this.modelPriceStorageKey || DEFAULT_MODEL_PRICE_STORAGE_KEY;
try {
localStorage.removeItem(key);
localStorage.removeItem(LEGACY_MODEL_PRICE_STORAGE_KEY);
} catch (error) {
console.warn('清除模型价格失败:', error);
}
}
this.renderSavedModelPrices();
this.prefillModelPriceInputs();
this.updateCostSummaryAndChart(this.currentUsageData, this.getCostChartPeriod());
}
export function calculateTokenBreakdown(usage = null) {
const details = this.collectUsageDetailsFromUsage(usage || this.currentUsageData);
if (!details.length) {
return { cachedTokens: 0, reasoningTokens: 0 };
}
let cachedTokens = 0;
let reasoningTokens = 0;
details.forEach(detail => {
const tokens = detail?.tokens || {};
if (typeof tokens.cached_tokens === 'number') {
cachedTokens += tokens.cached_tokens;
}
if (typeof tokens.reasoning_tokens === 'number') {
reasoningTokens += tokens.reasoning_tokens;
}
});
return { cachedTokens, reasoningTokens };
}
export function calculateRecentPerMinuteRates(windowMinutes = 30, usage = null) {
const details = this.collectUsageDetailsFromUsage(usage || this.currentUsageData);
const effectiveWindow = Number.isFinite(windowMinutes) && windowMinutes > 0
? windowMinutes
: 30;
if (!details.length) {
return { rpm: 0, tpm: 0, windowMinutes: effectiveWindow, requestCount: 0, tokenCount: 0 };
}
const now = Date.now();
const windowStart = now - effectiveWindow * 60 * 1000;
let requestCount = 0;
let tokenCount = 0;
details.forEach(detail => {
const timestamp = Date.parse(detail.timestamp);
if (Number.isNaN(timestamp) || timestamp < windowStart) {
return;
}
requestCount += 1;
tokenCount += this.extractTotalTokens(detail);
});
const denominator = effectiveWindow > 0 ? effectiveWindow : 1;
return {
rpm: requestCount / denominator,
tpm: tokenCount / denominator,
windowMinutes: effectiveWindow,
requestCount,
tokenCount
};
}
export function createHourlyBucketMeta() { export function createHourlyBucketMeta() {
const hourMs = 60 * 60 * 1000; const hourMs = 60 * 60 * 1000;
const now = new Date(); const now = new Date();
@@ -475,12 +814,279 @@ export function extractTotalTokens(detail) {
}, 0); }, 0);
} }
export function formatUsd(value) {
const num = Number(value);
if (!Number.isFinite(num)) {
return '$0.00';
}
const fixed = num.toFixed(2);
const parts = Number(fixed).toLocaleString(undefined, {
minimumFractionDigits: 2,
maximumFractionDigits: 2
});
return `$${parts}`;
}
export function calculateCostData(prices = null, usage = null, period = 'day') {
const priceTable = prices || this.modelPrices || {};
const usagePayload = usage || this.currentUsageData;
const entries = Object.entries(priceTable || {});
const result = { totalCost: 0, labels: [], datasets: [] };
if (!entries.length || !usagePayload) {
return result;
}
const details = this.collectUsageDetailsFromUsage(usagePayload);
if (!details.length) {
return result;
}
const normalizedDetails = details.map(detail => {
const modelName = detail.__modelName || 'Unknown';
const price = priceTable[modelName];
if (!price) {
return null;
}
const tokens = detail?.tokens || {};
const promptTokens = Number(tokens.input_tokens) || 0;
const completionTokens = Number(tokens.output_tokens) || 0;
const promptCost = (promptTokens / TOKENS_PER_PRICE_UNIT) * (Number(price.prompt) || 0);
const completionCost = (completionTokens / TOKENS_PER_PRICE_UNIT) * (Number(price.completion) || 0);
const detailCost = promptCost + completionCost;
const parsedTimestamp = Date.parse(detail.timestamp);
if (!Number.isFinite(detailCost) || detailCost <= 0 || Number.isNaN(parsedTimestamp)) {
return null;
}
return { modelName, cost: detailCost, timestamp: parsedTimestamp };
}).filter(Boolean);
if (!normalizedDetails.length) {
return result;
}
const totalCost = normalizedDetails.reduce((sum, item) => sum + item.cost, 0);
if (period === 'hour') {
const meta = this.createHourlyBucketMeta();
const dataByModel = new Map();
normalizedDetails.forEach(({ modelName, cost, timestamp }) => {
const normalized = new Date(timestamp);
normalized.setMinutes(0, 0, 0);
const bucketStart = normalized.getTime();
if (bucketStart < meta.earliestTime || bucketStart > meta.lastBucketTime) {
return;
}
const bucketIndex = Math.floor((bucketStart - meta.earliestTime) / meta.bucketSize);
if (bucketIndex < 0 || bucketIndex >= meta.labels.length) {
return;
}
if (!dataByModel.has(modelName)) {
dataByModel.set(modelName, new Array(meta.labels.length).fill(0));
}
const bucketValues = dataByModel.get(modelName);
bucketValues[bucketIndex] += cost;
});
const datasets = [];
dataByModel.forEach((series, modelName) => {
datasets.push({ label: modelName, data: series.map(value => Number(value.toFixed(4))) });
});
return { totalCost, labels: meta.labels, datasets };
}
const labelSet = new Set();
const costByModelDay = new Map();
normalizedDetails.forEach(({ modelName, cost, timestamp }) => {
const dayLabel = this.formatDayLabel(new Date(timestamp));
if (!dayLabel) {
return;
}
if (!costByModelDay.has(modelName)) {
costByModelDay.set(modelName, new Map());
}
const dayMap = costByModelDay.get(modelName);
dayMap.set(dayLabel, (dayMap.get(dayLabel) || 0) + cost);
labelSet.add(dayLabel);
});
const labels = Array.from(labelSet).sort();
const datasets = [];
costByModelDay.forEach((dayMap, modelName) => {
const series = labels.map(label => Number((dayMap.get(label) || 0).toFixed(4)));
datasets.push({ label: modelName, data: series });
});
return { totalCost, labels, datasets };
}
export function setCostChartPlaceholder(messageKey = null) {
const placeholder = document.getElementById('cost-chart-placeholder');
const canvas = document.getElementById('cost-chart');
if (!placeholder || !canvas) {
return;
}
if (messageKey) {
placeholder.textContent = i18n.t(messageKey);
placeholder.style.display = 'flex';
canvas.style.display = 'none';
} else {
placeholder.style.display = 'none';
canvas.style.display = 'block';
}
}
export function destroyCostChart() {
if (this.costChart) {
this.costChart.destroy();
this.costChart = null;
}
}
export function initializeCostChart(costData, period = 'day') {
const canvas = document.getElementById('cost-chart');
if (!canvas) {
return;
}
this.destroyCostChart();
const datasets = (costData.datasets || []).map((dataset, index) => {
const style = this.chartLineStyles[index % this.chartLineStyles.length] || this.chartLineStyles[0];
return {
...dataset,
borderColor: style.borderColor,
backgroundColor: style.backgroundColor || 'rgba(59, 130, 246, 0.15)',
fill: true,
tension: 0.35,
pointBackgroundColor: style.borderColor,
pointBorderColor: '#ffffff',
pointBorderWidth: 2,
pointRadius: dataset.data.some(v => v > 0) ? 4 : 3
};
});
this.costChart = new Chart(canvas, {
type: 'line',
data: {
labels: costData.labels || [],
datasets
},
options: {
responsive: true,
maintainAspectRatio: false,
interaction: {
mode: 'index',
intersect: false
},
plugins: {
legend: {
display: true,
position: 'top',
align: 'start',
labels: {
usePointStyle: true
}
},
tooltip: {
callbacks: {
label: context => {
const label = context.dataset.label || '';
const value = Number(context.parsed.y) || 0;
return `${label}: ${this.formatUsd(value)}`;
}
}
}
},
scales: {
x: {
title: {
display: true,
text: i18n.t(period === 'hour' ? 'usage_stats.by_hour' : 'usage_stats.by_day')
}
},
y: {
beginAtZero: true,
title: {
display: true,
text: i18n.t('usage_stats.cost_axis_label')
},
ticks: {
callback: (value) => this.formatUsd(value).replace('$', '')
}
}
},
elements: {
line: {
borderWidth: 2
},
point: {
borderWidth: 2
}
}
}
});
this.setCostChartPlaceholder(null);
}
export function getCostChartPeriod() {
const costHourActive = document.getElementById('cost-hour-btn')?.classList.contains('active');
return costHourActive ? 'hour' : 'day';
}
export function updateCostSummaryAndChart(usage = null, period = null) {
this.ensureModelPriceState();
const totalCostEl = document.getElementById('total-cost');
const hasPrices = Object.keys(this.modelPrices || {}).length > 0;
const usagePayload = usage || this.currentUsageData;
const resolvedPeriod = period || this.getCostChartPeriod();
if (!hasPrices) {
if (totalCostEl) {
totalCostEl.textContent = '--';
}
this.destroyCostChart();
this.setCostChartPlaceholder('usage_stats.cost_need_price');
return;
}
if (!usagePayload) {
if (totalCostEl) {
totalCostEl.textContent = '--';
}
this.destroyCostChart();
this.setCostChartPlaceholder('usage_stats.cost_need_usage');
return;
}
const costData = this.calculateCostData(this.modelPrices, usagePayload, resolvedPeriod);
if (totalCostEl) {
totalCostEl.textContent = this.formatUsd(costData.totalCost);
}
if (!costData.labels.length || !costData.datasets.length) {
this.destroyCostChart();
this.setCostChartPlaceholder('usage_stats.cost_no_data');
return;
}
this.initializeCostChart(costData, resolvedPeriod);
}
// 初始化图表 // 初始化图表
export function initializeCharts() { export function initializeCharts() {
const requestsHourActive = document.getElementById('requests-hour-btn')?.classList.contains('active'); const requestsHourActive = document.getElementById('requests-hour-btn')?.classList.contains('active');
const tokensHourActive = document.getElementById('tokens-hour-btn')?.classList.contains('active'); const tokensHourActive = document.getElementById('tokens-hour-btn')?.classList.contains('active');
const costHourActive = document.getElementById('cost-hour-btn')?.classList.contains('active');
this.initializeRequestsChart(requestsHourActive ? 'hour' : 'day'); this.initializeRequestsChart(requestsHourActive ? 'hour' : 'day');
this.initializeTokensChart(tokensHourActive ? 'hour' : 'day'); this.initializeTokensChart(tokensHourActive ? 'hour' : 'day');
this.updateCostSummaryAndChart(this.currentUsageData, costHourActive ? 'hour' : 'day');
} }
// 初始化请求趋势图表 // 初始化请求趋势图表
@@ -651,6 +1257,20 @@ export function switchTokensPeriod(period) {
} }
} }
export function switchCostPeriod(period) {
if (period !== 'hour' && period !== 'day') {
return;
}
const hourBtn = document.getElementById('cost-hour-btn');
const dayBtn = document.getElementById('cost-day-btn');
if (hourBtn && dayBtn) {
hourBtn.classList.toggle('active', period === 'hour');
dayBtn.classList.toggle('active', period === 'day');
}
this.updateCostSummaryAndChart(this.currentUsageData, period);
}
// 更新API详细统计表格 // 更新API详细统计表格
export function updateApiStatsTable(data) { export function updateApiStatsTable(data) {
const container = document.getElementById('api-stats-table'); const container = document.getElementById('api-stats-table');
@@ -663,6 +1283,29 @@ export function updateApiStatsTable(data) {
return; return;
} }
const hasPrices = Object.keys(this.modelPrices || {}).length > 0;
const calculateEndpointCost = (apiData) => {
if (!hasPrices) return 0;
let cost = 0;
const models = apiData.models || {};
Object.entries(models).forEach(([modelName, modelData]) => {
const price = this.modelPrices?.[modelName];
if (!price) return;
const details = Array.isArray(modelData.details) ? modelData.details : [];
details.forEach(detail => {
const tokens = detail?.tokens || {};
const promptTokens = Number(tokens.input_tokens) || 0;
const completionTokens = Number(tokens.output_tokens) || 0;
const detailCost = (promptTokens / TOKENS_PER_PRICE_UNIT) * (Number(price.prompt) || 0)
+ (completionTokens / TOKENS_PER_PRICE_UNIT) * (Number(price.completion) || 0);
if (Number.isFinite(detailCost) && detailCost > 0) {
cost += detailCost;
}
});
});
return Number(cost.toFixed(4));
};
let tableHtml = ` let tableHtml = `
<table class="stats-table"> <table class="stats-table">
<thead> <thead>
@@ -670,7 +1313,7 @@ export function updateApiStatsTable(data) {
<th>${i18n.t('usage_stats.api_endpoint')}</th> <th>${i18n.t('usage_stats.api_endpoint')}</th>
<th>${i18n.t('usage_stats.requests_count')}</th> <th>${i18n.t('usage_stats.requests_count')}</th>
<th>${i18n.t('usage_stats.tokens_count')}</th> <th>${i18n.t('usage_stats.tokens_count')}</th>
<th>${i18n.t('usage_stats.success_rate')}</th> <th>${i18n.t('usage_stats.total_cost')}</th>
<th>${i18n.t('usage_stats.models')}</th> <th>${i18n.t('usage_stats.models')}</th>
</tr> </tr>
</thead> </thead>
@@ -679,10 +1322,7 @@ export function updateApiStatsTable(data) {
Object.entries(apis).forEach(([endpoint, apiData]) => { Object.entries(apis).forEach(([endpoint, apiData]) => {
const totalRequests = apiData.total_requests || 0; const totalRequests = apiData.total_requests || 0;
const successCount = apiData.success_count ?? null; const endpointCost = calculateEndpointCost(apiData);
const successRate = successCount !== null && totalRequests > 0
? Math.round((successCount / totalRequests) * 100)
: null;
// 构建模型详情 // 构建模型详情
let modelsHtml = ''; let modelsHtml = '';
@@ -690,7 +1330,7 @@ export function updateApiStatsTable(data) {
modelsHtml = '<div class="model-details">'; modelsHtml = '<div class="model-details">';
Object.entries(apiData.models).forEach(([modelName, modelData]) => { Object.entries(apiData.models).forEach(([modelName, modelData]) => {
const modelRequests = modelData.total_requests ?? 0; const modelRequests = modelData.total_requests ?? 0;
const modelTokens = modelData.total_tokens ?? 0; const modelTokens = this.formatTokensInMillions(modelData.total_tokens ?? 0);
modelsHtml += ` modelsHtml += `
<div class="model-item"> <div class="model-item">
<span class="model-name">${modelName}</span> <span class="model-name">${modelName}</span>
@@ -705,8 +1345,8 @@ export function updateApiStatsTable(data) {
<tr> <tr>
<td>${endpoint}</td> <td>${endpoint}</td>
<td>${totalRequests}</td> <td>${totalRequests}</td>
<td>${apiData.total_tokens || 0}</td> <td>${this.formatTokensInMillions(apiData.total_tokens || 0)}</td>
<td>${successRate !== null ? successRate + '%' : '-'}</td> <td>${hasPrices && endpointCost > 0 ? this.formatUsd(endpointCost) : '--'}</td>
<td>${modelsHtml || '-'}</td> <td>${modelsHtml || '-'}</td>
</tr> </tr>
`; `;
@@ -727,13 +1367,34 @@ export const usageModule = {
getActiveChartLineSelections, getActiveChartLineSelections,
collectUsageDetailsFromUsage, collectUsageDetailsFromUsage,
collectUsageDetails, collectUsageDetails,
migrateLegacyModelPrices,
ensureModelPriceState,
loadModelPricesFromStorage,
persistModelPrices,
renderModelPriceOptions,
renderSavedModelPrices,
prefillModelPriceInputs,
normalizePriceValue,
handleModelPriceSubmit,
handleModelPriceReset,
calculateTokenBreakdown,
calculateRecentPerMinuteRates,
createHourlyBucketMeta, createHourlyBucketMeta,
buildHourlySeriesByModel, buildHourlySeriesByModel,
buildDailySeriesByModel, buildDailySeriesByModel,
buildChartDataForMetric, buildChartDataForMetric,
formatHourLabel, formatHourLabel,
formatTokensInMillions,
formatPerMinuteValue,
formatDayLabel, formatDayLabel,
extractTotalTokens, extractTotalTokens,
formatUsd,
calculateCostData,
getCostChartPeriod,
setCostChartPlaceholder,
destroyCostChart,
initializeCostChart,
updateCostSummaryAndChart,
initializeCharts, initializeCharts,
initializeRequestsChart, initializeRequestsChart,
initializeTokensChart, initializeTokensChart,
@@ -741,6 +1402,7 @@ export const usageModule = {
getTokensChartData, getTokensChartData,
switchRequestsPeriod, switchRequestsPeriod,
switchTokensPeriod, switchTokensPeriod,
switchCostPeriod,
updateApiStatsTable, updateApiStatsTable,
registerUsageListeners registerUsageListeners
}; };

View File

@@ -53,6 +53,12 @@ export const OAUTH_MAX_POLL_DURATION_MS = 5 * 60 * 1000;
*/ */
export const MAX_LOG_LINES = 2000; export const MAX_LOG_LINES = 2000;
/**
* 日志接口获取数量上限
* 限制后端返回的日志行数,避免一次拉取过多数据
*/
export const LOG_FETCH_LIMIT = 2500;
/** /**
* 认证文件列表默认每页显示数量 * 认证文件列表默认每页显示数量
*/ */
@@ -109,6 +115,7 @@ export const REQUEST_TIMEOUT_MS = 30 * 1000;
export const OAUTH_CARD_IDS = [ export const OAUTH_CARD_IDS = [
'codex-oauth-card', 'codex-oauth-card',
'anthropic-oauth-card', 'anthropic-oauth-card',
'antigravity-oauth-card',
'gemini-cli-oauth-card', 'gemini-cli-oauth-card',
'qwen-oauth-card', 'qwen-oauth-card',
'iflow-oauth-card' 'iflow-oauth-card'
@@ -120,6 +127,7 @@ export const OAUTH_CARD_IDS = [
export const OAUTH_PROVIDERS = { export const OAUTH_PROVIDERS = {
CODEX: 'codex', CODEX: 'codex',
ANTHROPIC: 'anthropic', ANTHROPIC: 'anthropic',
ANTIGRAVITY: 'antigravity',
GEMINI_CLI: 'gemini-cli', GEMINI_CLI: 'gemini-cli',
QWEN: 'qwen', QWEN: 'qwen',
IFLOW: 'iflow' IFLOW: 'iflow'

View File

@@ -1548,10 +1548,128 @@ input:checked+.slider:before {
/* 列表样式 */ /* 列表样式 */
.key-list { .key-list {
max-height: 400px; /* 便于一次展示 6 条密钥记录(约 6*80px 高度) */
max-height: 520px;
overflow-y: auto; overflow-y: auto;
} }
/* API 密钥列表压缩布局 */
.key-table {
display: flex;
flex-direction: column;
gap: 8px;
}
.key-table-row {
display: grid;
grid-template-columns: 72px 1fr auto;
column-gap: 14px;
align-items: center;
background: var(--bg-quaternary);
border: 1px solid var(--border-primary);
border-radius: 10px;
padding: 10px 12px;
transition: all 0.2s ease;
}
.key-table-row:hover {
background: var(--bg-tertiary);
border-color: var(--border-secondary);
transform: translateY(-1px);
}
.key-badge {
display: inline-flex;
align-items: center;
justify-content: center;
height: 32px;
min-width: 52px;
padding: 0 12px;
border-radius: 10px;
background: var(--bg-secondary);
color: var(--primary-color);
font-weight: 700;
font-size: 14px;
border: 1px solid var(--border-primary);
letter-spacing: 0.2px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08);
}
.key-table-value {
display: flex;
flex-direction: column;
gap: 4px;
min-width: 0;
padding: 2px 0;
}
.key-table .item-title {
margin: 0;
font-size: 13px;
color: var(--text-secondary);
}
.key-value {
background: var(--bg-secondary);
border: 1px solid var(--border-primary);
border-radius: 8px;
padding: 8px 12px;
font-family: "SFMono-Regular", Menlo, Consolas, "Liberation Mono", monospace;
font-size: 13px;
color: var(--text-primary);
font-weight: 600;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
line-height: 1.4;
}
.item-actions.compact {
position: static;
transform: none;
gap: 6px;
justify-content: flex-end;
justify-self: end;
}
.item-actions.compact .btn {
min-width: 34px;
height: 34px;
padding: 0;
border-radius: 6px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08);
}
.item-actions.compact .btn:hover {
transform: translateY(-1px);
box-shadow: 0 3px 6px rgba(0, 0, 0, 0.12);
}
.item-actions.compact .btn i {
font-size: 14px;
}
@media (max-width: 768px) {
.key-table-row {
grid-template-columns: 1fr;
row-gap: 8px;
}
.item-actions.compact {
justify-content: flex-start;
}
.key-badge {
width: fit-content;
}
.key-value {
white-space: normal;
word-break: break-all;
}
}
.file-list { .file-list {
/* 认证文件列表填满页面,保留版本信息空间 */ /* 认证文件列表填满页面,保留版本信息空间 */
max-height: calc(100vh - 300px); /* 减去导航栏、padding和版本信息的高度 */ max-height: calc(100vh - 300px); /* 减去导航栏、padding和版本信息的高度 */
@@ -1630,6 +1748,71 @@ input:checked+.slider:before {
white-space: nowrap; white-space: nowrap;
} }
.oauth-excluded-form {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
gap: 14px;
margin-top: 8px;
}
.oauth-excluded-actions {
display: flex;
flex-wrap: wrap;
justify-content: flex-start;
gap: 10px;
margin-top: 12px;
}
.oauth-excluded-scope {
color: var(--text-secondary);
font-weight: 600;
margin: 0;
}
.oauth-excluded-list {
margin-top: 12px;
}
.oauth-excluded-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 12px;
align-items: stretch;
}
.oauth-excluded-card {
height: 100%;
}
.oauth-excluded-card .provider-models {
margin-top: 6px;
}
.oauth-excluded-tags {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.oauth-excluded-card .item-actions {
top: 14px;
right: 14px;
transform: none;
}
.oauth-excluded-editor-card .item-content {
padding-right: 0;
}
.oauth-excluded-editor-card textarea {
min-height: 140px;
}
.oauth-excluded-empty {
color: var(--text-tertiary);
font-size: 0.95rem;
}
/* 认证文件工具栏 */ /* 认证文件工具栏 */
.auth-file-toolbar { .auth-file-toolbar {
display: flex; display: flex;
@@ -1816,6 +1999,16 @@ input:checked+.slider:before {
color: #ffb74d; color: #ffb74d;
} }
.file-type-badge.antigravity {
background: #e0f7fa;
color: #006064;
}
[data-theme="dark"] .file-type-badge.antigravity {
background: #004d40;
color: #80deea;
}
.file-type-badge.iflow { .file-type-badge.iflow {
background: #f3e5f5; background: #f3e5f5;
color: #7b1fa2; color: #7b1fa2;
@@ -1837,13 +2030,13 @@ input:checked+.slider:before {
} }
/* 未知类型通用样式 */ /* 未知类型通用样式 */
.file-type-badge:not(.qwen):not(.gemini):not(.gemini-cli):not(.aistudio):not(.claude):not(.codex):not(.iflow):not(.empty) { .file-type-badge:not(.qwen):not(.gemini):not(.gemini-cli):not(.aistudio):not(.claude):not(.codex):not(.antigravity):not(.iflow):not(.empty) {
background: #f0f0f0; background: #f0f0f0;
color: #666666; color: #666666;
border: 1px dashed #999999; border: 1px dashed #999999;
} }
[data-theme="dark"] .file-type-badge:not(.qwen):not(.gemini):not(.gemini-cli):not(.aistudio):not(.claude):not(.codex):not(.iflow):not(.empty) { [data-theme="dark"] .file-type-badge:not(.qwen):not(.gemini):not(.gemini-cli):not(.aistudio):not(.claude):not(.codex):not(.antigravity):not(.iflow):not(.empty) {
background: #3a3a3a; background: #3a3a3a;
color: #aaaaaa; color: #aaaaaa;
border: 1px dashed #666666; border: 1px dashed #666666;
@@ -2008,6 +2201,13 @@ input:checked+.slider:before {
font-size: 0.9rem; font-size: 0.9rem;
} }
.provider-base-url {
word-break: break-all;
overflow-wrap: anywhere;
white-space: normal;
display: block;
}
.provider-item .provider-models { .provider-item .provider-models {
margin-top: 8px; margin-top: 8px;
display: flex; display: flex;
@@ -2028,6 +2228,12 @@ input:checked+.slider:before {
font-size: 0.85rem; font-size: 0.85rem;
} }
.provider-item .excluded-models .provider-model-tag {
background: var(--warning-bg);
border-color: var(--warning-border);
color: var(--warning-text);
}
.provider-model-tag .model-name { .provider-model-tag .model-name {
font-weight: 600; font-weight: 600;
} }
@@ -2860,11 +3066,23 @@ input:checked+.slider:before {
/* 使用统计样式 */ /* 使用统计样式 */
.stats-overview { .stats-overview {
display: grid; display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 20px; gap: 20px;
margin-bottom: 30px; margin-bottom: 30px;
} }
@media (max-width: 1200px) {
.stats-overview {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 768px) {
.stats-overview {
grid-template-columns: 1fr;
}
}
.usage-filter-bar { .usage-filter-bar {
display: flex; display: flex;
justify-content: flex-start; justify-content: flex-start;
@@ -2958,6 +3176,20 @@ input:checked+.slider:before {
font-weight: 500; font-weight: 500;
} }
.stat-subtext {
font-size: 12px;
color: var(--text-tertiary);
line-height: 1.4;
}
.stat-subtext:first-of-type {
margin-top: 6px;
}
.cost-summary-card .stat-icon {
background: #f59e0b;
}
.charts-container { .charts-container {
display: grid; display: grid;
grid-template-columns: 1fr 1fr; grid-template-columns: 1fr 1fr;
@@ -3049,6 +3281,84 @@ input:checked+.slider:before {
font-size: 12px; font-size: 12px;
} }
.cost-config-card .card-content {
display: flex;
flex-direction: column;
gap: 12px;
}
.model-price-form {
display: flex;
flex-direction: column;
gap: 12px;
}
.price-input-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
}
@media (max-width: 768px) {
.price-input-grid {
grid-template-columns: 1fr;
}
}
.price-form-actions {
display: flex;
justify-content: flex-end;
}
.model-price-list {
margin-top: 6px;
}
.model-price-table {
display: flex;
flex-direction: column;
gap: 8px;
}
.model-price-header,
.model-price-row {
display: grid;
grid-template-columns: 2fr 1fr 1fr;
gap: 12px;
padding: 10px 12px;
border: 1px solid var(--border-color);
border-radius: 10px;
align-items: center;
}
.model-price-header {
font-weight: 700;
color: var(--text-primary);
background: var(--bg-secondary);
}
.model-price-row {
background: var(--bg-primary);
color: var(--text-secondary);
}
.chart-placeholder {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
text-align: center;
color: var(--text-tertiary);
background: var(--bg-primary);
border: 1px dashed var(--border-color);
border-radius: 12px;
}
.cost-chart-card {
grid-column: 1 / -1;
}
.model-item { .model-item {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;