feat: initialize new React application structure with TypeScript, ESLint, and Prettier configurations, while removing legacy files and adding new components and pages for enhanced functionality

This commit is contained in:
Supra4E8C
2025-12-07 11:32:31 +08:00
parent 8e4132200d
commit 450964fb1a
144 changed files with 14223 additions and 21647 deletions

20
.eslintrc.cjs Normal file
View File

@@ -0,0 +1,20 @@
module.exports = {
root: true,
env: { browser: true, es2020: true },
extends: [
'eslint:recommended',
'plugin:@typescript-eslint/recommended',
'plugin:react-hooks/recommended',
],
ignorePatterns: ['dist', '.eslintrc.cjs'],
parser: '@typescript-eslint/parser',
plugins: ['react-refresh'],
rules: {
'react-refresh/only-export-components': [
'warn',
{ allowConstantExport: true },
],
'@typescript-eslint/no-explicit-any': 'warn',
'@typescript-eslint/no-unused-vars': ['warn', { argsIgnorePattern: '^_' }],
},
};

46
.gitignore vendored
View File

@@ -1,28 +1,24 @@
# Node modules
node_modules/
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
# Build output
dist/
node_modules
dist
dist-ssr
*.local
# Temporary build files
index.build.html
# npm lock files
package-lock.json
# IDE and editor files
.vscode/
.idea/
*.swp
*.swo
*~
# OS files
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
Thumbs.db
CLAUDE.md
.claude
AGENTS.md
.codex
.serena
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

9
.prettierrc Normal file
View File

@@ -0,0 +1,9 @@
{
"semi": true,
"trailingComma": "es5",
"singleQuote": true,
"printWidth": 100,
"tabWidth": 2,
"useTabs": false,
"arrowParens": "always"
}

View File

@@ -1,69 +0,0 @@
# Build and Release Instructions
## Overview
This project uses webpack to bundle all HTML, CSS, JavaScript, and images into a single all-in-one HTML file. The GitHub workflow automatically builds and releases this file when you create a new tag.
## How to Create a Release
1. Make sure all your changes are committed
2. Create and push a new tag:
```bash
git tag v1.0.0
git push origin v1.0.0
```
3. The GitHub workflow will automatically:
- Install dependencies
- Build the all-in-one HTML file using webpack
- Create a new release with the tag
- Upload the bundled HTML file to the release
## Manual Build
To build locally:
```bash
# Install dependencies
npm install
# Build the all-in-one HTML file
npm run build
```
The output will be in the `dist/` directory as `index.html`.
## How It Works
1. **build-scripts/prepare-html.js**: Pre-build script
- Reads the original `index.html`
- Removes local CSS and JavaScript references
- Generates temporary `index.build.html` for webpack
2. **webpack.config.js**: Configures webpack to bundle all assets
- Uses `style-loader` to inline CSS
- Uses `asset/inline` to embed images as base64
- Uses `html-inline-script-webpack-plugin` to inline JavaScript
- Uses `index.build.html` as template (generated dynamically)
3. **bundle-entry.js**: Entry point that imports all resources
- Imports CSS files
- Imports JavaScript modules
- Imports and sets logo image
4. **package.json scripts**:
- `prebuild`: Automatically runs before build to generate `index.build.html`
- `build`: Runs webpack to bundle everything
- `postbuild`: Cleans up temporary `index.build.html` file
5. **.github/workflows/release.yml**: GitHub workflow
- Triggers on tag push
- Builds the project (prebuild → build → postbuild)
- Creates a release with the bundled HTML file
## External Dependencies
The bundled HTML file still relies on these CDN resources:
- Font Awesome (icons)
- Chart.js (charts and graphs)
These are loaded from CDN to keep the file size reasonable and leverage browser caching.

123
CLEAR_STORAGE.html Normal file
View File

@@ -0,0 +1,123 @@
<!DOCTYPE html>
<html>
<head>
<title>Clear LocalStorage</title>
<meta charset="UTF-8">
<style>
body {
font-family: Arial, sans-serif;
max-width: 600px;
margin: 50px auto;
padding: 20px;
background: #f5f5f5;
}
.container {
background: white;
padding: 30px;
border-radius: 8px;
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
}
h1 {
color: #333;
margin-top: 0;
}
button {
background: #dc2626;
color: white;
border: none;
padding: 12px 24px;
border-radius: 6px;
cursor: pointer;
font-size: 16px;
margin: 10px 10px 10px 0;
}
button:hover {
background: #b91c1c;
}
button.info {
background: #3b82f6;
}
button.info:hover {
background: #2563eb;
}
.message {
margin-top: 20px;
padding: 15px;
border-radius: 6px;
display: none;
}
.message.success {
background: #d1fae5;
color: #065f46;
display: block;
}
.message.info {
background: #dbeafe;
color: #1e40af;
display: block;
}
pre {
background: #f3f4f6;
padding: 15px;
border-radius: 6px;
overflow-x: auto;
}
</style>
</head>
<body>
<div class="container">
<h1>🧹 LocalStorage 清理工具</h1>
<p>用于清理旧版本的 CLI Proxy Web UI 遗留的 localStorage 数据</p>
<button onclick="showStorage()">📋 查看当前存储</button>
<button onclick="clearStorage()">🗑️ 清空 LocalStorage</button>
<div id="message" class="message"></div>
<div id="storageInfo" style="margin-top: 20px;"></div>
</div>
<script>
function showStorage() {
const info = document.getElementById('storageInfo');
const message = document.getElementById('message');
const items = {};
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i);
const value = localStorage.getItem(key);
items[key] = value.length > 100 ? value.substring(0, 100) + '...' : value;
}
if (Object.keys(items).length === 0) {
info.innerHTML = '<p style="color: #666;">LocalStorage 为空</p>';
} else {
info.innerHTML = '<h3>当前存储项:</h3><pre>' + JSON.stringify(items, null, 2) + '</pre>';
}
message.className = 'message info';
message.textContent = `共有 ${localStorage.length} 个存储项`;
}
function clearStorage() {
if (confirm('确定要清空所有 LocalStorage 数据吗?这将删除所有保存的登录信息。')) {
const count = localStorage.length;
localStorage.clear();
const message = document.getElementById('message');
message.className = 'message success';
message.textContent = `✅ 已成功清空 ${count} 个存储项`;
document.getElementById('storageInfo').innerHTML = '';
setTimeout(() => {
message.style.display = 'none';
}, 3000);
}
}
// 页面加载时自动显示
window.onload = showStorage;
</script>
</body>
</html>

21
LICENSE
View File

@@ -1,21 +0,0 @@
MIT License
Copyright (c) 2025 Supra4E8C
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

261
README.md
View File

@@ -1,107 +1,180 @@
# Cli-Proxy-API-Management-Center
This is the modern WebUI for managing the CLI Proxy API.
# CLI Proxy Web UI - React Version
[中文文档](README_CN.md)
CLI Proxy API Management Center 的 React + TypeScript 重构版本。
Main Project: https://github.com/router-for-me/CLIProxyAPI
Example URL: https://remote.router-for.me/
Minimum required version: ≥ 6.3.0 (recommended ≥ 6.5.0)
## ✨ 特性
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.
- 🎯 完全使用 TypeScript 编写,类型安全
- ⚛️ 基于 React 18 + Vite 构建,开发体验极佳
- 🎨 SCSS 模块化样式,支持亮色/暗色主题
- 🌍 完整的国际化支持 (中文/英文)
- 📦 单文件部署,无需构建服务器
- 🔒 安全的本地存储,支持数据加密
- 📱 响应式设计,支持移动端
## Features
## 🚀 快速开始
### Capabilities
- **Login & UX**: Auto-detects the current address (manual override/reset supported), encrypted auto-login, language/theme toggles, responsive layout with mobile sidebar.
- **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.
- **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.
- **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.
- **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).
- **Config Management**: In-browser CodeMirror YAML editor for `/config.yaml` with reload/save, syntax highlighting, and status feedback.
- **System Info & Versioning**: Connection/config cache status, last refresh time, server version/build date, and UI version in the footer.
- **Security & Preferences**: Masked secrets, secure local storage, persistent theme/language/sidebar state, real-time status feedback.
### 开发模式
## How to Use
1) **After CLI Proxy API is running (recommended)**
Visit `http://your-server:8317/management.html`.
2) **Direct static use after build**
The single file `dist/index.html` generated by `npm run build`
3) **Local server**
```bash
# 安装依赖
npm install
npm start # http://localhost:3000
npm run dev # optional dev port: 3090
# or
python -m http.server 8000
```
Then open the corresponding localhost URL.
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.
# 启动开发服务器
npm run dev
Tip: The Logs navigation item appears after enabling "Logging to file" in Basic Settings.
## Tech Stack
- **Frontend**: Plain HTML, CSS, JavaScript (ES6+)
- **Styling**: CSS3 + Flexbox/Grid with CSS Variables
- **Icons**: Font Awesome 6.4.0
- **Charts**: Chart.js for interactive data visualization
- **Editor/Parsing**: CodeMirror + js-yaml
- **Fonts**: Segoe UI system font
- **Internationalization**: Custom i18n (EN/CN) and theme system (light/dark)
- **API**: RESTful management endpoints with automatic authentication
- **Storage**: LocalStorage with lightweight encryption for preferences/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
### Connection Issues
1. Confirm that the CLI Proxy API service is running.
2. Check if the API address is correct.
3. Verify that the management key is valid.
4. Ensure your firewall settings allow the connection.
### Data Not Updating
1. Click the "Refresh All" button.
2. Check your network connection.
3. Check the browser's console for any error messages.
### 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.
### Usage Stats
- Enable "Usage statistics" if charts stay empty; data resets on server restart.
## Project Structure
```
├── index.html
├── styles.css
├── app.js
├── i18n.js
├── src/ # Core/modules/utils source code
├── build.cjs # Webpack build script
├── bundle-entry.js # Bundling entry
├── build-scripts/ # Build utilities
│ └── prepare-html.js
├── dist/ # Bundled single-file output
├── BUILD_RELEASE.md
├── LICENSE
├── README.md
└── README_CN.md
# 访问 http://localhost:5173
```
## Contributing
We welcome Issues and Pull Requests to improve this project! We encourage more developers to contribute to the enhancement of this WebUI!
### 生产构建
This project is licensed under the MIT License.
```bash
# 构建生产版本
npm run build
# 产物在 dist/index.html
# 直接双击打开或部署到服务器
```
### 代码检查
```bash
# TypeScript 类型检查
npm run type-check
# ESLint 代码检查
npm run lint
```
## 📁 项目结构
```
src/
├── components/ # 公共组件
│ ├── common/ # 基础组件 (Button, Input, Card, Modal...)
│ └── layout/ # 布局组件 (MainLayout, Sidebar, Header...)
├── pages/ # 页面组件
│ ├── LoginPage.tsx
│ ├── SettingsPage.tsx
│ ├── ApiKeysPage.tsx
│ ├── AiProvidersPage.tsx
│ ├── AuthFilesPage.tsx
│ ├── OAuthPage.tsx
│ ├── UsagePage.tsx
│ ├── ConfigPage.tsx
│ ├── LogsPage.tsx
│ └── SystemPage.tsx
├── services/ # API 服务
│ ├── api/ # API 客户端
│ └── storage/ # 本地存储服务
├── stores/ # Zustand 状态管理
│ ├── useAuthStore.ts
│ ├── useConfigStore.ts
│ ├── useThemeStore.ts
│ └── useLanguageStore.ts
├── hooks/ # 自定义 Hooks
├── types/ # TypeScript 类型定义
├── utils/ # 工具函数
├── i18n/ # 国际化配置
├── styles/ # 全局样式
└── router/ # 路由配置
```
## 🔧 技术栈
- **框架**: React 18
- **语言**: TypeScript 5
- **构建工具**: Vite 7
- **路由**: React Router 7 (Hash 模式)
- **状态管理**: Zustand 5
- **样式**: SCSS Modules
- **国际化**: i18next
- **HTTP 客户端**: Axios
- **代码检查**: ESLint + TypeScript ESLint
## 📝 使用说明
### 首次使用
1. **清理旧数据** (如果从旧版本升级)
- 打开 `CLEAR_STORAGE.html` 文件
- 点击"清空 LocalStorage"按钮
- 这将清理旧版本的存储数据
2. **打开应用**
- 双击 `dist/index.html` 文件
- 或使用 HTTP 服务器访问 (推荐)
3. **配置连接**
- 输入 CLI Proxy API 服务器地址
- 输入管理密钥
- 点击"连接"按钮
### 部署方式
#### 方式 1: 本地文件 (file:// 协议)
直接双击 `dist/index.html` 即可使用。应用已配置为使用 Hash 路由,支持 file:// 协议。
#### 方式 2: HTTP 服务器 (推荐)
```bash
# 使用 Python
cd dist
python -m http.server 8080
# 使用 Node.js (需要安装 serve)
npx serve dist
# 访问 http://localhost:8080
```
#### 方式 3: Nginx/Apache
`dist/index.html` 部署到 Web 服务器即可。
## 🐛 故障排除
### 白屏问题
如果打开后显示白屏:
1. 检查浏览器控制台是否有错误
2. 确认是否清理了旧版本的 localStorage 数据
3. 尝试使用 HTTP 服务器访问而不是 file:// 协议
### LocalStorage 错误
如果看到 "Failed to parse stored data" 错误:
1. 打开 `CLEAR_STORAGE.html`
2. 清空所有存储数据
3. 刷新页面重新登录
### 路由问题
应用使用 Hash 路由 (#/login, #/settings),确保 URL 中包含 `#` 符号。
## 📊 构建信息
- **TypeScript**: 0 errors ✅
- **ESLint**: 0 errors, 137 warnings ⚠️
- **Bundle Size**: 473 KB (144 KB gzipped)
- **Build Time**: ~5 seconds
## 🔄 从旧版本迁移
旧版本 (原生 JS) 的数据存储格式已变更。首次使用新版本时:
1. 旧的 localStorage 数据会自动迁移
2. 如果迁移失败,请手动清理 localStorage
3. 重新输入连接信息即可
## 📄 License
Same as CLI Proxy API
## 🤝 贡献
欢迎提交 Issue 和 Pull Request!
---
**注意**: 此版本是原 CLI Proxy Web UI 的 React 重构版本,与原版功能保持一致。

View File

@@ -1,106 +0,0 @@
# Cli-Proxy-API-Management-Center
这是一个用于管理 CLI Proxy API 的现代化 Web 界面。
[English](README.md)
主项目: https://github.com/router-for-me/CLIProxyAPI
示例网站: https://remote.router-for.me/
最低版本 ≥ 6.3.0(推荐 ≥ 6.5.0
自 6.0.19 起 WebUI 已集成到主程序中,启动后可通过 `/management.html` 访问。
## 功能特点
### 主要能力
- **登录与体验**: 自动检测当前地址(可自定义/重置),加密自动登录,语言/主题切换,响应式布局与移动端侧边栏。
- **基础设置**: 调试、代理 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 版本。
- **安全与偏好**: 密钥遮蔽、加密本地存储,主题/语言/侧边栏状态持久化,实时状态反馈。
## 使用方法
1) **主程序启动后使用(推荐)**
访问 `http://您的服务器:8317/management.html`
2) **构建后直接静态打开**
`npm run build` 生成的 `dist/index.html` 单文件
3) **本地服务器**
```bash
npm install
npm start # 默认 http://localhost:3000
npm run dev # 可选开发端口 3090
# 或
python -m http.server 8000
```
然后在浏览器打开对应的 localhost 地址。
4) **配置连接**
登录页会显示自动检测的地址,可自行修改,填入管理密钥后点击连接。凭据将加密保存以便下次自动登录。
提示: 开启“写入日志文件”后才会显示“日志查看”导航。
## 技术栈
- **前端**: 纯 HTML、CSS、JavaScript (ES6+)
- **样式**: CSS3 + Flexbox/Grid支持 CSS 变量
- **图标**: Font Awesome 6.4.0
- **图表**: Chart.js 交互式数据可视化
- **编辑/解析**: CodeMirror + js-yaml
- **国际化**: 自定义 i18n中/英)与主题系统(明/暗)
- **API**: RESTful 管理接口,自动附加认证
- **存储**: 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` 静态托管。
## 故障排除
### 连接问题
1. 确认 CLI Proxy API 服务正在运行
2. 检查 API 地址是否正确
3. 验证管理密钥是否有效
4. 确认防火墙设置允许连接
### 数据不更新
1. 点击"刷新全部"按钮
2. 检查网络连接
3. 查看浏览器控制台错误信息
### 日志与配置编辑
- 日志: 需要服务端开启写文件日志;返回 404 说明版本过旧或未启用。
- 配置编辑: 依赖 `/config.yaml` 接口,保存前请确保 YAML 语法正确。
### 使用统计
- 若图表为空,请开启“使用统计”;数据在服务重启后会清空。
## 项目结构
```
├── index.html
├── styles.css
├── app.js
├── i18n.js
├── src/ # 核心/模块/工具源码
├── build.cjs # Webpack 构建脚本
├── bundle-entry.js # 打包入口
├── build-scripts/ # 构建工具
│ └── prepare-html.js
├── dist/ # 打包输出单文件
├── BUILD_RELEASE.md
├── LICENSE
├── README.md
└── README_CN.md
```
## 贡献
欢迎提交 Issue 和 Pull Request 来改进这个项目!我们欢迎更多的大佬来对这个 WebUI 进行更新!
本项目采用 MIT 许可。

21
REFACTOR_PROGRESS.md Normal file
View File

@@ -0,0 +1,21 @@
# 重构进度追踪
## 阶段完成状态
- [x] 阶段 0: 准备工作
- [x] 阶段 1: 项目初始化
- [x] 阶段 2: 类型定义
- [x] 阶段 3: 工具与服务
- [x] 阶段 4: 状态管理
- [x] 阶段 5: 自定义 Hooks
- [x] 阶段 6: 国际化配置
- [x] 阶段 7: 样式系统
- [x] 阶段 8: 通用组件
- [x] 阶段 9: 页面组件API Keys / Providers / Auth Files / OAuth / Usage / Config / Logs / System
- [x] 阶段 10: 路由布局与构建验证
## 错误记录
<!-- 遇到任何错误都记录在这里 -->
## 待办事项
- [ ] 深入回归交互细节(模型价格、图表、日志增量等)与基线对齐
- [ ] 优化 Sass 导入与 darken 用法以消除警告

1065
app.js

File diff suppressed because it is too large Load Diff

View File

@@ -1,30 +0,0 @@
const fs = require('fs');
const path = require('path');
// Read the original index.html
const indexPath = path.resolve(__dirname, '../index.html');
const outputPath = path.resolve(__dirname, '../index.build.html');
let htmlContent = fs.readFileSync(indexPath, 'utf8');
// Remove local CSS reference
htmlContent = htmlContent.replace(
/<link rel="stylesheet" href="styles\.css">\n?/g,
''
);
// Remove local JavaScript references
htmlContent = htmlContent.replace(
/<script src="i18n\.js"><\/script>\n?/g,
''
);
htmlContent = htmlContent.replace(
/<script src="app\.js"><\/script>\n?/g,
''
);
// Write the modified HTML to a temporary build file
fs.writeFileSync(outputPath, htmlContent, 'utf8');
console.log('✓ Generated index.build.html for webpack processing');

225
build.cjs
View File

@@ -1,225 +0,0 @@
'use strict';
const fs = require('fs');
const path = require('path');
const projectRoot = __dirname;
const distDir = path.join(projectRoot, 'dist');
const sourceFiles = {
html: path.join(projectRoot, 'index.html'),
css: path.join(projectRoot, 'styles.css'),
i18n: path.join(projectRoot, 'i18n.js'),
app: path.join(projectRoot, 'app.js')
};
const logoCandidates = ['logo.png', 'logo.jpg', 'logo.jpeg', 'logo.svg', 'logo.webp', 'logo.gif'];
const logoMimeMap = {
'.png': 'image/png',
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.svg': 'image/svg+xml',
'.webp': 'image/webp',
'.gif': 'image/gif'
};
function readFile(filePath) {
try {
return fs.readFileSync(filePath, 'utf8');
} catch (err) {
console.error(`读取文件失败: ${filePath}`);
throw err;
}
}
function readBinary(filePath) {
try {
return fs.readFileSync(filePath);
} catch (err) {
console.error(`读取文件失败: ${filePath}`);
throw err;
}
}
function escapeForScript(content) {
return content.replace(/<\/(script)/gi, '<\\/$1');
}
function escapeForStyle(content) {
return content.replace(/<\/(style)/gi, '<\\/$1');
}
function getVersion() {
// 1. 优先从环境变量获取GitHub Actions 会设置)
if (process.env.VERSION) {
return process.env.VERSION;
}
// 2. 尝试从 git tag 获取
try {
const { execSync } = require('child_process');
const gitTag = execSync('git describe --tags --exact-match 2>/dev/null || git describe --tags 2>/dev/null || echo ""', { encoding: 'utf8' }).trim();
if (gitTag) {
return gitTag;
}
} catch (err) {
console.warn('无法从 git 获取版本号');
}
// 3. 回退到 package.json
try {
const packageJson = JSON.parse(fs.readFileSync(path.join(projectRoot, 'package.json'), 'utf8'));
return 'v' + packageJson.version;
} catch (err) {
console.warn('无法从 package.json 读取版本号');
}
// 4. 最后使用默认值
return 'v0.0.0-dev';
}
function ensureDistDir() {
if (fs.existsSync(distDir)) {
fs.rmSync(distDir, { recursive: true, force: true });
}
fs.mkdirSync(distDir);
}
// 匹配各种 import 语句
const importRegex = /import\s+(?:{[^}]*}|[\w*\s,{}]+)\s+from\s+['"]([^'"]+)['"];?/gm;
// 匹配 export 关键字(包括 export const, export function, export class, export async function 等)
const exportRegex = /^export\s+(?=const|let|var|function|class|default|async)/gm;
// 匹配单独的 export {} 或 export { ... } from '...'
const exportBraceRegex = /^export\s*{[^}]*}\s*(?:from\s+['"][^'"]+['"];?)?$/gm;
function bundleApp(entryPath) {
const visited = new Set();
const modules = [];
function inlineFile(filePath) {
let content = readFile(filePath);
const dir = path.dirname(filePath);
// 收集所有 import 语句
const imports = [];
content = content.replace(importRegex, (match, specifier) => {
const targetPath = path.resolve(dir, specifier);
const normalized = path.normalize(targetPath);
if (!fs.existsSync(normalized)) {
throw new Error(`无法解析模块: ${specifier} (from ${filePath})`);
}
if (!visited.has(normalized)) {
visited.add(normalized);
imports.push(normalized);
}
return ''; // 移除 import 语句
});
// 移除 export 关键字
content = content.replace(exportRegex, '');
content = content.replace(exportBraceRegex, '');
// 处理依赖的模块
for (const importPath of imports) {
const moduleContent = inlineFile(importPath);
const relativePath = path.relative(projectRoot, importPath);
modules.push(`\n// ============ ${relativePath} ============\n${moduleContent}\n`);
}
return content;
}
const mainContent = inlineFile(entryPath);
// 将所有模块内容组合在一起,模块在前,主文件在后
return modules.join('\n') + '\n// ============ Main ============\n' + mainContent;
}
function loadLogoDataUrl() {
for (const candidate of logoCandidates) {
const filePath = path.join(projectRoot, candidate);
if (!fs.existsSync(filePath)) continue;
const ext = path.extname(candidate).toLowerCase();
const mime = logoMimeMap[ext];
if (!mime) {
console.warn(`未知 Logo 文件类型,跳过内联: ${candidate}`);
continue;
}
const buffer = readBinary(filePath);
const base64 = buffer.toString('base64');
return `data:${mime};base64,${base64}`;
}
return null;
}
function build() {
ensureDistDir();
let html = readFile(sourceFiles.html);
const css = escapeForStyle(readFile(sourceFiles.css));
const i18n = escapeForScript(readFile(sourceFiles.i18n));
const bundledApp = bundleApp(sourceFiles.app);
const app = escapeForScript(bundledApp);
// 获取版本号并替换
const version = getVersion();
console.log(`使用版本号: ${version}`);
html = html.replace(/__VERSION__/g, version);
html = html.replace(
'<link rel="stylesheet" href="styles.css">',
() => `<style>
${css}
</style>`
);
html = html.replace(
'<script src="i18n.js"></script>',
() => `<script>
${i18n}
</script>`
);
const scriptTagRegex = /<script[^>]*src="app\.js"[^>]*><\/script>/i;
if (scriptTagRegex.test(html)) {
html = html.replace(
scriptTagRegex,
() => `<script>
${app}
</script>`
);
} else {
console.warn('未找到 app.js 脚本标签,未内联应用代码。');
}
const logoDataUrl = loadLogoDataUrl();
if (logoDataUrl) {
const logoScript = `<script>window.__INLINE_LOGO__ = "${logoDataUrl}";</script>`;
const closingBodyTag = '</body>';
const closingBodyIndex = html.lastIndexOf(closingBodyTag);
if (closingBodyIndex !== -1) {
html = `${html.slice(0, closingBodyIndex)}${logoScript}\n${closingBodyTag}${html.slice(closingBodyIndex + closingBodyTag.length)}`;
} else {
html += `\n${logoScript}`;
}
} else {
console.warn('未找到可内联的 Logo 文件,将保持运行时加载。');
}
const outputPath = path.join(distDir, 'index.html');
fs.writeFileSync(outputPath, html, 'utf8');
console.log('构建完成: dist/index.html');
}
try {
build();
} catch (error) {
console.error('构建失败:', error);
process.exit(1);
}

View File

@@ -1,25 +0,0 @@
// Import CSS
import './styles.css';
// Import JavaScript modules
import './i18n.js';
import './app.js';
// Import logo image
import logoImg from './logo.jpg';
// Set logo after DOM is loaded
document.addEventListener('DOMContentLoaded', () => {
const loginLogo = document.getElementById('login-logo');
const siteLogo = document.getElementById('site-logo');
if (loginLogo) {
loginLogo.src = logoImg;
loginLogo.style.display = 'block';
}
if (siteLogo) {
siteLogo.src = logoImg;
siteLogo.style.display = 'block';
}
});

30
eslint.config.js Normal file
View File

@@ -0,0 +1,30 @@
import js from '@eslint/js';
import globals from 'globals';
import reactHooks from 'eslint-plugin-react-hooks';
import reactRefresh from 'eslint-plugin-react-refresh';
import tseslint from 'typescript-eslint';
export default tseslint.config(
{ ignores: ['dist'] },
{
extends: [js.configs.recommended, ...tseslint.configs.recommended],
files: ['**/*.{ts,tsx}'],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
plugins: {
'react-hooks': reactHooks,
'react-refresh': reactRefresh,
},
rules: {
...reactHooks.configs.recommended.rules,
'react-refresh/only-export-components': [
'warn',
{ allowConstantExport: true },
],
'@typescript-eslint/no-explicit-any': 'warn',
'@typescript-eslint/no-unused-vars': ['warn', { argsIgnorePattern: '^_' }],
},
},
);

1441
i18n.js

File diff suppressed because it is too large Load Diff

1406
index.html

File diff suppressed because it is too large Load Diff

BIN
logo.jpg

Binary file not shown.

Before

Width:  |  Height:  |  Size: 53 KiB

5351
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,31 +1,45 @@
{
"name": "cli-proxy-api-webui",
"version": "1.0.0",
"description": "CLI Proxy API 管理界面",
"main": "index.html",
"type": "module",
"scripts": {
"start": "npx serve .",
"dev": "npx serve . -l 3090",
"build": "node build.cjs",
"lint": "echo '使用浏览器开发者工具检查代码'"
},
"keywords": [
"cli-proxy-api",
"webui",
"management",
"api"
],
"author": "CLI Proxy API WebUI",
"license": "MIT",
"devDependencies": {
"serve": "^14.2.1"
},
"engines": {
"node": ">=14.0.0"
},
"repository": {
"type": "git",
"url": "local"
}
}
{
"name": "cli-proxy-webui-react",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview",
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
"format": "prettier --write \"src/**/*.{ts,tsx,css,scss}\"",
"type-check": "tsc --noEmit"
},
"dependencies": {
"@codemirror/lang-yaml": "^6.1.2",
"@uiw/react-codemirror": "^4.25.3",
"axios": "^1.13.2",
"chart.js": "^4.5.1",
"i18next": "^25.7.1",
"react": "^19.2.1",
"react-chartjs-2": "^5.3.1",
"react-dom": "^19.2.1",
"react-i18next": "^16.4.0",
"react-router-dom": "^7.10.1",
"zustand": "^5.0.9"
},
"devDependencies": {
"@eslint/js": "^9.39.1",
"@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3",
"@typescript-eslint/eslint-plugin": "^8.48.1",
"@typescript-eslint/parser": "^8.48.1",
"@vitejs/plugin-react": "^5.1.1",
"eslint": "^9.39.1",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.24",
"globals": "^16.5.0",
"prettier": "^3.7.4",
"sass": "^1.94.2",
"typescript": "^5.9.3",
"typescript-eslint": "^8.48.1",
"vite": "^7.2.6",
"vite-plugin-singlefile": "^2.3.0"
}
}

1
public/vite.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

42
src/App.css Normal file
View File

@@ -0,0 +1,42 @@
#root {
max-width: 1280px;
margin: 0 auto;
padding: 2rem;
text-align: center;
}
.logo {
height: 6em;
padding: 1.5em;
will-change: filter;
transition: filter 300ms;
}
.logo:hover {
filter: drop-shadow(0 0 2em #646cffaa);
}
.logo.react:hover {
filter: drop-shadow(0 0 2em #61dafbaa);
}
@keyframes logo-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
@media (prefers-reduced-motion: no-preference) {
a:nth-of-type(2) .logo {
animation: logo-spin infinite 20s linear;
}
}
.card {
padding: 2em;
}
.read-the-docs {
color: #888;
}

64
src/App.tsx Normal file
View File

@@ -0,0 +1,64 @@
import { useEffect } from 'react';
import { HashRouter, Navigate, Route, Routes } from 'react-router-dom';
import { LoginPage } from '@/pages/LoginPage';
import { SettingsPage } from '@/pages/SettingsPage';
import { ApiKeysPage } from '@/pages/ApiKeysPage';
import { AiProvidersPage } from '@/pages/AiProvidersPage';
import { AuthFilesPage } from '@/pages/AuthFilesPage';
import { OAuthPage } from '@/pages/OAuthPage';
import { UsagePage } from '@/pages/UsagePage';
import { ConfigPage } from '@/pages/ConfigPage';
import { LogsPage } from '@/pages/LogsPage';
import { SystemPage } from '@/pages/SystemPage';
import { NotificationContainer } from '@/components/common/NotificationContainer';
import { MainLayout } from '@/components/layout/MainLayout';
import { ProtectedRoute } from '@/router/ProtectedRoute';
import { useAuthStore, useLanguageStore, useThemeStore } from '@/stores';
function App() {
const initializeTheme = useThemeStore((state) => state.initializeTheme);
const language = useLanguageStore((state) => state.language);
const setLanguage = useLanguageStore((state) => state.setLanguage);
const restoreSession = useAuthStore((state) => state.restoreSession);
useEffect(() => {
initializeTheme();
restoreSession();
}, [initializeTheme, restoreSession]);
useEffect(() => {
setLanguage(language);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []); // 仅用于首屏同步 i18n 语言
return (
<HashRouter>
<NotificationContainer />
<Routes>
<Route path="/login" element={<LoginPage />} />
<Route
path="/"
element={
<ProtectedRoute>
<MainLayout />
</ProtectedRoute>
}
>
<Route index element={<Navigate to="/settings" replace />} />
<Route path="settings" element={<SettingsPage />} />
<Route path="api-keys" element={<ApiKeysPage />} />
<Route path="ai-providers" element={<AiProvidersPage />} />
<Route path="auth-files" element={<AuthFilesPage />} />
<Route path="oauth" element={<OAuthPage />} />
<Route path="usage" element={<UsagePage />} />
<Route path="config" element={<ConfigPage />} />
<Route path="logs" element={<LogsPage />} />
<Route path="system" element={<SystemPage />} />
<Route path="*" element={<Navigate to="/settings" replace />} />
</Route>
</Routes>
</HashRouter>
);
}
export default App;

1
src/assets/react.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

View File

@@ -0,0 +1,20 @@
import { useNotificationStore } from '@/stores';
export function NotificationContainer() {
const { notifications, removeNotification } = useNotificationStore();
if (!notifications.length) return null;
return (
<div className="notification-container">
{notifications.map((notification) => (
<div key={notification.id} className={`notification ${notification.type}`}>
<div className="message">{notification.message}</div>
<button className="close-btn" onClick={() => removeNotification(notification.id)}>
×
</button>
</div>
))}
</div>
);
}

View File

@@ -0,0 +1,225 @@
import { useEffect, useMemo, useState } from 'react';
import { NavLink, Outlet, useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { Button } from '@/components/ui/Button';
import { useAuthStore, useConfigStore, useLanguageStore, useNotificationStore, useThemeStore } from '@/stores';
import { versionApi } from '@/services/api';
import { isLocalhost } from '@/utils/connection';
const parseVersionSegments = (version?: string | null) => {
if (!version) return null;
const cleaned = version.trim().replace(/^v/i, '');
if (!cleaned) return null;
const parts = cleaned
.split(/[^0-9]+/)
.filter(Boolean)
.map((segment) => Number.parseInt(segment, 10))
.filter(Number.isFinite);
return parts.length ? parts : null;
};
const compareVersions = (latest?: string | null, current?: string | null) => {
const latestParts = parseVersionSegments(latest);
const currentParts = parseVersionSegments(current);
if (!latestParts || !currentParts) return null;
const length = Math.max(latestParts.length, currentParts.length);
for (let i = 0; i < length; i++) {
const l = latestParts[i] || 0;
const c = currentParts[i] || 0;
if (l > c) return 1;
if (l < c) return -1;
}
return 0;
};
export function MainLayout() {
const { t, i18n } = useTranslation();
const navigate = useNavigate();
const { showNotification } = useNotificationStore();
const apiBase = useAuthStore((state) => state.apiBase);
const serverVersion = useAuthStore((state) => state.serverVersion);
const serverBuildDate = useAuthStore((state) => state.serverBuildDate);
const connectionStatus = useAuthStore((state) => state.connectionStatus);
const checkAuth = useAuthStore((state) => state.checkAuth);
const logout = useAuthStore((state) => state.logout);
const config = useConfigStore((state) => state.config);
const fetchConfig = useConfigStore((state) => state.fetchConfig);
const clearCache = useConfigStore((state) => state.clearCache);
const theme = useThemeStore((state) => state.theme);
const toggleTheme = useThemeStore((state) => state.toggleTheme);
const language = useLanguageStore((state) => state.language);
const toggleLanguage = useLanguageStore((state) => state.toggleLanguage);
const [sidebarOpen, setSidebarOpen] = useState(false);
const [checkingConnection, setCheckingConnection] = useState(false);
const [checkingVersion, setCheckingVersion] = useState(false);
const isLocal = useMemo(() => isLocalhost(window.location.hostname), []);
useEffect(() => {
fetchConfig().catch(() => {
// ignore initial failure; login flow会提示
});
}, [fetchConfig]);
const statusClass =
connectionStatus === 'connected'
? 'success'
: connectionStatus === 'connecting'
? 'warning'
: connectionStatus === 'error'
? 'error'
: 'muted';
const navItems = [
{ path: '/settings', label: t('nav.basic_settings') },
{ path: '/api-keys', label: t('nav.api_keys') },
{ path: '/ai-providers', label: t('nav.ai_providers') },
{ path: '/auth-files', label: t('nav.auth_files') },
...(isLocal ? [{ path: '/oauth', label: t('nav.oauth', { defaultValue: 'OAuth' }) }] : []),
{ path: '/usage', label: t('nav.usage_stats') },
{ path: '/config', label: t('nav.config_management') },
...(config?.loggingToFile ? [{ path: '/logs', label: t('nav.logs') }] : []),
{ path: '/system', label: t('nav.system_info') }
];
const handleRefreshAll = async () => {
clearCache();
try {
await fetchConfig(undefined, true);
showNotification(t('notification.data_refreshed'), 'success');
} catch (error: any) {
showNotification(`${t('notification.refresh_failed')}: ${error?.message || ''}`, 'error');
}
};
const handleCheckConnection = async () => {
setCheckingConnection(true);
try {
const ok = await checkAuth();
if (ok) {
showNotification(t('common.connected_status'), 'success');
} else {
showNotification(t('common.disconnected_status'), 'warning');
}
} catch (error: any) {
showNotification(`${t('notification.login_failed')}: ${error?.message || ''}`, 'error');
} finally {
setCheckingConnection(false);
}
};
const handleVersionCheck = async () => {
setCheckingVersion(true);
try {
const data = await versionApi.checkLatest();
const latest = data?.['latest-version'] ?? data?.latest_version ?? data?.latest ?? '';
const comparison = compareVersions(latest, serverVersion);
if (!latest) {
showNotification(t('system_info.version_check_error'), 'error');
return;
}
if (comparison === null) {
showNotification(t('system_info.version_current_missing'), 'warning');
return;
}
if (comparison > 0) {
showNotification(t('system_info.version_update_available', { version: latest }), 'warning');
} else {
showNotification(t('system_info.version_is_latest'), 'success');
}
} catch (error: any) {
showNotification(`${t('system_info.version_check_error')}: ${error?.message || ''}`, 'error');
} finally {
setCheckingVersion(false);
}
};
const handleLogout = () => {
logout();
navigate('/login', { replace: true });
};
return (
<div className="app-shell">
<aside className={`sidebar ${sidebarOpen ? 'open' : ''}`}>
<div className="brand">{t('title.abbr')}</div>
<div className="nav-section">
{navItems.map((item) => (
<NavLink
key={item.path}
to={item.path}
className={({ isActive }) => `nav-item ${isActive ? 'active' : ''}`}
onClick={() => setSidebarOpen(false)}
>
{item.label}
</NavLink>
))}
</div>
</aside>
<div className="content">
<header className="main-header">
<div className="left">
<Button variant="ghost" size="sm" onClick={() => setSidebarOpen((prev) => !prev)}>
</Button>
<div className="connection">
<span className={`status-badge ${statusClass}`}>
{t(
connectionStatus === 'connected'
? 'common.connected_status'
: connectionStatus === 'connecting'
? 'common.connecting_status'
: 'common.disconnected_status'
)}
</span>
<span className="base">{apiBase || '-'}</span>
</div>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<Button variant="secondary" size="sm" onClick={handleCheckConnection} loading={checkingConnection}>
{t('header.check_connection')}
</Button>
<Button variant="secondary" size="sm" onClick={handleRefreshAll}>
{t('header.refresh_all')}
</Button>
<Button variant="ghost" size="sm" onClick={handleVersionCheck} loading={checkingVersion}>
{t('system_info.version_check_button')}
</Button>
<Button variant="ghost" size="sm" onClick={toggleLanguage}>
{t('language.switch')}: {language === 'zh-CN' ? t('language.chinese') : t('language.english')}
</Button>
<Button variant="ghost" size="sm" onClick={toggleTheme}>
{t('theme.switch')}: {theme === 'dark' ? t('theme.dark') : t('theme.light')}
</Button>
<Button variant="danger" size="sm" onClick={handleLogout}>
{t('header.logout')}
</Button>
</div>
</header>
<main className="main-content">
<Outlet />
</main>
<footer className="footer">
<span>
{t('footer.api_version')}: {serverVersion || t('system_info.version_unknown')}
</span>
<span>
{t('footer.build_date')}:{' '}
{serverBuildDate ? new Date(serverBuildDate).toLocaleString(i18n.language) : t('system_info.version_unknown')}
</span>
</footer>
</div>
</div>
);
}

View File

@@ -0,0 +1,39 @@
import type { ButtonHTMLAttributes, PropsWithChildren } from 'react';
type ButtonVariant = 'primary' | 'secondary' | 'ghost' | 'danger';
type ButtonSize = 'md' | 'sm';
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
variant?: ButtonVariant;
size?: ButtonSize;
fullWidth?: boolean;
loading?: boolean;
}
export function Button({
children,
variant = 'primary',
size = 'md',
fullWidth = false,
loading = false,
className = '',
disabled,
...rest
}: PropsWithChildren<ButtonProps>) {
const classes = [
'btn',
`btn-${variant}`,
size === 'sm' ? 'btn-sm' : '',
fullWidth ? 'btn-full' : '',
className
]
.filter(Boolean)
.join(' ');
return (
<button className={classes} disabled={disabled || loading} {...rest}>
{loading && <span className="loading-spinner" aria-hidden="true" />}
<span>{children}</span>
</button>
);
}

View File

@@ -0,0 +1,20 @@
import type { PropsWithChildren, ReactNode } from 'react';
interface CardProps {
title?: ReactNode;
extra?: ReactNode;
}
export function Card({ title, extra, children }: PropsWithChildren<CardProps>) {
return (
<div className="card">
{(title || extra) && (
<div className="card-header">
<div className="title">{title}</div>
{extra}
</div>
)}
{children}
</div>
);
}

View File

@@ -0,0 +1,24 @@
import type { ReactNode } from 'react';
interface EmptyStateProps {
title: string;
description?: string;
action?: ReactNode;
}
export function EmptyState({ title, description, action }: EmptyStateProps) {
return (
<div className="empty-state">
<div className="empty-content">
<div className="empty-icon" aria-hidden="true">
</div>
<div>
<div className="empty-title">{title}</div>
{description && <div className="empty-desc">{description}</div>}
</div>
</div>
{action && <div className="empty-action">{action}</div>}
</div>
);
}

View File

@@ -0,0 +1,74 @@
import { Fragment } from 'react';
import { Button } from './Button';
import type { HeaderEntry } from '@/utils/headers';
interface HeaderInputListProps {
entries: HeaderEntry[];
onChange: (entries: HeaderEntry[]) => void;
addLabel: string;
disabled?: boolean;
keyPlaceholder?: string;
valuePlaceholder?: string;
}
export function HeaderInputList({
entries,
onChange,
addLabel,
disabled = false,
keyPlaceholder = 'X-Custom-Header',
valuePlaceholder = 'value'
}: HeaderInputListProps) {
const currentEntries = entries.length ? entries : [{ key: '', value: '' }];
const updateEntry = (index: number, field: 'key' | 'value', value: string) => {
const next = currentEntries.map((entry, idx) => (idx === index ? { ...entry, [field]: value } : entry));
onChange(next);
};
const addEntry = () => {
onChange([...currentEntries, { key: '', value: '' }]);
};
const removeEntry = (index: number) => {
const next = currentEntries.filter((_, idx) => idx !== index);
onChange(next.length ? next : [{ key: '', value: '' }]);
};
return (
<div className="header-input-list">
{currentEntries.map((entry, index) => (
<Fragment key={index}>
<div className="header-input-row">
<input
className="input"
placeholder={keyPlaceholder}
value={entry.key}
onChange={(e) => updateEntry(index, 'key', e.target.value)}
disabled={disabled}
/>
<span className="header-separator">:</span>
<input
className="input"
placeholder={valuePlaceholder}
value={entry.value}
onChange={(e) => updateEntry(index, 'value', e.target.value)}
disabled={disabled}
/>
<Button
variant="ghost"
size="sm"
onClick={() => removeEntry(index)}
disabled={disabled || currentEntries.length <= 1}
>
</Button>
</div>
</Fragment>
))}
<Button variant="secondary" size="sm" onClick={addEntry} disabled={disabled} className="align-start">
{addLabel}
</Button>
</div>
);
}

View File

@@ -0,0 +1,26 @@
import type { InputHTMLAttributes, ReactNode } from 'react';
interface InputProps extends InputHTMLAttributes<HTMLInputElement> {
label?: string;
hint?: string;
error?: string;
rightElement?: ReactNode;
}
export function Input({ label, hint, error, rightElement, className = '', ...rest }: InputProps) {
return (
<div className="form-group">
{label && <label>{label}</label>}
<div style={{ position: 'relative' }}>
<input className={`input ${className}`.trim()} {...rest} />
{rightElement && (
<div style={{ position: 'absolute', right: 8, top: '50%', transform: 'translateY(-50%)' }}>
{rightElement}
</div>
)}
</div>
{hint && <div className="hint">{hint}</div>}
{error && <div className="error-box">{error}</div>}
</div>
);
}

View File

@@ -0,0 +1,10 @@
export function LoadingSpinner({ size = 20 }: { size?: number }) {
return (
<div
className="loading-spinner"
style={{ width: size, height: size, borderWidth: size / 7 }}
role="status"
aria-live="polite"
/>
);
}

View File

@@ -0,0 +1,34 @@
import type { PropsWithChildren, ReactNode } from 'react';
interface ModalProps {
open: boolean;
title?: ReactNode;
onClose: () => void;
footer?: ReactNode;
width?: number | string;
}
export function Modal({ open, title, onClose, footer, width = 520, children }: PropsWithChildren<ModalProps>) {
if (!open) return null;
const handleMaskClick = (event: React.MouseEvent<HTMLDivElement>) => {
if (event.target === event.currentTarget) {
onClose();
}
};
return (
<div className="modal-overlay" onClick={handleMaskClick}>
<div className="modal" style={{ width }} role="dialog" aria-modal="true">
<div className="modal-header">
<div className="modal-title">{title}</div>
<button className="modal-close" onClick={onClose} aria-label="Close">
×
</button>
</div>
<div className="modal-body">{children}</div>
{footer && <div className="modal-footer">{footer}</div>}
</div>
</div>
);
}

View File

@@ -0,0 +1,24 @@
import type { ChangeEvent } from 'react';
interface ToggleSwitchProps {
checked: boolean;
onChange: (value: boolean) => void;
label?: string;
disabled?: boolean;
}
export function ToggleSwitch({ checked, onChange, label, disabled = false }: ToggleSwitchProps) {
const handleChange = (event: ChangeEvent<HTMLInputElement>) => {
onChange(event.target.checked);
};
return (
<label className="switch">
<input type="checkbox" checked={checked} onChange={handleChange} disabled={disabled} />
<span className="track">
<span className="thumb" />
</span>
{label && <span className="label">{label}</span>}
</label>
);
}

View File

@@ -1,87 +0,0 @@
// API 客户端:负责规范化基础地址、构造完整 URL、发送请求并回传版本信息
export class ApiClient {
constructor({ apiBase = '', managementKey = '', onVersionUpdate = null } = {}) {
this.apiBase = '';
this.apiUrl = '';
this.managementKey = managementKey || '';
this.onVersionUpdate = onVersionUpdate;
this.setApiBase(apiBase);
}
buildHeaders(options = {}) {
const customHeaders = options.headers || {};
const headers = {
'Authorization': `Bearer ${this.managementKey}`,
...customHeaders
};
const hasContentType = Object.keys(headers).some(key => key.toLowerCase() === 'content-type');
const body = options.body;
const isFormData = typeof FormData !== 'undefined' && body instanceof FormData;
if (!hasContentType && !isFormData) {
headers['Content-Type'] = 'application/json';
}
return headers;
}
normalizeBase(input) {
let base = (input || '').trim();
if (!base) return '';
base = base.replace(/\/?v0\/management\/?$/i, '');
base = base.replace(/\/+$/i, '');
if (!/^https?:\/\//i.test(base)) {
base = 'http://' + base;
}
return base;
}
computeApiUrl(base) {
const normalized = this.normalizeBase(base);
if (!normalized) return '';
return normalized.replace(/\/$/, '') + '/v0/management';
}
setApiBase(newBase) {
this.apiBase = this.normalizeBase(newBase);
this.apiUrl = this.computeApiUrl(this.apiBase);
return this.apiUrl;
}
setManagementKey(key) {
this.managementKey = key || '';
}
async request(endpoint, options = {}) {
const url = `${this.apiUrl}${endpoint}`;
const headers = this.buildHeaders(options);
const response = await fetch(url, {
...options,
headers
});
if (typeof this.onVersionUpdate === 'function') {
this.onVersionUpdate(response.headers);
}
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.error || `HTTP ${response.status}`);
}
return await response.json();
}
// 返回原始 Response供下载/自定义解析使用
async requestRaw(endpoint, options = {}) {
const url = `${this.apiUrl}${endpoint}`;
const headers = this.buildHeaders(options);
const response = await fetch(url, {
...options,
headers
});
if (typeof this.onVersionUpdate === 'function') {
this.onVersionUpdate(response.headers);
}
return response;
}
}

View File

@@ -1,70 +0,0 @@
// 配置缓存服务:负责分段/全量读取配置与缓存控制,不涉及任何 DOM
export class ConfigService {
constructor({ apiClient, cacheExpiry }) {
this.apiClient = apiClient;
this.cacheExpiry = cacheExpiry;
this.cache = {};
this.cacheTimestamps = {};
}
isCacheValid(section = null) {
if (section) {
if (!(section in this.cache) || !(section in this.cacheTimestamps)) {
return false;
}
return (Date.now() - this.cacheTimestamps[section]) < this.cacheExpiry;
}
if (!this.cache['__full__'] || !this.cacheTimestamps['__full__']) {
return false;
}
return (Date.now() - this.cacheTimestamps['__full__']) < this.cacheExpiry;
}
clearCache(section = null) {
if (section) {
delete this.cache[section];
delete this.cacheTimestamps[section];
if (this.cache['__full__']) {
delete this.cache['__full__'][section];
}
return;
}
Object.keys(this.cache).forEach(key => delete this.cache[key]);
Object.keys(this.cacheTimestamps).forEach(key => delete this.cacheTimestamps[key]);
}
async getConfig(section = null, forceRefresh = false) {
const now = Date.now();
if (section && !forceRefresh && this.isCacheValid(section)) {
return this.cache[section];
}
if (!section && !forceRefresh && this.isCacheValid()) {
return this.cache['__full__'];
}
const config = await this.apiClient.request('/config');
if (section) {
this.cache[section] = config[section];
this.cacheTimestamps[section] = now;
if (this.cache['__full__']) {
this.cache['__full__'][section] = config[section];
} else {
this.cache['__full__'] = config;
this.cacheTimestamps['__full__'] = now;
}
return config[section];
}
this.cache['__full__'] = config;
this.cacheTimestamps['__full__'] = now;
Object.keys(config).forEach(key => {
this.cache[key] = config[key];
this.cacheTimestamps[key] = now;
});
return config;
}
}

View File

@@ -1,746 +0,0 @@
// 连接与配置缓存核心模块
// 提供 API 基础地址规范化、请求封装、配置缓存以及统一数据加载能力
import { STATUS_UPDATE_INTERVAL_MS, DEFAULT_API_PORT } from '../utils/constants.js';
import { secureStorage } from '../utils/secure-storage.js';
import { normalizeModelList, classifyModels } from '../utils/models.js';
const buildModelsEndpoint = (baseUrl) => {
if (!baseUrl) return '';
const trimmed = String(baseUrl).trim().replace(/\/+$/g, '');
if (!trimmed) return '';
return trimmed.endsWith('/v1') ? `${trimmed}/models` : `${trimmed}/v1/models`;
};
const normalizeApiKeyList = (input) => {
if (!Array.isArray(input)) return [];
const seen = new Set();
const keys = [];
input.forEach(item => {
const value = typeof item === 'string'
? item
: (item && item['api-key'] ? item['api-key'] : '');
const trimmed = String(value || '').trim();
if (!trimmed || seen.has(trimmed)) {
return;
}
seen.add(trimmed);
keys.push(trimmed);
});
return keys;
};
export const connectionModule = {
// 规范化基础地址,移除尾部斜杠与 /v0/management
normalizeBase(input) {
return this.apiClient.normalizeBase(input);
},
// 由基础地址生成完整管理 API 地址
computeApiUrl(base) {
return this.apiClient.computeApiUrl(base);
},
setApiBase(newBase) {
this.apiClient.setApiBase(newBase);
this.apiBase = this.apiClient.apiBase;
this.apiUrl = this.apiClient.apiUrl;
secureStorage.setItem('apiBase', this.apiBase);
secureStorage.setItem('apiUrl', this.apiUrl); // 兼容旧字段
this.updateLoginConnectionInfo();
},
setManagementKey(key, { persist = true } = {}) {
this.managementKey = key || '';
this.apiClient.setManagementKey(this.managementKey);
if (persist) {
secureStorage.setItem('managementKey', this.managementKey);
}
},
// 加载设置(简化版,仅加载内部状态)
loadSettings() {
secureStorage.migratePlaintextKeys(['apiBase', 'apiUrl', 'managementKey']);
const savedBase = secureStorage.getItem('apiBase');
const savedUrl = secureStorage.getItem('apiUrl');
const savedKey = secureStorage.getItem('managementKey');
if (savedBase) {
this.setApiBase(savedBase);
} else if (savedUrl) {
const base = (savedUrl || '').replace(/\/?v0\/management\/?$/i, '');
this.setApiBase(base);
} else {
this.setApiBase(this.detectApiBaseFromLocation());
}
this.setManagementKey(savedKey || '', { persist: false });
this.updateLoginConnectionInfo();
},
// 读取并填充管理中心版本号(可能来自构建时注入或占位符)
initUiVersion() {
const uiVersion = this.readUiVersionFromDom();
this.uiVersion = uiVersion;
this.renderVersionInfo();
},
// 从 DOM 获取版本占位符,并处理空值、引号或未替换的占位符
readUiVersionFromDom() {
const el = document.getElementById('ui-version');
if (!el) return null;
const raw = (el.dataset && el.dataset.uiVersion) ? el.dataset.uiVersion : el.textContent;
if (typeof raw !== 'string') return null;
const cleaned = raw.replace(/^"+|"+$/g, '').trim();
if (!cleaned || cleaned === '__VERSION__') {
return null;
}
return cleaned;
},
// 根据响应头更新版本与构建时间
updateVersionFromHeaders(headers) {
if (!headers || typeof headers.get !== 'function') {
return;
}
const version = headers.get('X-CPA-VERSION');
const buildDate = headers.get('X-CPA-BUILD-DATE');
let updated = false;
if (version && version !== this.serverVersion) {
this.serverVersion = version;
updated = true;
}
if (buildDate && buildDate !== this.serverBuildDate) {
this.serverBuildDate = buildDate;
updated = true;
}
if (updated) {
this.renderVersionInfo();
}
},
renderVersionCheckStatus({
currentVersion,
latestVersion,
message,
status
} = {}) {
const resolvedCurrent = (typeof currentVersion === 'undefined' || currentVersion === null)
? this.serverVersion
: currentVersion;
const resolvedLatest = (typeof latestVersion === 'undefined' || latestVersion === null)
? this.latestVersion
: latestVersion;
const resolvedMessage = (typeof message === 'undefined' || message === null)
? (this.versionCheckMessage || i18n.t('system_info.version_check_idle'))
: message;
const resolvedStatus = status || this.versionCheckStatus || 'muted';
this.latestVersion = resolvedLatest || null;
this.versionCheckMessage = resolvedMessage;
this.versionCheckStatus = resolvedStatus;
const currentEl = document.getElementById('version-check-current');
if (currentEl) {
currentEl.textContent = resolvedCurrent || i18n.t('system_info.version_unknown');
}
const latestEl = document.getElementById('version-check-latest');
if (latestEl) {
latestEl.textContent = resolvedLatest || '-';
}
const resultEl = document.getElementById('version-check-result');
if (resultEl) {
resultEl.textContent = resolvedMessage;
resultEl.className = `version-check-result ${resolvedStatus}`.trim();
}
},
resetVersionCheckStatus() {
this.latestVersion = null;
this.versionCheckMessage = i18n.t('system_info.version_check_idle');
this.versionCheckStatus = 'muted';
this.renderVersionCheckStatus({
currentVersion: this.serverVersion,
latestVersion: this.latestVersion,
message: this.versionCheckMessage,
status: this.versionCheckStatus
});
},
// 渲染底栏的版本与构建时间
renderVersionInfo() {
const versionEl = document.getElementById('api-version');
const buildDateEl = document.getElementById('api-build-date');
const uiVersionEl = document.getElementById('ui-version');
if (versionEl) {
versionEl.textContent = this.serverVersion || '-';
}
if (buildDateEl) {
buildDateEl.textContent = this.serverBuildDate
? this.formatBuildDate(this.serverBuildDate)
: '-';
}
if (uiVersionEl) {
const domVersion = this.readUiVersionFromDom();
uiVersionEl.textContent = this.uiVersion || domVersion || 'v0.0.0-dev';
}
this.renderVersionCheckStatus({
currentVersion: this.serverVersion,
latestVersion: this.latestVersion,
message: this.versionCheckMessage,
status: this.versionCheckStatus
});
},
// 清空版本信息(例如登出时)
resetVersionInfo() {
this.serverVersion = null;
this.serverBuildDate = null;
this.resetVersionCheckStatus();
this.renderVersionInfo();
},
// 格式化构建时间,优先使用界面语言对应的本地格式
formatBuildDate(buildDate) {
if (!buildDate) return '-';
const parsed = Date.parse(buildDate);
if (!Number.isNaN(parsed)) {
const locale = i18n?.currentLanguage || undefined;
return new Date(parsed).toLocaleString(locale);
}
return buildDate;
},
parseVersionSegments(version) {
if (!version || typeof version !== 'string') return null;
const cleaned = version.trim().replace(/^v/i, '');
if (!cleaned) return null;
const parts = cleaned.split(/[^0-9]+/).filter(Boolean).map(segment => {
const parsed = parseInt(segment, 10);
return Number.isFinite(parsed) ? parsed : 0;
});
return parts.length ? parts : null;
},
compareVersions(latestVersion, currentVersion) {
const latestParts = this.parseVersionSegments(latestVersion);
const currentParts = this.parseVersionSegments(currentVersion);
if (!latestParts || !currentParts) {
return null;
}
const length = Math.max(latestParts.length, currentParts.length);
for (let i = 0; i < length; i++) {
const latest = latestParts[i] || 0;
const current = currentParts[i] || 0;
if (latest > current) return 1;
if (latest < current) return -1;
}
return 0;
},
async checkLatestVersion() {
if (!this.isConnected) {
const message = i18n.t('notification.connection_required');
this.renderVersionCheckStatus({
currentVersion: this.serverVersion,
latestVersion: this.latestVersion,
message,
status: 'warning'
});
this.showNotification(message, 'error');
return;
}
const button = document.getElementById('version-check-btn');
const originalLabel = button ? button.innerHTML : '';
if (button) {
button.disabled = true;
button.innerHTML = `<div class="loading"></div> ${i18n.t('system_info.version_checking')}`;
}
this.renderVersionCheckStatus({
currentVersion: this.serverVersion,
latestVersion: this.latestVersion,
message: i18n.t('system_info.version_checking'),
status: 'info'
});
try {
const data = await this.makeRequest('/latest-version');
const latestVersion = data?.['latest-version'] || data?.latest_version || '';
const latestParts = this.parseVersionSegments(latestVersion);
const currentParts = this.parseVersionSegments(this.serverVersion);
const comparison = (latestParts && currentParts)
? this.compareVersions(latestVersion, this.serverVersion)
: null;
let messageKey = 'system_info.version_check_error';
let statusClass = 'error';
if (!latestParts) {
messageKey = 'system_info.version_check_error';
} else if (!currentParts) {
messageKey = 'system_info.version_current_missing';
statusClass = 'warning';
} else if (comparison > 0) {
messageKey = 'system_info.version_update_available';
statusClass = 'warning';
} else {
messageKey = 'system_info.version_is_latest';
statusClass = 'success';
}
const message = i18n.t(messageKey, latestVersion ? { version: latestVersion } : undefined);
this.renderVersionCheckStatus({
currentVersion: this.serverVersion,
latestVersion,
message,
status: statusClass
});
if (latestVersion && comparison !== null) {
const notifyKey = comparison > 0
? 'system_info.version_update_available'
: 'system_info.version_is_latest';
const notifyType = comparison > 0 ? 'warning' : 'success';
this.showNotification(i18n.t(notifyKey, { version: latestVersion }), notifyType);
}
} catch (error) {
const message = `${i18n.t('system_info.version_check_error')}: ${error.message}`;
this.renderVersionCheckStatus({
currentVersion: this.serverVersion,
latestVersion: this.latestVersion,
message,
status: 'error'
});
this.showNotification(message, 'error');
} finally {
if (button) {
button.disabled = false;
button.innerHTML = originalLabel;
}
}
},
// API 请求方法
async makeRequest(endpoint, options = {}) {
try {
return await this.apiClient.request(endpoint, options);
} catch (error) {
console.error('API请求失败:', error);
throw error;
}
},
buildAvailableModelsEndpoint() {
return buildModelsEndpoint(this.apiBase || this.apiClient?.apiBase || '');
},
setAvailableModelsStatus(message = '', type = 'info') {
const statusEl = document.getElementById('available-models-status');
if (!statusEl) return;
statusEl.textContent = message || '';
statusEl.className = `available-models-status ${type}`;
},
renderAvailableModels(models = []) {
const listEl = document.getElementById('available-models-list');
if (!listEl) return;
if (!models.length) {
listEl.innerHTML = `
<div class="available-models-empty">
<i class="fas fa-inbox"></i>
<span>${i18n.t('system_info.models_empty')}</span>
</div>
`;
return;
}
const language = (i18n?.currentLanguage || '').toLowerCase();
const otherLabel = language.startsWith('zh') ? '其他' : 'Other';
const groups = classifyModels(models, { otherLabel });
const groupHtml = groups.map(group => {
const pills = group.items.map(model => {
const name = this.escapeHtml(model.name || '');
const alias = model.alias ? `<span class="model-alias">${this.escapeHtml(model.alias)}</span>` : '';
const description = model.description ? this.escapeHtml(model.description) : '';
const titleAttr = description ? ` title="${description}"` : '';
return `
<span class="provider-model-tag available-model-tag"${titleAttr}>
<span class="model-name">${name}</span>
${alias}
</span>
`;
}).join('');
const label = this.escapeHtml(group.label || group.id || '');
return `
<div class="available-model-group">
<div class="available-model-group-header">
<div class="available-model-group-title">
<span class="available-model-group-label">${label}</span>
<span class="available-model-group-count">${group.items.length}</span>
</div>
</div>
<div class="available-model-group-body">
${pills}
</div>
</div>
`;
}).join('');
listEl.innerHTML = groupHtml;
},
clearAvailableModels(messageKey = 'system_info.models_empty') {
this.availableModels = [];
this.availableModelApiKeysCache = null;
const listEl = document.getElementById('available-models-list');
if (listEl) {
listEl.innerHTML = '';
}
this.setAvailableModelsStatus(i18n.t(messageKey), 'warning');
},
async resolveApiKeysForModels({ config = null, forceRefresh = false } = {}) {
if (!forceRefresh && Array.isArray(this.availableModelApiKeysCache) && this.availableModelApiKeysCache.length) {
return this.availableModelApiKeysCache;
}
const configKeys = normalizeApiKeyList(config?.['api-keys'] || this.configCache?.['api-keys']);
if (configKeys.length) {
this.availableModelApiKeysCache = configKeys;
return configKeys;
}
try {
const data = await this.makeRequest('/api-keys');
const keys = normalizeApiKeyList(data?.['api-keys']);
if (keys.length) {
this.availableModelApiKeysCache = keys;
}
return keys;
} catch (error) {
console.warn('自动获取 API Key 失败:', error);
return [];
}
},
async loadAvailableModels({ config = null, forceRefresh = false } = {}) {
const listEl = document.getElementById('available-models-list');
const statusEl = document.getElementById('available-models-status');
if (!listEl || !statusEl) {
return;
}
if (!this.isConnected) {
this.setAvailableModelsStatus(i18n.t('common.disconnected'), 'warning');
listEl.innerHTML = '';
return;
}
const endpoint = this.buildAvailableModelsEndpoint();
if (!endpoint) {
this.setAvailableModelsStatus(i18n.t('system_info.models_error'), 'error');
listEl.innerHTML = `
<div class="available-models-empty">
<i class="fas fa-exclamation-circle"></i>
<span>${i18n.t('login.error_invalid')}</span>
</div>
`;
return;
}
this.availableModelsLoading = true;
this.setAvailableModelsStatus(i18n.t('system_info.models_loading'), 'info');
listEl.innerHTML = '<div class="available-models-placeholder"><i class="fas fa-spinner fa-spin"></i></div>';
try {
const headers = {};
const keys = await this.resolveApiKeysForModels({ config, forceRefresh });
if (keys.length) {
headers.Authorization = `Bearer ${keys[0]}`;
}
const response = await fetch(endpoint, { headers });
if (!response.ok) {
throw new Error(`${response.status} ${response.statusText}`);
}
let data;
try {
data = await response.json();
} catch (err) {
const text = await response.text();
throw new Error(text || err.message || 'Invalid JSON');
}
const models = normalizeModelList(data, { dedupe: true });
this.availableModels = models;
if (!models.length) {
this.setAvailableModelsStatus(i18n.t('system_info.models_empty'), 'warning');
this.renderAvailableModels([]);
return;
}
this.setAvailableModelsStatus(i18n.t('system_info.models_count', { count: models.length }), 'success');
this.renderAvailableModels(models);
} catch (error) {
console.error('加载可用模型失败:', error);
this.availableModels = [];
this.setAvailableModelsStatus(`${i18n.t('system_info.models_error')}: ${error.message}`, 'error');
listEl.innerHTML = `
<div class="available-models-empty">
<i class="fas fa-exclamation-circle"></i>
<span>${this.escapeHtml(error.message || '')}</span>
</div>
`;
} finally {
this.availableModelsLoading = false;
}
},
// 测试连接(简化版,用于内部调用)
async testConnection() {
try {
await this.makeRequest('/debug');
this.isConnected = true;
this.updateConnectionStatus();
this.startStatusUpdateTimer();
await this.loadAllData();
return true;
} catch (error) {
this.isConnected = false;
this.updateConnectionStatus();
this.stopStatusUpdateTimer();
throw error;
}
},
// 更新连接状态
updateConnectionStatus() {
const statusButton = document.getElementById('connection-status');
const apiStatus = document.getElementById('api-status');
const configStatus = document.getElementById('config-status');
const lastUpdate = document.getElementById('last-update');
if (this.isConnected) {
statusButton.innerHTML = `<i class="fas fa-circle connection-indicator connected"></i> ${i18n.t('common.connected')}`;
statusButton.className = 'btn btn-success';
apiStatus.textContent = i18n.t('common.connected');
// 更新配置状态
if (this.isCacheValid()) {
const fullTimestamp = this.cacheTimestamps && this.cacheTimestamps['__full__'];
const cacheAge = fullTimestamp
? Math.floor((Date.now() - fullTimestamp) / 1000)
: 0;
configStatus.textContent = `${i18n.t('system_info.cache_data')} (${cacheAge}${i18n.t('system_info.seconds_ago')})`;
configStatus.style.color = '#f59e0b'; // 橙色表示缓存
} else if (this.configCache && this.configCache['__full__']) {
configStatus.textContent = i18n.t('system_info.real_time_data');
configStatus.style.color = '#10b981'; // 绿色表示实时
} else {
configStatus.textContent = i18n.t('system_info.not_loaded');
configStatus.style.color = '#6b7280'; // 灰色表示未加载
}
} else {
statusButton.innerHTML = `<i class="fas fa-circle connection-indicator disconnected"></i> ${i18n.t('common.disconnected')}`;
statusButton.className = 'btn btn-danger';
apiStatus.textContent = i18n.t('common.disconnected');
configStatus.textContent = i18n.t('system_info.not_loaded');
configStatus.style.color = '#6b7280';
this.setAvailableModelsStatus(i18n.t('common.disconnected'), 'warning');
const modelsList = document.getElementById('available-models-list');
if (modelsList) {
modelsList.innerHTML = '';
}
}
lastUpdate.textContent = new Date().toLocaleString('zh-CN');
if (this.lastEditorConnectionState !== this.isConnected) {
this.updateConfigEditorAvailability();
}
// 更新连接信息显示
this.updateConnectionInfo();
if (this.events && typeof this.events.emit === 'function') {
const shouldEmit = this.lastConnectionStatusEmitted !== this.isConnected;
if (shouldEmit) {
this.events.emit('connection:status-changed', {
isConnected: this.isConnected,
apiBase: this.apiBase
});
this.lastConnectionStatusEmitted = this.isConnected;
}
}
},
// 检查连接状态
async checkConnectionStatus() {
await this.testConnection();
},
// 刷新所有数据
async refreshAllData() {
if (!this.isConnected) {
this.showNotification(i18n.t('notification.connection_required'), 'error');
return;
}
const button = document.getElementById('refresh-all');
const originalText = button.innerHTML;
button.innerHTML = `<div class="loading"></div> ${i18n.t('common.loading')}`;
button.disabled = true;
try {
// 强制刷新,清除缓存
await this.loadAllData(true);
this.showNotification(i18n.t('notification.data_refreshed'), 'success');
} catch (error) {
this.showNotification(`${i18n.t('notification.refresh_failed')}: ${error.message}`, 'error');
} finally {
button.innerHTML = originalText;
button.disabled = false;
}
},
// 检查缓存是否有效
isCacheValid(section = null) {
return this.configService.isCacheValid(section);
},
// 获取配置(优先使用缓存,支持按段获取)
async getConfig(section = null, forceRefresh = false) {
try {
const config = await this.configService.getConfig(section, forceRefresh);
this.configCache = this.configService.cache;
this.cacheTimestamps = this.configService.cacheTimestamps;
this.updateConnectionStatus();
return config;
} catch (error) {
console.error('获取配置失败:', error);
throw error;
}
},
// 清除缓存(支持清除特定配置段)
clearCache(section = null) {
this.configService.clearCache(section);
this.configCache = this.configService.cache;
this.cacheTimestamps = this.configService.cacheTimestamps;
if (!section || section === 'api-keys') {
this.availableModelApiKeysCache = null;
}
if (!section) {
this.configYamlCache = '';
this.availableModels = [];
}
},
// 启动状态更新定时器
startStatusUpdateTimer() {
if (this.statusUpdateTimer) {
clearInterval(this.statusUpdateTimer);
}
this.statusUpdateTimer = setInterval(() => {
if (this.isConnected) {
this.updateConnectionStatus();
}
}, STATUS_UPDATE_INTERVAL_MS);
},
// 停止状态更新定时器
stopStatusUpdateTimer() {
if (this.statusUpdateTimer) {
clearInterval(this.statusUpdateTimer);
this.statusUpdateTimer = null;
}
},
// 加载所有数据 - 使用新的 /config 端点一次性获取所有配置
async loadAllData(forceRefresh = false) {
try {
console.log(i18n.t('system_info.real_time_data'));
// 使用新的 /config 端点一次性获取所有配置
// 注意getConfig(section, forceRefresh),不传 section 表示获取全部
const config = await this.getConfig(null, forceRefresh);
// 获取一次usage统计数据供渲染函数和loadUsageStats复用
let usageData = null;
let keyStats = null;
try {
const response = await this.makeRequest('/usage');
usageData = response?.usage || null;
if (usageData) {
keyStats = await this.getKeyStats(usageData);
}
} catch (error) {
console.warn('获取usage统计失败:', error);
}
// 从配置中提取并设置各个设置项现在传递keyStats
await this.updateSettingsFromConfig(config, keyStats);
await this.loadAvailableModels({ config, forceRefresh });
if (this.events && typeof this.events.emit === 'function') {
this.events.emit('data:config-loaded', {
config,
usageData,
keyStats,
forceRefresh
});
}
console.log('配置加载完成,使用缓存:', !forceRefresh && this.isCacheValid());
} catch (error) {
console.error('加载配置失败:', error);
}
},
// 从配置对象更新所有设置 —— 委派给 settings 模块,保持兼容旧调用
async updateSettingsFromConfig(config, keyStats = null) {
if (typeof this.applySettingsFromConfig === 'function') {
return this.applySettingsFromConfig(config, keyStats);
}
},
detectApiBaseFromLocation() {
try {
const { protocol, hostname, port } = window.location;
const normalizedPort = port ? `:${port}` : '';
return this.normalizeBase(`${protocol}//${hostname}${normalizedPort}`);
} catch (error) {
console.warn('无法从当前地址检测 API 基础地址,使用默认设置', error);
return this.normalizeBase(this.apiBase || `http://localhost:${DEFAULT_API_PORT}`);
}
}
};

View File

@@ -1,231 +0,0 @@
/**
* 错误处理器
* 统一管理应用中的错误处理逻辑
*/
import { ERROR_MESSAGES } from '../utils/constants.js';
/**
* 错误处理器类
* 提供统一的错误处理接口,确保错误处理的一致性
*/
export class ErrorHandler {
/**
* 构造错误处理器
* @param {Object} notificationService - 通知服务对象
* @param {Function} notificationService.show - 显示通知的方法
*/
constructor(notificationService) {
this.notificationService = notificationService;
}
/**
* 处理更新操作失败
* 包括显示错误通知和执行UI回滚操作
*
* @param {Error} error - 错误对象
* @param {string} context - 操作上下文(如"调试模式"、"代理设置"
* @param {Function} [rollbackFn] - UI回滚函数
*
* @example
* try {
* await this.makeRequest('/debug', { method: 'PATCH', body: JSON.stringify({ enabled: true }) });
* } catch (error) {
* this.errorHandler.handleUpdateError(
* error,
* '调试模式',
* () => document.getElementById('debug-toggle').checked = false
* );
* }
*/
handleUpdateError(error, context, rollbackFn) {
console.error(`更新${context}失败:`, error);
const message = `更新${context}失败: ${error.message || ERROR_MESSAGES.OPERATION_FAILED}`;
this.notificationService.show(message, 'error');
// 执行回滚操作
if (typeof rollbackFn === 'function') {
try {
rollbackFn();
} catch (rollbackError) {
console.error('UI回滚操作失败:', rollbackError);
}
}
}
/**
* 处理加载操作失败
*
* @param {Error} error - 错误对象
* @param {string} context - 加载内容的上下文(如"API密钥"、"使用统计"
*
* @example
* try {
* const data = await this.makeRequest('/api-keys');
* this.renderApiKeys(data);
* } catch (error) {
* this.errorHandler.handleLoadError(error, 'API密钥');
* }
*/
handleLoadError(error, context) {
console.error(`加载${context}失败:`, error);
const message = `加载${context}失败,请检查连接`;
this.notificationService.show(message, 'error');
}
/**
* 处理删除操作失败
*
* @param {Error} error - 错误对象
* @param {string} context - 删除内容的上下文
*/
handleDeleteError(error, context) {
console.error(`删除${context}失败:`, error);
const message = `删除${context}失败: ${error.message || ERROR_MESSAGES.OPERATION_FAILED}`;
this.notificationService.show(message, 'error');
}
/**
* 处理添加操作失败
*
* @param {Error} error - 错误对象
* @param {string} context - 添加内容的上下文
*/
handleAddError(error, context) {
console.error(`添加${context}失败:`, error);
const message = `添加${context}失败: ${error.message || ERROR_MESSAGES.OPERATION_FAILED}`;
this.notificationService.show(message, 'error');
}
/**
* 处理网络错误
* 检测常见的网络问题并提供友好的错误提示
*
* @param {Error} error - 错误对象
*/
handleNetworkError(error) {
console.error('网络请求失败:', error);
let message = ERROR_MESSAGES.NETWORK_ERROR;
// 检测特定错误类型
if (error.name === 'TypeError' && error.message.includes('fetch')) {
message = ERROR_MESSAGES.NETWORK_ERROR;
} else if (error.message && error.message.includes('timeout')) {
message = ERROR_MESSAGES.TIMEOUT;
} else if (error.message && error.message.includes('401')) {
message = ERROR_MESSAGES.UNAUTHORIZED;
} else if (error.message && error.message.includes('404')) {
message = ERROR_MESSAGES.NOT_FOUND;
} else if (error.message && error.message.includes('500')) {
message = ERROR_MESSAGES.SERVER_ERROR;
} else if (error.message) {
message = `网络错误: ${error.message}`;
}
this.notificationService.show(message, 'error');
}
/**
* 处理验证错误
*
* @param {string} fieldName - 字段名称
* @param {string} [message] - 自定义错误消息
*/
handleValidationError(fieldName, message) {
const errorMessage = message || `请输入有效的${fieldName}`;
this.notificationService.show(errorMessage, 'error');
}
/**
* 处理通用错误
* 当错误类型不明确时使用
*
* @param {Error} error - 错误对象
* @param {string} [defaultMessage] - 默认错误消息
*/
handleGenericError(error, defaultMessage) {
console.error('操作失败:', error);
const message = error.message || defaultMessage || ERROR_MESSAGES.OPERATION_FAILED;
this.notificationService.show(message, 'error');
}
/**
* 创建带错误处理的异步函数包装器
* 自动捕获并处理错误
*
* @param {Function} asyncFn - 异步函数
* @param {string} context - 操作上下文
* @param {Function} [rollbackFn] - 回滚函数
* @returns {Function} 包装后的函数
*
* @example
* const safeUpdate = this.errorHandler.withErrorHandling(
* () => this.makeRequest('/debug', { method: 'PATCH', body: '...' }),
* '调试模式',
* () => document.getElementById('debug-toggle').checked = false
* );
* await safeUpdate();
*/
withErrorHandling(asyncFn, context, rollbackFn) {
return async (...args) => {
try {
return await asyncFn(...args);
} catch (error) {
this.handleUpdateError(error, context, rollbackFn);
throw error; // 重新抛出以便调用者处理
}
};
}
/**
* 创建带重试机制的错误处理包装器
*
* @param {Function} asyncFn - 异步函数
* @param {number} [maxRetries=3] - 最大重试次数
* @param {number} [retryDelay=1000] - 重试延迟(毫秒)
* @returns {Function} 包装后的函数
*
* @example
* const retryableFetch = this.errorHandler.withRetry(
* () => this.makeRequest('/config'),
* 3,
* 2000
* );
* const config = await retryableFetch();
*/
withRetry(asyncFn, maxRetries = 3, retryDelay = 1000) {
return async (...args) => {
let lastError;
for (let attempt = 0; attempt < maxRetries; attempt++) {
try {
return await asyncFn(...args);
} catch (error) {
lastError = error;
console.warn(`尝试 ${attempt + 1}/${maxRetries} 失败:`, error);
if (attempt < maxRetries - 1) {
// 等待后重试
await new Promise(resolve => setTimeout(resolve, retryDelay));
}
}
}
// 所有尝试都失败
throw lastError;
};
}
}
/**
* 创建错误处理器工厂函数
* 便于在不同模块中创建错误处理器实例
*
* @param {Function} showNotification - 显示通知的函数
* @returns {ErrorHandler} 错误处理器实例
*/
export function createErrorHandler(showNotification) {
return new ErrorHandler({
show: showNotification
});
}

View File

@@ -1,10 +0,0 @@
// 轻量事件总线,避免模块之间的直接耦合
export function createEventBus() {
const target = new EventTarget();
const on = (type, listener) => target.addEventListener(type, listener);
const off = (type, listener) => target.removeEventListener(type, listener);
const emit = (type, detail = {}) => target.dispatchEvent(new CustomEvent(type, { detail }));
return { on, off, emit };
}

10
src/hooks/index.ts Normal file
View File

@@ -0,0 +1,10 @@
/**
* Hooks 统一导出
*/
export { useApi } from './useApi';
export { useDebounce } from './useDebounce';
export { useLocalStorage } from './useLocalStorage';
export { useInterval } from './useInterval';
export { useMediaQuery } from './useMediaQuery';
export { usePagination } from './usePagination';

65
src/hooks/useApi.ts Normal file
View File

@@ -0,0 +1,65 @@
/**
* 通用 API 调用 Hook
*/
import { useState, useCallback } from 'react';
import { useNotificationStore } from '@/stores';
interface UseApiOptions<T> {
onSuccess?: (data: T) => void;
onError?: (error: Error) => void;
showSuccessNotification?: boolean;
showErrorNotification?: boolean;
successMessage?: string;
}
export function useApi<T = any, Args extends any[] = any[]>(
apiFunction: (...args: Args) => Promise<T>,
options: UseApiOptions<T> = {}
) {
const [data, setData] = useState<T | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<Error | null>(null);
const { showNotification } = useNotificationStore();
const execute = useCallback(
async (...args: Args) => {
setLoading(true);
setError(null);
try {
const result = await apiFunction(...args);
setData(result);
if (options.showSuccessNotification && options.successMessage) {
showNotification(options.successMessage, 'success');
}
options.onSuccess?.(result);
return result;
} catch (err) {
const errorObj = err as Error;
setError(errorObj);
if (options.showErrorNotification !== false) {
showNotification(errorObj.message, 'error');
}
options.onError?.(errorObj);
throw errorObj;
} finally {
setLoading(false);
}
},
[apiFunction, options, showNotification]
);
const reset = useCallback(() => {
setData(null);
setError(null);
setLoading(false);
}, []);
return { data, loading, error, execute, reset };
}

21
src/hooks/useDebounce.ts Normal file
View File

@@ -0,0 +1,21 @@
/**
* 防抖 Hook
*/
import { useEffect, useState } from 'react';
export function useDebounce<T>(value: T, delay: number = 500): T {
const [debouncedValue, setDebouncedValue] = useState<T>(value);
useEffect(() => {
const handler = setTimeout(() => {
setDebouncedValue(value);
}, delay);
return () => {
clearTimeout(handler);
};
}, [value, delay]);
return debouncedValue;
}

24
src/hooks/useInterval.ts Normal file
View File

@@ -0,0 +1,24 @@
/**
* 定时器 Hook
*/
import { useEffect, useRef } from 'react';
export function useInterval(callback: () => void, delay: number | null) {
const savedCallback = useRef<(() => void) | null>(null);
useEffect(() => {
savedCallback.current = callback;
}, [callback]);
useEffect(() => {
if (delay === null) return;
const tick = () => {
savedCallback.current?.();
};
const id = setInterval(tick, delay);
return () => clearInterval(id);
}, [delay]);
}

View File

@@ -0,0 +1,32 @@
/**
* LocalStorage Hook
*/
import { useState } from 'react';
export function useLocalStorage<T>(
key: string,
initialValue: T
): [T, (value: T | ((val: T) => T)) => void] {
const [storedValue, setStoredValue] = useState<T>(() => {
try {
const item = window.localStorage.getItem(key);
return item ? (JSON.parse(item) as T) : initialValue;
} catch (error) {
console.error(`Error reading localStorage key "${key}":`, error);
return initialValue;
}
});
const setValue = (value: T | ((val: T) => T)) => {
try {
const valueToStore = value instanceof Function ? value(storedValue) : value;
setStoredValue(valueToStore);
window.localStorage.setItem(key, JSON.stringify(valueToStore));
} catch (error) {
console.error(`Error setting localStorage key "${key}":`, error);
}
};
return [storedValue, setValue];
}

View File

@@ -0,0 +1,27 @@
/**
* 媒体查询 Hook
*/
import { useState, useEffect } from 'react';
export function useMediaQuery(query: string): boolean {
const [matches, setMatches] = useState(() => {
return window.matchMedia(query).matches;
});
useEffect(() => {
const media = window.matchMedia(query);
const listener = (event: MediaQueryListEvent) => {
setMatches(event.matches);
};
// Set initial value via listener to avoid direct setState in effect
listener({ matches: media.matches } as MediaQueryListEvent);
media.addEventListener('change', listener);
return () => media.removeEventListener('change', listener);
}, [query]);
return matches;
}

View File

@@ -0,0 +1,59 @@
/**
* 分页 Hook
*/
import { useState, useMemo } from 'react';
import type { PaginationState } from '@/types';
export function usePagination<T>(
items: T[],
initialPageSize: number = 20
): PaginationState & {
currentItems: T[];
goToPage: (page: number) => void;
nextPage: () => void;
prevPage: () => void;
setPageSize: (size: number) => void;
} {
const [currentPage, setCurrentPage] = useState(1);
const [pageSize, setPageSize] = useState(initialPageSize);
const totalItems = items.length;
const totalPages = Math.ceil(totalItems / pageSize) || 1;
const currentItems = useMemo(() => {
const start = (currentPage - 1) * pageSize;
const end = start + pageSize;
return items.slice(start, end);
}, [items, currentPage, pageSize]);
const goToPage = (page: number) => {
const validPage = Math.max(1, Math.min(page, totalPages));
setCurrentPage(validPage);
};
const nextPage = () => {
goToPage(currentPage + 1);
};
const prevPage = () => {
goToPage(currentPage - 1);
};
const handleSetPageSize = (size: number) => {
setPageSize(size);
setCurrentPage(1); // 重置到第一页
};
return {
currentPage,
pageSize,
totalPages,
totalItems,
currentItems,
goToPage,
nextPage,
prevPage,
setPageSize: handleSetPageSize
};
}

26
src/i18n/index.ts Normal file
View File

@@ -0,0 +1,26 @@
/**
* i18next 国际化配置
*/
import i18n from 'i18next';
import { initReactI18next } from 'react-i18next';
import zhCN from './locales/zh-CN.json';
import en from './locales/en.json';
import { STORAGE_KEY_LANGUAGE } from '@/utils/constants';
i18n.use(initReactI18next).init({
resources: {
'zh-CN': { translation: zhCN },
en: { translation: en }
},
lng: localStorage.getItem(STORAGE_KEY_LANGUAGE) || 'zh-CN',
fallbackLng: 'zh-CN',
interpolation: {
escapeValue: false // React 已经转义
},
react: {
useSuspense: false
}
});
export default i18n;

656
src/i18n/locales/en.json Normal file
View File

@@ -0,0 +1,656 @@
{
"common": {
"login": "Login",
"logout": "Logout",
"cancel": "Cancel",
"confirm": "Confirm",
"save": "Save",
"delete": "Delete",
"edit": "Edit",
"add": "Add",
"update": "Update",
"refresh": "Refresh",
"close": "Close",
"success": "Success",
"error": "Error",
"info": "Info",
"warning": "Warning",
"loading": "Loading...",
"connecting": "Connecting...",
"connected": "Connected",
"disconnected": "Disconnected",
"connecting_status": "Connecting",
"connected_status": "Connected",
"disconnected_status": "Disconnected",
"yes": "Yes",
"no": "No",
"optional": "Optional",
"required": "Required",
"api_key": "Key",
"base_url": "Address",
"proxy_url": "Proxy",
"alias": "Alias",
"failure": "Failure",
"unknown_error": "Unknown error",
"copy": "Copy",
"custom_headers_label": "Custom Headers",
"custom_headers_hint": "Optional HTTP headers to send with the request. Leave blank to remove.",
"custom_headers_add": "Add Header",
"custom_headers_key_placeholder": "Header name, e.g. X-Custom-Header",
"custom_headers_value_placeholder": "Header value",
"model_name_placeholder": "Model name, e.g. claude-3-5-sonnet-20241022",
"model_alias_placeholder": "Model alias (optional)"
},
"title": {
"main": "CLI Proxy API Management Center",
"login": "CLI Proxy API Management Center",
"abbr": "CPAMC"
},
"auto_login": {
"title": "Auto Login in Progress...",
"message": "Attempting to connect to server using locally saved connection information"
},
"login": {
"subtitle": "Please enter connection information to access the management interface",
"connection_title": "Connection Address",
"connection_current": "Current URL",
"connection_auto_hint": "The system will automatically use the current URL for connection",
"custom_connection_label": "Custom Connection URL:",
"custom_connection_placeholder": "Eg: https://example.com:8317",
"custom_connection_hint": "By default the current URL is used. Override it here if needed.",
"use_current_address": "Use Current URL",
"management_key_label": "Management Key:",
"management_key_placeholder": "Enter the management key",
"connect_button": "Connect",
"submit_button": "Login",
"submitting": "Connecting...",
"error_title": "Login Failed",
"error_required": "Please fill in complete connection information",
"error_invalid": "Connection failed, please check address and key"
},
"header": {
"check_connection": "Check Connection",
"refresh_all": "Refresh All",
"logout": "Logout"
},
"connection": {
"title": "Connection Information",
"server_address": "Server Address:",
"management_key": "Management Key:",
"status": "Connection Status:"
},
"nav": {
"basic_settings": "Basic Settings",
"api_keys": "API Keys",
"ai_providers": "AI Providers",
"auth_files": "Auth Files",
"oauth": "OAuth Login",
"usage_stats": "Usage Statistics",
"config_management": "Config Management",
"logs": "Logs Viewer",
"system_info": "Management Center Info"
},
"basic_settings": {
"title": "Basic Settings",
"debug_title": "Debug Mode",
"debug_enable": "Enable Debug Mode",
"proxy_title": "Proxy Settings",
"proxy_url_label": "Proxy URL:",
"proxy_url_placeholder": "e.g.: socks5://user:pass@127.0.0.1:1080/",
"proxy_update": "Update",
"proxy_clear": "Clear",
"retry_title": "Request Retry",
"retry_count_label": "Retry Count:",
"retry_update": "Update",
"quota_title": "Quota Exceeded Behavior",
"quota_switch_project": "Auto Switch Project",
"quota_switch_preview": "Switch to Preview Model",
"usage_statistics_title": "Usage Statistics",
"usage_statistics_enable": "Enable usage statistics",
"logging_title": "Logging",
"logging_to_file_enable": "Enable logging to file",
"request_log_enable": "Enable request logging",
"ws_auth_title": "WebSocket Authentication",
"ws_auth_enable": "Require auth for /ws/*"
},
"api_keys": {
"title": "API Keys Management",
"proxy_auth_title": "Proxy Service Authentication Keys",
"add_button": "Add Key",
"empty_title": "No API Keys",
"empty_desc": "Click the button above to add the first key",
"item_title": "API Key",
"add_modal_title": "Add API Key",
"add_modal_key_label": "API Key:",
"add_modal_key_placeholder": "Please enter API key",
"edit_modal_title": "Edit API Key",
"edit_modal_key_label": "API Key:",
"delete_confirm": "Are you sure you want to delete this API key?"
},
"ai_providers": {
"title": "AI Providers Configuration",
"gemini_title": "Gemini API Keys",
"gemini_add_button": "Add Key",
"gemini_empty_title": "No Gemini Keys",
"gemini_empty_desc": "Click the button above to add the first key",
"gemini_item_title": "Gemini Key",
"gemini_add_modal_title": "Add Gemini API Key",
"gemini_add_modal_key_label": "API Keys:",
"gemini_add_modal_key_placeholder": "Enter Gemini API key",
"gemini_add_modal_key_hint": "Add keys one by one and optionally specify a Base URL.",
"gemini_keys_add_btn": "Add Key",
"gemini_base_url_placeholder": "Optional Base URL, e.g. https://generativelanguage.googleapis.com",
"gemini_edit_modal_title": "Edit Gemini API Key",
"gemini_edit_modal_key_label": "API Key:",
"gemini_delete_confirm": "Are you sure you want to delete this Gemini key?",
"excluded_models_label": "Excluded models (optional):",
"excluded_models_placeholder": "Comma or newline separated, e.g. gemini-1.5-pro, gemini-1.5-flash",
"excluded_models_hint": "Leave empty to allow all models; values are trimmed and deduplicated automatically.",
"excluded_models_count": "Excluding {count} models",
"codex_title": "Codex API Configuration",
"codex_add_button": "Add Configuration",
"codex_empty_title": "No Codex Configuration",
"codex_empty_desc": "Click the button above to add the first configuration",
"codex_item_title": "Codex Configuration",
"codex_add_modal_title": "Add Codex API Configuration",
"codex_add_modal_key_label": "API Key:",
"codex_add_modal_key_placeholder": "Please enter Codex API key",
"codex_add_modal_url_label": "Base URL (Required):",
"codex_add_modal_url_placeholder": "e.g.: https://api.example.com",
"codex_add_modal_proxy_label": "Proxy URL (Optional):",
"codex_add_modal_proxy_placeholder": "e.g.: socks5://proxy.example.com:1080",
"codex_edit_modal_title": "Edit Codex API Configuration",
"codex_edit_modal_key_label": "API Key:",
"codex_edit_modal_url_label": "Base URL (Required):",
"codex_edit_modal_proxy_label": "Proxy URL (Optional):",
"codex_delete_confirm": "Are you sure you want to delete this Codex configuration?",
"claude_title": "Claude API Configuration",
"claude_add_button": "Add Configuration",
"claude_empty_title": "No Claude Configuration",
"claude_empty_desc": "Click the button above to add the first configuration",
"claude_item_title": "Claude Configuration",
"claude_add_modal_title": "Add Claude API Configuration",
"claude_add_modal_key_label": "API Key:",
"claude_add_modal_key_placeholder": "Please enter Claude API key",
"claude_add_modal_url_label": "Base URL (Optional):",
"claude_add_modal_url_placeholder": "e.g.: https://api.anthropic.com",
"claude_add_modal_proxy_label": "Proxy URL (Optional):",
"claude_add_modal_proxy_placeholder": "e.g.: socks5://proxy.example.com:1080",
"claude_edit_modal_title": "Edit Claude API Configuration",
"claude_edit_modal_key_label": "API Key:",
"claude_edit_modal_url_label": "Base URL (Optional):",
"claude_edit_modal_proxy_label": "Proxy URL (Optional):",
"claude_delete_confirm": "Are you sure you want to delete this Claude configuration?",
"claude_models_label": "Custom Models (Optional):",
"claude_models_hint": "Leave empty to allow all models, or add name[, alias] entries to limit/alias them.",
"claude_models_add_btn": "Add Model",
"claude_models_count": "Models Count",
"openai_title": "OpenAI Compatible Providers",
"openai_add_button": "Add Provider",
"openai_empty_title": "No OpenAI Compatible Providers",
"openai_empty_desc": "Click the button above to add the first provider",
"openai_add_modal_title": "Add OpenAI Compatible Provider",
"openai_add_modal_name_label": "Provider Name:",
"openai_add_modal_name_placeholder": "e.g.: openrouter",
"openai_add_modal_url_label": "Base URL:",
"openai_add_modal_url_placeholder": "e.g.: https://openrouter.ai/api/v1",
"openai_add_modal_keys_label": "API Keys",
"openai_edit_modal_keys_label": "API Keys",
"openai_keys_hint": "Add each key separately with an optional proxy URL to keep things organized.",
"openai_keys_add_btn": "Add Key",
"openai_key_placeholder": "sk-... key",
"openai_proxy_placeholder": "Optional proxy URL (e.g. socks5://...)",
"openai_add_modal_models_label": "Model List (name[, alias] one per line):",
"openai_models_hint": "Example: gpt-4o-mini or moonshotai/kimi-k2:free, kimi-k2",
"openai_model_name_placeholder": "Model name, e.g. moonshotai/kimi-k2:free",
"openai_model_alias_placeholder": "Model alias (optional)",
"openai_models_add_btn": "Add Model",
"openai_models_fetch_button": "Fetch via /v1/models",
"openai_models_fetch_title": "Pick Models from /v1/models",
"openai_models_fetch_hint": "Call the /v1/models endpoint using the Base URL above, sending the first API key as Bearer plus custom headers.",
"openai_models_fetch_url_label": "Request URL",
"openai_models_fetch_refresh": "Refresh",
"openai_models_fetch_loading": "Fetching models from /v1/models...",
"openai_models_fetch_empty": "No models returned. Please check the endpoint or auth.",
"openai_models_fetch_error": "Failed to fetch models",
"openai_models_fetch_back": "Back to edit",
"openai_models_fetch_apply": "Add selected models",
"openai_models_search_label": "Search models",
"openai_models_search_placeholder": "Filter by name, alias, or description",
"openai_models_search_empty": "No models match your search. Try a different keyword.",
"openai_models_fetch_invalid_url": "Please enter a valid Base URL first",
"openai_models_fetch_added": "{count} new models added",
"openai_edit_modal_title": "Edit OpenAI Compatible Provider",
"openai_edit_modal_name_label": "Provider Name:",
"openai_edit_modal_url_label": "Base URL:",
"openai_edit_modal_models_label": "Model List (name[, alias] one per line):",
"openai_delete_confirm": "Are you sure you want to delete this OpenAI provider?",
"openai_keys_count": "Keys Count",
"openai_models_count": "Models Count",
"openai_test_title": "Connection Test",
"openai_test_hint": "Send a /v1/chat/completions request with the current settings to verify availability.",
"openai_test_model_placeholder": "Model to test",
"openai_test_action": "Run Test",
"openai_test_running": "Sending test request...",
"openai_test_success": "Test succeeded. The model responded.",
"openai_test_failed": "Test failed",
"openai_test_select_placeholder": "Choose from current models",
"openai_test_select_empty": "No models configured, enter manually"
},
"auth_files": {
"title": "Auth Files Management",
"title_section": "Auth Files",
"description": "Manage all CLI Proxy JSON auth files here (e.g. Qwen, Gemini, Vertex). Uploading a credential immediately enables the corresponding AI integration.",
"upload_button": "Upload File",
"delete_all_button": "Delete All",
"empty_title": "No Auth Files",
"empty_desc": "Click the button above to upload the first file",
"search_empty_title": "No matching files",
"search_empty_desc": "Try changing the filters or clearing the search box.",
"file_size": "Size",
"file_modified": "Modified",
"download_button": "Download",
"delete_button": "Delete",
"delete_confirm": "Are you sure you want to delete file",
"delete_all_confirm": "Are you sure you want to delete all auth files? This operation cannot be undone!",
"delete_filtered_confirm": "Are you sure you want to delete all {type} auth files? This operation cannot be undone!",
"upload_error_json": "Only JSON files are allowed",
"upload_success": "File uploaded successfully",
"download_success": "File downloaded successfully",
"delete_success": "File deleted successfully",
"delete_all_success": "Successfully deleted",
"delete_filtered_success": "Deleted {count} {type} auth files successfully",
"delete_filtered_partial": "{type} auth files deletion finished: {success} succeeded, {failed} failed",
"delete_filtered_none": "No deletable auth files under the current filter ({type})",
"files_count": "files",
"pagination_prev": "Previous",
"pagination_next": "Next",
"pagination_info": "Page {current} / {total} · {count} files",
"search_label": "Search configs",
"search_placeholder": "Filter by name, type, or provider",
"page_size_label": "Per page",
"page_size_unit": "items",
"filter_all": "All",
"filter_qwen": "Qwen",
"filter_gemini": "Gemini",
"filter_gemini-cli": "GeminiCLI",
"filter_aistudio": "AIStudio",
"filter_claude": "Claude",
"filter_codex": "Codex",
"filter_antigravity": "Antigravity",
"filter_iflow": "iFlow",
"filter_vertex": "Vertex",
"filter_empty": "Empty",
"filter_unknown": "Other",
"type_qwen": "Qwen",
"type_gemini": "Gemini",
"type_gemini-cli": "GeminiCLI",
"type_aistudio": "AIStudio",
"type_claude": "Claude",
"type_codex": "Codex",
"type_antigravity": "Antigravity",
"type_iflow": "iFlow",
"type_vertex": "Vertex",
"type_empty": "Empty",
"type_unknown": "Other"
},
"vertex_import": {
"title": "Vertex AI Credential Import",
"description": "Upload a Google service account JSON to store it as auth-dir/vertex-<project>.json using the same rules as the CLI vertex-import helper.",
"location_label": "Region (optional)",
"location_placeholder": "us-central1",
"location_hint": "Leave empty to use the default region us-central1.",
"file_label": "Service account key JSON",
"file_hint": "Only Google Cloud service account key JSON files are accepted.",
"file_placeholder": "No file selected",
"choose_file": "Choose File",
"import_button": "Import Vertex Credential",
"file_required": "Select a .json credential file first",
"success": "Vertex credential imported successfully",
"result_title": "Credential saved",
"result_project": "Project ID",
"result_email": "Service account",
"result_location": "Region",
"result_file": "Persisted file"
},
"oauth_excluded": {
"title": "OAuth Excluded Models",
"description": "Per-provider exclusions are shown as cards; click edit to adjust. Wildcards * are supported and the scope follows the auth file filter.",
"add": "Add Exclusion",
"add_title": "Add provider exclusion",
"edit_title": "Edit exclusions for {provider}",
"refresh": "Refresh",
"refreshing": "Refreshing...",
"provider_label": "Provider",
"provider_auto": "Follow current filter",
"provider_placeholder": "e.g. gemini-cli",
"provider_hint": "Defaults to the current filter; pick an existing provider or type a new name.",
"models_label": "Models to exclude",
"models_placeholder": "gpt-4.1-mini\n*-preview",
"models_hint": "Separate by commas or new lines; saving an empty list removes that provider. * wildcards are supported.",
"save": "Save/Update",
"saving": "Saving...",
"save_success": "Excluded models updated",
"save_failed": "Failed to update excluded models",
"delete": "Delete Provider",
"delete_confirm": "Delete the exclusion list for {provider}?",
"delete_success": "Exclusion list removed",
"delete_failed": "Failed to delete exclusion list",
"deleting": "Deleting...",
"no_models": "No excluded models",
"model_count": "{count} models excluded",
"list_empty_all": "No exclusions yet—use “Add Exclusion” to create one.",
"list_empty_filtered": "No exclusions in this scope; click “Add Exclusion” to add.",
"disconnected": "Connect to the server to view exclusions",
"load_failed": "Failed to load exclusion list",
"provider_required": "Please enter a provider first",
"scope_all": "Scope: All providers",
"scope_provider": "Scope: {provider}"
},
"auth_login": {
"codex_oauth_title": "Codex OAuth",
"codex_oauth_button": "Start Codex Login",
"codex_oauth_hint": "Login to Codex service through OAuth flow, automatically obtain and save authentication files.",
"codex_oauth_url_label": "Authorization URL:",
"codex_open_link": "Open Link",
"codex_copy_link": "Copy Link",
"codex_oauth_status_waiting": "Waiting for authentication...",
"codex_oauth_status_success": "Authentication successful!",
"codex_oauth_status_error": "Authentication failed:",
"codex_oauth_start_error": "Failed to start Codex OAuth:",
"codex_oauth_polling_error": "Failed to check authentication status:",
"anthropic_oauth_title": "Anthropic OAuth",
"anthropic_oauth_button": "Start Anthropic Login",
"anthropic_oauth_hint": "Login to Anthropic (Claude) service through OAuth flow, automatically obtain and save authentication files.",
"anthropic_oauth_url_label": "Authorization URL:",
"anthropic_open_link": "Open Link",
"anthropic_copy_link": "Copy Link",
"anthropic_oauth_status_waiting": "Waiting for authentication...",
"anthropic_oauth_status_success": "Authentication successful!",
"anthropic_oauth_status_error": "Authentication failed:",
"anthropic_oauth_start_error": "Failed to start Anthropic OAuth:",
"anthropic_oauth_polling_error": "Failed to check authentication status:",
"antigravity_oauth_title": "Antigravity OAuth",
"antigravity_oauth_button": "Start Antigravity Login",
"antigravity_oauth_hint": "Login to Antigravity service (Google account) through OAuth flow, automatically obtain and save authentication files.",
"antigravity_oauth_url_label": "Authorization URL:",
"antigravity_open_link": "Open Link",
"antigravity_copy_link": "Copy Link",
"antigravity_oauth_status_waiting": "Waiting for authentication...",
"antigravity_oauth_status_success": "Authentication successful!",
"antigravity_oauth_status_error": "Authentication failed:",
"antigravity_oauth_start_error": "Failed to start Antigravity OAuth:",
"antigravity_oauth_polling_error": "Failed to check authentication status:",
"gemini_cli_oauth_title": "Gemini CLI OAuth",
"gemini_cli_oauth_button": "Start Gemini CLI Login",
"gemini_cli_oauth_hint": "Login to Google Gemini CLI service through OAuth flow, automatically obtain and save authentication files.",
"gemini_cli_project_id_label": "Google Cloud Project ID (Optional):",
"gemini_cli_project_id_placeholder": "Enter Google Cloud Project ID (optional)",
"gemini_cli_project_id_hint": "If a project ID is specified, authentication information for that project will be used.",
"gemini_cli_oauth_url_label": "Authorization URL:",
"gemini_cli_open_link": "Open Link",
"gemini_cli_copy_link": "Copy Link",
"gemini_cli_oauth_status_waiting": "Waiting for authentication...",
"gemini_cli_oauth_status_success": "Authentication successful!",
"gemini_cli_oauth_status_error": "Authentication failed:",
"gemini_cli_oauth_start_error": "Failed to start Gemini CLI OAuth:",
"gemini_cli_oauth_polling_error": "Failed to check authentication status:",
"qwen_oauth_title": "Qwen OAuth",
"qwen_oauth_button": "Start Qwen Login",
"qwen_oauth_hint": "Login to Qwen service through device authorization flow, automatically obtain and save authentication files.",
"qwen_oauth_url_label": "Authorization URL:",
"qwen_open_link": "Open Link",
"qwen_copy_link": "Copy Link",
"qwen_oauth_status_waiting": "Waiting for authentication...",
"qwen_oauth_status_success": "Authentication successful!",
"qwen_oauth_status_error": "Authentication failed:",
"qwen_oauth_start_error": "Failed to start Qwen OAuth:",
"qwen_oauth_polling_error": "Failed to check authentication status:",
"missing_state": "Unable to retrieve authentication state parameter",
"iflow_oauth_title": "iFlow OAuth",
"iflow_oauth_button": "Start iFlow Login",
"iflow_oauth_hint": "Login to iFlow service through OAuth flow, automatically obtain and save authentication files.",
"iflow_oauth_url_label": "Authorization URL:",
"iflow_open_link": "Open Link",
"iflow_copy_link": "Copy Link",
"iflow_oauth_status_waiting": "Waiting for authentication...",
"iflow_oauth_status_success": "Authentication successful!",
"iflow_oauth_status_error": "Authentication failed:",
"iflow_oauth_start_error": "Failed to start iFlow OAuth:",
"iflow_oauth_polling_error": "Failed to check authentication status:",
"iflow_cookie_title": "iFlow Cookie Login",
"iflow_cookie_label": "Cookie Value:",
"iflow_cookie_placeholder": "Paste browser cookie, e.g. sessionid=...;",
"iflow_cookie_hint": "Submit an existing cookie to finish login without opening the authorization link; the credential file will be saved automatically.",
"iflow_cookie_button": "Submit Cookie Login",
"iflow_cookie_status_success": "Cookie login succeeded and credentials are saved.",
"iflow_cookie_status_error": "Cookie login failed:",
"iflow_cookie_start_error": "Failed to submit cookie login:",
"iflow_cookie_required": "Please provide the Cookie value first.",
"iflow_cookie_result_title": "Cookie Login Result",
"iflow_cookie_result_email": "Account",
"iflow_cookie_result_expired": "Expires At",
"iflow_cookie_result_path": "Saved Path",
"iflow_cookie_result_type": "Type"
},
"usage_stats": {
"title": "Usage Statistics",
"total_requests": "Total Requests",
"success_requests": "Success Requests",
"failed_requests": "Failed Requests",
"total_tokens": "Total Tokens",
"cached_tokens": "Cached Tokens",
"reasoning_tokens": "Reasoning Tokens",
"rpm_30m": "RPM (last 30 min)",
"tpm_30m": "TPM (last 30 min)",
"requests_trend": "Request Trends",
"tokens_trend": "Token Usage Trends",
"api_details": "API Details",
"by_hour": "By Hour",
"by_day": "By Day",
"refresh": "Refresh",
"chart_line_label_1": "Line 1",
"chart_line_label_2": "Line 2",
"chart_line_label_3": "Line 3",
"chart_line_label_4": "Line 4",
"chart_line_label_5": "Line 5",
"chart_line_label_6": "Line 6",
"chart_line_label_7": "Line 7",
"chart_line_label_8": "Line 8",
"chart_line_label_9": "Line 9",
"chart_line_hidden": "Hide",
"chart_line_actions_label": "Lines to display",
"chart_line_add": "Add line",
"chart_line_all": "All",
"chart_line_delete": "Delete line",
"chart_line_hint": "Show up to 9 model lines at once",
"no_data": "No Data Available",
"loading_error": "Loading Failed",
"api_endpoint": "API Endpoint",
"requests_count": "Request Count",
"tokens_count": "Token Count",
"models": "Model Statistics",
"success_rate": "Success Rate",
"total_cost": "Total Cost",
"total_cost_hint": "Based on configured model pricing",
"model_price_title": "Model Pricing",
"model_price_reset": "Clear Prices",
"model_price_model_label": "Model",
"model_price_select_placeholder": "Choose a model",
"model_price_select_hint": "Models come from usage details",
"model_price_prompt": "Prompt price ($/1M tokens)",
"model_price_completion": "Completion price ($/1M tokens)",
"model_price_save": "Save Price",
"model_price_empty": "No model prices set",
"model_price_model": "Model",
"model_price_saved": "Model price saved",
"model_price_model_required": "Please choose a model to set pricing",
"cost_trend": "Cost Overview",
"cost_axis_label": "Cost ($)",
"cost_need_price": "Set a model price to view cost stats",
"cost_need_usage": "No usage data available to calculate cost",
"cost_no_data": "No cost data yet"
},
"stats": {
"success": "Success",
"failure": "Failure"
},
"logs": {
"title": "Logs Viewer",
"refresh_button": "Refresh Logs",
"clear_button": "Clear Logs",
"download_button": "Download Logs",
"error_log_button": "Select Error Log",
"error_logs_modal_title": "Error Request Logs",
"error_logs_description": "Pick an error request log file to download (only generated when request logging is off).",
"error_logs_empty": "No error request log files found",
"error_logs_load_error": "Failed to load error log list",
"error_logs_size": "Size",
"error_logs_modified": "Last modified",
"error_logs_download": "Download",
"error_log_download_success": "Error log downloaded successfully",
"empty_title": "No Logs Available",
"empty_desc": "When \"Enable logging to file\" is enabled, logs will be displayed here",
"log_content": "Log Content",
"loading": "Loading logs...",
"load_error": "Failed to load logs",
"clear_confirm": "Are you sure you want to clear all logs? This action cannot be undone!",
"clear_success": "Logs cleared successfully",
"download_success": "Logs downloaded successfully",
"auto_refresh": "Auto Refresh",
"auto_refresh_enabled": "Auto refresh enabled",
"auto_refresh_disabled": "Auto refresh disabled",
"search_placeholder": "Search logs by content or keyword",
"search_empty_title": "No matching logs found",
"search_empty_desc": "Try a different keyword or clear the search filter.",
"lines": "lines",
"removed": "Removed",
"upgrade_required_title": "Please Upgrade CLI Proxy API",
"upgrade_required_desc": "The current server version does not support the logs viewing feature. Please upgrade to the latest version of CLI Proxy API to use this feature."
},
"config_management": {
"title": "Config Management",
"editor_title": "Configuration File",
"reload": "Reload",
"save": "Save",
"description": "View and edit the server-side config.yaml file. Validate the syntax before saving.",
"status_idle": "Waiting for action",
"status_loading": "Loading configuration...",
"status_loaded": "Configuration loaded",
"status_dirty": "Unsaved changes",
"status_disconnected": "Connect to the server to load the configuration",
"status_load_failed": "Load failed",
"status_saving": "Saving configuration...",
"status_saved": "Configuration saved",
"status_save_failed": "Save failed",
"save_success": "Configuration saved successfully",
"error_yaml_not_supported": "Server did not return YAML. Verify the /config.yaml endpoint is available.",
"editor_placeholder": "key: value"
},
"system_info": {
"title": "Management Center Info",
"connection_status_title": "Connection Status",
"api_status_label": "API Status:",
"config_status_label": "Config Status:",
"last_update_label": "Last Update:",
"cache_data": "Cache Data",
"real_time_data": "Real-time Data",
"not_loaded": "Not Loaded",
"seconds_ago": "seconds ago",
"models_title": "Available Models",
"models_desc": "Shows the /v1/models response and uses saved API keys for auth automatically.",
"models_loading": "Loading available models...",
"models_empty": "No models returned by /v1/models",
"models_error": "Failed to load model list",
"models_count": "{count} available models",
"version_check_title": "Update Check",
"version_check_desc": "Call the /latest-version endpoint to compare with the server version and see if an update is available.",
"version_current_label": "Current version",
"version_latest_label": "Latest version",
"version_check_button": "Check for updates",
"version_check_idle": "Click to check for updates",
"version_checking": "Checking for the latest version...",
"version_update_available": "An update is available: {version}",
"version_is_latest": "You are on the latest version",
"version_check_error": "Update check failed",
"version_current_missing": "Server version is unavailable; cannot compare",
"version_unknown": "Unknown"
},
"notification": {
"debug_updated": "Debug settings updated",
"proxy_updated": "Proxy settings updated",
"proxy_cleared": "Proxy settings cleared",
"retry_updated": "Retry settings updated",
"quota_switch_project_updated": "Project switch settings updated",
"quota_switch_preview_updated": "Preview model switch settings updated",
"usage_statistics_updated": "Usage statistics settings updated",
"logging_to_file_updated": "Logging settings updated",
"request_log_updated": "Request logging setting updated",
"ws_auth_updated": "WebSocket authentication setting updated",
"api_key_added": "API key added successfully",
"api_key_updated": "API key updated successfully",
"api_key_deleted": "API key deleted successfully",
"gemini_key_added": "Gemini key added successfully",
"gemini_key_updated": "Gemini key updated successfully",
"gemini_key_deleted": "Gemini key deleted successfully",
"gemini_multi_input_required": "Please enter at least one Gemini key",
"gemini_multi_failed": "Gemini bulk add failed",
"gemini_multi_summary": "Gemini bulk add finished: {success} added, {skipped} skipped, {failed} failed",
"codex_config_added": "Codex configuration added successfully",
"codex_config_updated": "Codex configuration updated successfully",
"codex_config_deleted": "Codex configuration deleted successfully",
"codex_base_url_required": "Please enter the Codex Base URL",
"claude_config_added": "Claude configuration added successfully",
"claude_config_updated": "Claude configuration updated successfully",
"claude_config_deleted": "Claude configuration deleted successfully",
"field_required": "Required fields cannot be empty",
"openai_provider_required": "Please fill in provider name and Base URL",
"openai_provider_added": "OpenAI provider added successfully",
"openai_provider_updated": "OpenAI provider updated successfully",
"openai_provider_deleted": "OpenAI provider deleted successfully",
"openai_model_name_required": "Model name is required",
"openai_test_url_required": "Please provide a valid Base URL before testing",
"openai_test_key_required": "Please add at least one API key before testing",
"openai_test_model_required": "Please select or enter a model to test",
"data_refreshed": "Data refreshed successfully",
"connection_required": "Please establish connection first",
"refresh_failed": "Refresh failed",
"update_failed": "Update failed",
"add_failed": "Add failed",
"delete_failed": "Delete failed",
"upload_failed": "Upload failed",
"download_failed": "Download failed",
"login_failed": "Login failed",
"please_enter": "Please enter",
"please_fill": "Please fill",
"provider_name_url": "provider name and Base URL",
"api_key": "API key",
"gemini_api_key": "Gemini API key",
"codex_api_key": "Codex API key",
"claude_api_key": "Claude API key",
"link_copied": "Link copied to clipboard"
},
"language": {
"switch": "Language",
"chinese": "中文",
"english": "English"
},
"theme": {
"switch": "Theme",
"light": "Light",
"dark": "Dark",
"switch_to_light": "Switch to light mode",
"switch_to_dark": "Switch to dark mode",
"auto": "Follow system"
},
"sidebar": {
"toggle_expand": "Expand sidebar",
"toggle_collapse": "Collapse sidebar"
},
"footer": {
"api_version": "CLI Proxy API Version",
"build_date": "Build Time",
"version": "Management UI Version",
"author": "Author"
}
}

656
src/i18n/locales/zh-CN.json Normal file
View File

@@ -0,0 +1,656 @@
{
"common": {
"login": "登录",
"logout": "登出",
"cancel": "取消",
"confirm": "确认",
"save": "保存",
"delete": "删除",
"edit": "编辑",
"add": "添加",
"update": "更新",
"refresh": "刷新",
"close": "关闭",
"success": "成功",
"error": "错误",
"info": "信息",
"warning": "警告",
"loading": "加载中...",
"connecting": "连接中...",
"connected": "已连接",
"disconnected": "未连接",
"connecting_status": "连接中",
"connected_status": "已连接",
"disconnected_status": "未连接",
"yes": "是",
"no": "否",
"optional": "可选",
"required": "必填",
"api_key": "密钥",
"base_url": "地址",
"proxy_url": "代理",
"alias": "别名",
"failure": "失败",
"unknown_error": "未知错误",
"copy": "复制",
"custom_headers_label": "自定义请求头",
"custom_headers_hint": "可选,设置需要附带到请求中的 HTTP 头,名称和值均不能为空。",
"custom_headers_add": "添加请求头",
"custom_headers_key_placeholder": "Header 名称,例如 X-Custom-Header",
"custom_headers_value_placeholder": "Header 值",
"model_name_placeholder": "模型名称,例如 claude-3-5-sonnet-20241022",
"model_alias_placeholder": "模型别名 (可选)"
},
"title": {
"main": "CLI Proxy API Management Center",
"login": "CLI Proxy API Management Center",
"abbr": "CPAMC"
},
"auto_login": {
"title": "正在自动登录...",
"message": "正在使用本地保存的连接信息尝试连接服务器"
},
"login": {
"subtitle": "请输入连接信息以访问管理界面",
"connection_title": "连接地址",
"connection_current": "当前地址",
"connection_auto_hint": "系统将自动使用当前访问地址进行连接",
"custom_connection_label": "自定义连接地址:",
"custom_connection_placeholder": "例如: https://example.com:8317",
"custom_connection_hint": "默认使用当前访问地址,若需要可手动输入其他地址。",
"use_current_address": "使用当前地址",
"management_key_label": "管理密钥:",
"management_key_placeholder": "请输入管理密钥",
"connect_button": "连接",
"submit_button": "登录",
"submitting": "连接中...",
"error_title": "登录失败",
"error_required": "请填写完整的连接信息",
"error_invalid": "连接失败,请检查地址和密钥"
},
"header": {
"check_connection": "检查连接",
"refresh_all": "刷新全部",
"logout": "登出"
},
"connection": {
"title": "连接信息",
"server_address": "服务器地址:",
"management_key": "管理密钥:",
"status": "连接状态:"
},
"nav": {
"basic_settings": "基础设置",
"api_keys": "API 密钥",
"ai_providers": "AI 提供商",
"auth_files": "认证文件",
"oauth": "OAuth 登录",
"usage_stats": "使用统计",
"config_management": "配置管理",
"logs": "日志查看",
"system_info": "中心信息"
},
"basic_settings": {
"title": "基础设置",
"debug_title": "调试模式",
"debug_enable": "启用调试模式",
"proxy_title": "代理设置",
"proxy_url_label": "代理 URL:",
"proxy_url_placeholder": "例如: socks5://user:pass@127.0.0.1:1080/",
"proxy_update": "更新",
"proxy_clear": "清空",
"retry_title": "请求重试",
"retry_count_label": "重试次数:",
"retry_update": "更新",
"quota_title": "配额超出行为",
"quota_switch_project": "自动切换项目",
"quota_switch_preview": "切换到预览模型",
"usage_statistics_title": "使用统计",
"usage_statistics_enable": "启用使用统计",
"logging_title": "日志记录",
"logging_to_file_enable": "启用日志记录到文件",
"request_log_enable": "启用请求日志",
"ws_auth_title": "WebSocket 鉴权",
"ws_auth_enable": "启用 /ws/* 鉴权"
},
"api_keys": {
"title": "API 密钥管理",
"proxy_auth_title": "代理服务认证密钥",
"add_button": "添加密钥",
"empty_title": "暂无API密钥",
"empty_desc": "点击上方按钮添加第一个密钥",
"item_title": "API密钥",
"add_modal_title": "添加API密钥",
"add_modal_key_label": "API密钥:",
"add_modal_key_placeholder": "请输入API密钥",
"edit_modal_title": "编辑API密钥",
"edit_modal_key_label": "API密钥:",
"delete_confirm": "确定要删除这个API密钥吗"
},
"ai_providers": {
"title": "AI 提供商配置",
"gemini_title": "Gemini API 密钥",
"gemini_add_button": "添加密钥",
"gemini_empty_title": "暂无Gemini密钥",
"gemini_empty_desc": "点击上方按钮添加第一个密钥",
"gemini_item_title": "Gemini密钥",
"gemini_add_modal_title": "添加Gemini API密钥",
"gemini_add_modal_key_label": "API密钥",
"gemini_add_modal_key_placeholder": "输入 Gemini API 密钥",
"gemini_add_modal_key_hint": "逐条输入密钥,可同时指定可选 Base URL。",
"gemini_keys_add_btn": "添加密钥",
"gemini_base_url_placeholder": "可选 Base URL如 https://generativelanguage.googleapis.com",
"gemini_edit_modal_title": "编辑Gemini API密钥",
"gemini_edit_modal_key_label": "API密钥:",
"gemini_delete_confirm": "确定要删除这个Gemini密钥吗",
"excluded_models_label": "排除的模型 (可选):",
"excluded_models_placeholder": "用逗号或换行分隔,例如: gemini-1.5-pro, gemini-1.5-flash",
"excluded_models_hint": "留空表示不过滤;保存时会自动去重并忽略空白。",
"excluded_models_count": "排除 {count} 个模型",
"codex_title": "Codex API 配置",
"codex_add_button": "添加配置",
"codex_empty_title": "暂无Codex配置",
"codex_empty_desc": "点击上方按钮添加第一个配置",
"codex_item_title": "Codex配置",
"codex_add_modal_title": "添加Codex API配置",
"codex_add_modal_key_label": "API密钥:",
"codex_add_modal_key_placeholder": "请输入Codex API密钥",
"codex_add_modal_url_label": "Base URL (必填):",
"codex_add_modal_url_placeholder": "例如: https://api.example.com",
"codex_add_modal_proxy_label": "代理 URL (可选):",
"codex_add_modal_proxy_placeholder": "例如: socks5://proxy.example.com:1080",
"codex_edit_modal_title": "编辑Codex API配置",
"codex_edit_modal_key_label": "API密钥:",
"codex_edit_modal_url_label": "Base URL (必填):",
"codex_edit_modal_proxy_label": "代理 URL (可选):",
"codex_delete_confirm": "确定要删除这个Codex配置吗",
"claude_title": "Claude API 配置",
"claude_add_button": "添加配置",
"claude_empty_title": "暂无Claude配置",
"claude_empty_desc": "点击上方按钮添加第一个配置",
"claude_item_title": "Claude配置",
"claude_add_modal_title": "添加Claude API配置",
"claude_add_modal_key_label": "API密钥:",
"claude_add_modal_key_placeholder": "请输入Claude API密钥",
"claude_add_modal_url_label": "Base URL (可选):",
"claude_add_modal_url_placeholder": "例如: https://api.anthropic.com",
"claude_add_modal_proxy_label": "代理 URL (可选):",
"claude_add_modal_proxy_placeholder": "例如: socks5://proxy.example.com:1080",
"claude_edit_modal_title": "编辑Claude API配置",
"claude_edit_modal_key_label": "API密钥:",
"claude_edit_modal_url_label": "Base URL (可选):",
"claude_edit_modal_proxy_label": "代理 URL (可选):",
"claude_delete_confirm": "确定要删除这个Claude配置吗",
"claude_models_label": "自定义模型 (可选):",
"claude_models_hint": "为空表示使用全部模型;可填写 name[, alias] 以限制或重命名模型。",
"claude_models_add_btn": "添加模型",
"claude_models_count": "模型数量",
"openai_title": "OpenAI 兼容提供商",
"openai_add_button": "添加提供商",
"openai_empty_title": "暂无OpenAI兼容提供商",
"openai_empty_desc": "点击上方按钮添加第一个提供商",
"openai_add_modal_title": "添加OpenAI兼容提供商",
"openai_add_modal_name_label": "提供商名称:",
"openai_add_modal_name_placeholder": "例如: openrouter",
"openai_add_modal_url_label": "Base URL:",
"openai_add_modal_url_placeholder": "例如: https://openrouter.ai/api/v1",
"openai_add_modal_keys_label": "API密钥",
"openai_edit_modal_keys_label": "API密钥",
"openai_keys_hint": "每个密钥可搭配一个可选代理地址,更便于管理。",
"openai_keys_add_btn": "添加密钥",
"openai_key_placeholder": "输入 sk- 开头的密钥",
"openai_proxy_placeholder": "可选代理 URL (如 socks5://...)",
"openai_add_modal_models_label": "模型列表 (name[, alias] 每行一个):",
"openai_models_hint": "示例gpt-4o-mini 或 moonshotai/kimi-k2:free, kimi-k2",
"openai_model_name_placeholder": "模型名称,如 moonshotai/kimi-k2:free",
"openai_model_alias_placeholder": "模型别名 (可选)",
"openai_models_add_btn": "添加模型",
"openai_models_fetch_button": "从 /v1/models 获取",
"openai_models_fetch_title": "从 /v1/models 选择模型",
"openai_models_fetch_hint": "使用上方 Base URL 调用 /v1/models 端点,附带首个 API KeyBearer与自定义请求头。",
"openai_models_fetch_url_label": "请求地址",
"openai_models_fetch_refresh": "重新获取",
"openai_models_fetch_loading": "正在从 /v1/models 获取模型列表...",
"openai_models_fetch_empty": "未获取到模型,请检查端点或鉴权信息。",
"openai_models_fetch_error": "获取模型失败",
"openai_models_fetch_back": "返回编辑",
"openai_models_fetch_apply": "添加所选模型",
"openai_models_search_label": "搜索模型",
"openai_models_search_placeholder": "按名称、别名或描述筛选",
"openai_models_search_empty": "没有匹配的模型,请更换关键字试试。",
"openai_models_fetch_invalid_url": "请先填写有效的 Base URL",
"openai_models_fetch_added": "已添加 {count} 个新模型",
"openai_edit_modal_title": "编辑OpenAI兼容提供商",
"openai_edit_modal_name_label": "提供商名称:",
"openai_edit_modal_url_label": "Base URL:",
"openai_edit_modal_models_label": "模型列表 (name[, alias] 每行一个):",
"openai_delete_confirm": "确定要删除这个OpenAI提供商吗",
"openai_keys_count": "密钥数量",
"openai_models_count": "模型数量",
"openai_test_title": "连通性测试",
"openai_test_hint": "使用当前配置向 /v1/chat/completions 请求,验证是否可用。",
"openai_test_model_placeholder": "选择或输入要测试的模型",
"openai_test_action": "发送测试",
"openai_test_running": "正在发送测试请求...",
"openai_test_success": "测试成功,模型可用。",
"openai_test_failed": "测试失败",
"openai_test_select_placeholder": "从当前模型列表选择",
"openai_test_select_empty": "当前未配置模型,可直接输入"
},
"auth_files": {
"title": "认证文件管理",
"title_section": "认证文件",
"description": "这里集中管理 CLI Proxy 支持的所有 JSON 认证文件(如 Qwen、Gemini、Vertex 等),上传后即可在运行时启用相应的 AI 服务。",
"upload_button": "上传文件",
"delete_all_button": "删除全部",
"empty_title": "暂无认证文件",
"empty_desc": "点击上方按钮上传第一个文件",
"search_empty_title": "没有匹配的配置文件",
"search_empty_desc": "请调整筛选条件或清空搜索关键字再试一次。",
"file_size": "大小",
"file_modified": "修改时间",
"download_button": "下载",
"delete_button": "删除",
"delete_confirm": "确定要删除文件",
"delete_all_confirm": "确定要删除所有认证文件吗?此操作不可恢复!",
"delete_filtered_confirm": "确定要删除筛选出的 {type} 认证文件吗?此操作不可恢复!",
"upload_error_json": "只能上传JSON文件",
"upload_success": "文件上传成功",
"download_success": "文件下载成功",
"delete_success": "文件删除成功",
"delete_all_success": "成功删除",
"delete_filtered_success": "成功删除 {count} 个 {type} 认证文件",
"delete_filtered_partial": "{type} 认证文件删除完成,成功 {success} 个,失败 {failed} 个",
"delete_filtered_none": "当前筛选类型 ({type}) 下没有可删除的认证文件",
"files_count": "个文件",
"pagination_prev": "上一页",
"pagination_next": "下一页",
"pagination_info": "第 {current} / {total} 页 · 共 {count} 个文件",
"search_label": "搜索配置文件",
"search_placeholder": "输入名称、类型或提供方关键字",
"page_size_label": "单页数量",
"page_size_unit": "个/页",
"filter_all": "全部",
"filter_qwen": "Qwen",
"filter_gemini": "Gemini",
"filter_gemini-cli": "GeminiCLI",
"filter_aistudio": "AIStudio",
"filter_claude": "Claude",
"filter_codex": "Codex",
"filter_antigravity": "Antigravity",
"filter_iflow": "iFlow",
"filter_vertex": "Vertex",
"filter_empty": "空文件",
"filter_unknown": "其他",
"type_qwen": "Qwen",
"type_gemini": "Gemini",
"type_gemini-cli": "GeminiCLI",
"type_aistudio": "AIStudio",
"type_claude": "Claude",
"type_codex": "Codex",
"type_antigravity": "Antigravity",
"type_iflow": "iFlow",
"type_vertex": "Vertex",
"type_empty": "空文件",
"type_unknown": "其他"
},
"vertex_import": {
"title": "Vertex AI 凭证导入",
"description": "上传 Google 服务账号 JSON使用 CLI vertex-import 同步规则写入 auth-dir/vertex-<project>.json。",
"location_label": "目标区域 (可选)",
"location_placeholder": "us-central1",
"location_hint": "留空表示使用默认区域 us-central1。",
"file_label": "服务账号密钥 JSON",
"file_hint": "仅支持 Google Cloud service account key JSON 文件,私钥会自动规范化。",
"file_placeholder": "尚未选择文件",
"choose_file": "选择文件",
"import_button": "导入 Vertex 凭证",
"file_required": "请先选择 .json 凭证文件",
"success": "Vertex 凭证导入成功",
"result_title": "凭证已保存",
"result_project": "项目 ID",
"result_email": "服务账号",
"result_location": "区域",
"result_file": "存储文件"
},
"oauth_excluded": {
"title": "OAuth 排除列表",
"description": "按提供商分列展示,点击卡片编辑或删除;支持 * 通配符,范围跟随上方的配置文件过滤标签。",
"add": "新增排除",
"add_title": "新增提供商排除列表",
"edit_title": "编辑 {provider} 的排除列表",
"refresh": "刷新",
"refreshing": "刷新中...",
"provider_label": "提供商",
"provider_auto": "跟随当前过滤",
"provider_placeholder": "例如 gemini-cli / openai",
"provider_hint": "默认选中当前筛选的提供商,也可直接输入或选择其他名称。",
"models_label": "排除的模型",
"models_placeholder": "gpt-4.1-mini\n*-preview",
"models_hint": "逗号或换行分隔;留空保存将删除该提供商记录;支持 * 通配符。",
"save": "保存/更新",
"saving": "正在保存...",
"save_success": "排除列表已更新",
"save_failed": "更新排除列表失败",
"delete": "删除提供商",
"delete_confirm": "确定要删除 {provider} 的排除列表吗?",
"delete_success": "已删除该提供商的排除列表",
"delete_failed": "删除排除列表失败",
"deleting": "正在删除...",
"no_models": "未配置排除模型",
"model_count": "排除 {count} 个模型",
"list_empty_all": "暂无任何提供商的排除列表,点击“新增排除”创建。",
"list_empty_filtered": "当前筛选下没有排除项,点击“新增排除”添加。",
"disconnected": "请先连接服务器以查看排除列表",
"load_failed": "加载排除列表失败",
"provider_required": "请先填写提供商名称",
"scope_all": "当前范围:全局(显示所有提供商)",
"scope_provider": "当前范围:{provider}"
},
"auth_login": {
"codex_oauth_title": "Codex OAuth",
"codex_oauth_button": "开始 Codex 登录",
"codex_oauth_hint": "通过 OAuth 流程登录 Codex 服务,自动获取并保存认证文件。",
"codex_oauth_url_label": "授权链接:",
"codex_open_link": "打开链接",
"codex_copy_link": "复制链接",
"codex_oauth_status_waiting": "等待认证中...",
"codex_oauth_status_success": "认证成功!",
"codex_oauth_status_error": "认证失败:",
"codex_oauth_start_error": "启动 Codex OAuth 失败:",
"codex_oauth_polling_error": "检查认证状态失败:",
"anthropic_oauth_title": "Anthropic OAuth",
"anthropic_oauth_button": "开始 Anthropic 登录",
"anthropic_oauth_hint": "通过 OAuth 流程登录 Anthropic (Claude) 服务,自动获取并保存认证文件。",
"anthropic_oauth_url_label": "授权链接:",
"anthropic_open_link": "打开链接",
"anthropic_copy_link": "复制链接",
"anthropic_oauth_status_waiting": "等待认证中...",
"anthropic_oauth_status_success": "认证成功!",
"anthropic_oauth_status_error": "认证失败:",
"anthropic_oauth_start_error": "启动 Anthropic OAuth 失败:",
"anthropic_oauth_polling_error": "检查认证状态失败:",
"antigravity_oauth_title": "Antigravity OAuth",
"antigravity_oauth_button": "开始 Antigravity 登录",
"antigravity_oauth_hint": "通过 OAuth 流程登录 AntigravityGoogle 账号)服务,自动获取并保存认证文件。",
"antigravity_oauth_url_label": "授权链接:",
"antigravity_open_link": "打开链接",
"antigravity_copy_link": "复制链接",
"antigravity_oauth_status_waiting": "等待认证中...",
"antigravity_oauth_status_success": "认证成功!",
"antigravity_oauth_status_error": "认证失败:",
"antigravity_oauth_start_error": "启动 Antigravity OAuth 失败:",
"antigravity_oauth_polling_error": "检查认证状态失败:",
"gemini_cli_oauth_title": "Gemini CLI OAuth",
"gemini_cli_oauth_button": "开始 Gemini CLI 登录",
"gemini_cli_oauth_hint": "通过 OAuth 流程登录 Google Gemini CLI 服务,自动获取并保存认证文件。",
"gemini_cli_project_id_label": "Google Cloud 项目 ID (可选):",
"gemini_cli_project_id_placeholder": "输入 Google Cloud 项目 ID (可选)",
"gemini_cli_project_id_hint": "如果指定了项目 ID将使用该项目的认证信息。",
"gemini_cli_oauth_url_label": "授权链接:",
"gemini_cli_open_link": "打开链接",
"gemini_cli_copy_link": "复制链接",
"gemini_cli_oauth_status_waiting": "等待认证中...",
"gemini_cli_oauth_status_success": "认证成功!",
"gemini_cli_oauth_status_error": "认证失败:",
"gemini_cli_oauth_start_error": "启动 Gemini CLI OAuth 失败:",
"gemini_cli_oauth_polling_error": "检查认证状态失败:",
"qwen_oauth_title": "Qwen OAuth",
"qwen_oauth_button": "开始 Qwen 登录",
"qwen_oauth_hint": "通过设备授权流程登录 Qwen 服务,自动获取并保存认证文件。",
"qwen_oauth_url_label": "授权链接:",
"qwen_open_link": "打开链接",
"qwen_copy_link": "复制链接",
"qwen_oauth_status_waiting": "等待认证中...",
"qwen_oauth_status_success": "认证成功!",
"qwen_oauth_status_error": "认证失败:",
"qwen_oauth_start_error": "启动 Qwen OAuth 失败:",
"qwen_oauth_polling_error": "检查认证状态失败:",
"missing_state": "无法获取认证状态参数",
"iflow_oauth_title": "iFlow OAuth",
"iflow_oauth_button": "开始 iFlow 登录",
"iflow_oauth_hint": "通过 OAuth 流程登录 iFlow 服务,自动获取并保存认证文件。",
"iflow_oauth_url_label": "授权链接:",
"iflow_open_link": "打开链接",
"iflow_copy_link": "复制链接",
"iflow_oauth_status_waiting": "等待认证中...",
"iflow_oauth_status_success": "认证成功!",
"iflow_oauth_status_error": "认证失败:",
"iflow_oauth_start_error": "启动 iFlow OAuth 失败:",
"iflow_oauth_polling_error": "检查认证状态失败:",
"iflow_cookie_title": "iFlow Cookie 登录",
"iflow_cookie_label": "Cookie 内容:",
"iflow_cookie_placeholder": "粘贴浏览器中的 Cookie例如 sessionid=...;",
"iflow_cookie_hint": "直接提交 Cookie 以完成登录(无需打开授权链接),服务端将自动保存凭据。",
"iflow_cookie_button": "提交 Cookie 登录",
"iflow_cookie_status_success": "Cookie 登录成功,凭据已保存。",
"iflow_cookie_status_error": "Cookie 登录失败:",
"iflow_cookie_start_error": "提交 Cookie 登录失败:",
"iflow_cookie_required": "请先填写 Cookie 内容",
"iflow_cookie_result_title": "Cookie 登录结果",
"iflow_cookie_result_email": "账号",
"iflow_cookie_result_expired": "过期时间",
"iflow_cookie_result_path": "保存路径",
"iflow_cookie_result_type": "类型"
},
"usage_stats": {
"title": "使用统计",
"total_requests": "总请求数",
"success_requests": "成功请求",
"failed_requests": "失败请求",
"total_tokens": "总Token数",
"cached_tokens": "缓存 Token 数",
"reasoning_tokens": "思考 Token 数",
"rpm_30m": "RPM近30分钟",
"tpm_30m": "TPM近30分钟",
"requests_trend": "请求趋势",
"tokens_trend": "Token 使用趋势",
"api_details": "API 详细统计",
"by_hour": "按小时",
"by_day": "按天",
"refresh": "刷新",
"chart_line_label_1": "曲线 1",
"chart_line_label_2": "曲线 2",
"chart_line_label_3": "曲线 3",
"chart_line_label_4": "曲线 4",
"chart_line_label_5": "曲线 5",
"chart_line_label_6": "曲线 6",
"chart_line_label_7": "曲线 7",
"chart_line_label_8": "曲线 8",
"chart_line_label_9": "曲线 9",
"chart_line_hidden": "不显示",
"chart_line_actions_label": "曲线数量",
"chart_line_add": "增加曲线",
"chart_line_all": "全部",
"chart_line_delete": "删除曲线",
"chart_line_hint": "最多同时显示 9 条模型曲线",
"no_data": "暂无数据",
"loading_error": "加载失败",
"api_endpoint": "API端点",
"requests_count": "请求次数",
"tokens_count": "Token数量",
"models": "模型统计",
"success_rate": "成功率",
"total_cost": "总花费",
"total_cost_hint": "基于已设置的模型单价",
"model_price_title": "模型价格",
"model_price_reset": "清除价格",
"model_price_model_label": "选择模型",
"model_price_select_placeholder": "选择模型",
"model_price_select_hint": "模型列表来自使用统计明细",
"model_price_prompt": "提示价格 ($/1M tokens)",
"model_price_completion": "补全价格 ($/1M tokens)",
"model_price_save": "保存价格",
"model_price_empty": "暂未设置任何模型价格",
"model_price_model": "模型",
"model_price_saved": "模型价格已保存",
"model_price_model_required": "请选择要设置价格的模型",
"cost_trend": "花费统计",
"cost_axis_label": "花费 ($)",
"cost_need_price": "请先设置模型价格",
"cost_need_usage": "暂无使用数据,无法计算花费",
"cost_no_data": "没有可计算的花费数据"
},
"stats": {
"success": "成功",
"failure": "失败"
},
"logs": {
"title": "日志查看",
"refresh_button": "刷新日志",
"clear_button": "清空日志",
"download_button": "下载日志",
"error_log_button": "选择错误日志",
"error_logs_modal_title": "错误请求日志",
"error_logs_description": "请选择要下载的错误请求日志文件(仅在关闭请求日志时生成)。",
"error_logs_empty": "暂无错误请求日志文件",
"error_logs_load_error": "加载错误日志列表失败",
"error_logs_size": "大小",
"error_logs_modified": "最后修改",
"error_logs_download": "下载",
"error_log_download_success": "错误日志下载成功",
"empty_title": "暂无日志记录",
"empty_desc": "当启用\"日志记录到文件\"功能后,日志将显示在这里",
"log_content": "日志内容",
"loading": "正在加载日志...",
"load_error": "加载日志失败",
"clear_confirm": "确定要清空所有日志吗?此操作不可恢复!",
"clear_success": "日志已清空",
"download_success": "日志下载成功",
"auto_refresh": "自动刷新",
"auto_refresh_enabled": "自动刷新已开启",
"auto_refresh_disabled": "自动刷新已关闭",
"search_placeholder": "搜索日志内容或关键字",
"search_empty_title": "未找到匹配的日志",
"search_empty_desc": "尝试更换关键字或清空搜索条件。",
"lines": "行",
"removed": "已删除",
"upgrade_required_title": "需要升级 CLI Proxy API",
"upgrade_required_desc": "当前服务器版本不支持日志查看功能,请升级到最新版本的 CLI Proxy API 以使用此功能。"
},
"config_management": {
"title": "配置管理",
"editor_title": "配置文件",
"reload": "重新加载",
"save": "保存",
"description": "查看并编辑服务器上的 config.yaml 配置文件。保存前请确认语法正确。",
"status_idle": "等待操作",
"status_loading": "加载配置中...",
"status_loaded": "配置已加载",
"status_dirty": "有未保存的更改",
"status_disconnected": "请先连接服务器以加载配置",
"status_load_failed": "加载失败",
"status_saving": "正在保存配置...",
"status_saved": "配置保存完成",
"status_save_failed": "保存失败",
"save_success": "配置已保存",
"error_yaml_not_supported": "服务器未返回 YAML 格式,请确认 /config.yaml 接口可用",
"editor_placeholder": "key: value"
},
"system_info": {
"title": "管理中心信息",
"connection_status_title": "连接状态",
"api_status_label": "API 状态:",
"config_status_label": "配置状态:",
"last_update_label": "最后更新:",
"cache_data": "缓存数据",
"real_time_data": "实时数据",
"not_loaded": "未加载",
"seconds_ago": "秒前",
"models_title": "可用模型列表",
"models_desc": "展示 /v1/models 返回的模型,并自动使用服务器保存的 API Key 进行鉴权。",
"models_loading": "正在加载可用模型...",
"models_empty": "未从 /v1/models 获取到模型数据",
"models_error": "获取模型列表失败",
"models_count": "可用模型 {count} 个",
"version_check_title": "版本检查",
"version_check_desc": "调用 /latest-version 接口比对服务器版本,提示是否有可用更新。",
"version_current_label": "当前版本",
"version_latest_label": "最新版本",
"version_check_button": "检查更新",
"version_check_idle": "点击检查更新",
"version_checking": "正在检查最新版本...",
"version_update_available": "有新版本可用:{version}",
"version_is_latest": "当前已是最新版本",
"version_check_error": "检查更新失败",
"version_current_missing": "未获取到服务器版本号,暂无法比对",
"version_unknown": "未知"
},
"notification": {
"debug_updated": "调试设置已更新",
"proxy_updated": "代理设置已更新",
"proxy_cleared": "代理设置已清空",
"retry_updated": "重试设置已更新",
"quota_switch_project_updated": "项目切换设置已更新",
"quota_switch_preview_updated": "预览模型切换设置已更新",
"usage_statistics_updated": "使用统计设置已更新",
"logging_to_file_updated": "日志记录设置已更新",
"request_log_updated": "请求日志设置已更新",
"ws_auth_updated": "WebSocket 鉴权设置已更新",
"api_key_added": "API密钥添加成功",
"api_key_updated": "API密钥更新成功",
"api_key_deleted": "API密钥删除成功",
"gemini_key_added": "Gemini密钥添加成功",
"gemini_key_updated": "Gemini密钥更新成功",
"gemini_key_deleted": "Gemini密钥删除成功",
"gemini_multi_input_required": "请先输入至少一个Gemini密钥",
"gemini_multi_failed": "Gemini密钥批量添加失败",
"gemini_multi_summary": "Gemini批量添加完成成功 {success},跳过 {skipped},失败 {failed}",
"codex_config_added": "Codex配置添加成功",
"codex_config_updated": "Codex配置更新成功",
"codex_config_deleted": "Codex配置删除成功",
"codex_base_url_required": "请填写Codex Base URL",
"claude_config_added": "Claude配置添加成功",
"claude_config_updated": "Claude配置更新成功",
"claude_config_deleted": "Claude配置删除成功",
"field_required": "必填字段不能为空",
"openai_provider_required": "请填写提供商名称和Base URL",
"openai_provider_added": "OpenAI提供商添加成功",
"openai_provider_updated": "OpenAI提供商更新成功",
"openai_provider_deleted": "OpenAI提供商删除成功",
"openai_model_name_required": "请填写模型名称",
"openai_test_url_required": "请先填写有效的 Base URL 以进行测试",
"openai_test_key_required": "请至少填写一个 API 密钥以进行测试",
"openai_test_model_required": "请选择或输入要测试的模型",
"data_refreshed": "数据刷新成功",
"connection_required": "请先建立连接",
"refresh_failed": "刷新失败",
"update_failed": "更新失败",
"add_failed": "添加失败",
"delete_failed": "删除失败",
"upload_failed": "上传失败",
"download_failed": "下载失败",
"login_failed": "登录失败",
"please_enter": "请输入",
"please_fill": "请填写",
"provider_name_url": "提供商名称和Base URL",
"api_key": "API密钥",
"gemini_api_key": "Gemini API密钥",
"codex_api_key": "Codex API密钥",
"claude_api_key": "Claude API密钥",
"link_copied": "链接已复制到剪贴板"
},
"language": {
"switch": "语言",
"chinese": "中文",
"english": "English"
},
"theme": {
"switch": "主题",
"light": "亮色",
"dark": "暗色",
"switch_to_light": "切换到亮色模式",
"switch_to_dark": "切换到暗色模式",
"auto": "跟随系统"
},
"sidebar": {
"toggle_expand": "展开侧边栏",
"toggle_collapse": "收起侧边栏"
},
"footer": {
"api_version": "CLI Proxy API 版本",
"build_date": "构建时间",
"version": "管理中心版本",
"author": "作者"
}
}

68
src/index.css Normal file
View File

@@ -0,0 +1,68 @@
:root {
font-family: system-ui, Avenir, Helvetica, Arial, sans-serif;
line-height: 1.5;
font-weight: 400;
color-scheme: light dark;
color: rgba(255, 255, 255, 0.87);
background-color: #242424;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
a {
font-weight: 500;
color: #646cff;
text-decoration: inherit;
}
a:hover {
color: #535bf2;
}
body {
margin: 0;
display: flex;
place-items: center;
min-width: 320px;
min-height: 100vh;
}
h1 {
font-size: 3.2em;
line-height: 1.1;
}
button {
border-radius: 8px;
border: 1px solid transparent;
padding: 0.6em 1.2em;
font-size: 1em;
font-weight: 500;
font-family: inherit;
background-color: #1a1a1a;
cursor: pointer;
transition: border-color 0.25s;
}
button:hover {
border-color: #646cff;
}
button:focus,
button:focus-visible {
outline: 4px auto -webkit-focus-ring-color;
}
@media (prefers-color-scheme: light) {
:root {
color: #213547;
background-color: #ffffff;
}
a:hover {
color: #747bff;
}
button {
background-color: #f9f9f9;
}
}

10
src/main.tsx Normal file
View File

@@ -0,0 +1,10 @@
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import '@/styles/global.scss';
import App from './App.tsx';
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>
);

File diff suppressed because it is too large Load Diff

View File

@@ -1,390 +0,0 @@
export const apiKeysModule = {
// 加载API密钥
async loadApiKeys() {
try {
const data = await this.makeRequest('/api-keys');
const apiKeysValue = data?.['api-keys'] || [];
const keys = Array.isArray(apiKeysValue) ? apiKeysValue : [];
this.renderApiKeys(keys);
} catch (error) {
console.error('加载API密钥失败:', error);
}
},
// 渲染API密钥列表
renderApiKeys(keys) {
const container = document.getElementById('api-keys-list');
if (keys.length === 0) {
container.innerHTML = `
<div class="empty-state">
<i class="fas fa-key"></i>
<h3>${i18n.t('api_keys.empty_title')}</h3>
<p>${i18n.t('api_keys.empty_desc')}</p>
</div>
`;
return;
}
const rows = keys.map((key, index) => {
const normalizedKey = typeof key === 'string' ? key : String(key ?? '');
const maskedDisplay = this.escapeHtml(this.maskApiKey(normalizedKey));
const keyArgument = encodeURIComponent(normalizedKey);
return `
<div class="key-table-row">
<div class="key-badge">#${index + 1}</div>
<div class="key-table-value">
<div class="item-title">${i18n.t('api_keys.item_title')}</div>
<div class="key-value">${maskedDisplay}</div>
</div>
<div class="item-actions compact">
<button class="btn btn-secondary" data-action="edit-api-key" data-index="${index}" data-key="${keyArgument}">
<i class="fas fa-edit"></i>
</button>
<button class="btn btn-danger" data-action="delete-api-key" data-index="${index}">
<i class="fas fa-trash"></i>
</button>
</div>
</div>
`;
}).join('');
container.innerHTML = `
<div class="key-table">
${rows}
</div>
`;
this.bindApiKeyListEvents(container);
},
// 注意: escapeHtml, maskApiKey, normalizeArrayResponse
// 现在由 app.js 通过工具模块提供,通过 this 访问
// 添加一行自定义请求头输入
addHeaderField(wrapperId, header = {}) {
const wrapper = document.getElementById(wrapperId);
if (!wrapper) return;
const row = document.createElement('div');
row.className = 'header-input-row';
const keyValue = typeof header.key === 'string' ? header.key : '';
const valueValue = typeof header.value === 'string' ? header.value : '';
row.innerHTML = `
<div class="input-group header-input-group">
<input type="text" class="header-key-input" placeholder="${i18n.t('common.custom_headers_key_placeholder')}" value="${this.escapeHtml(keyValue)}">
<span class="header-separator">:</span>
<input type="text" class="header-value-input" placeholder="${i18n.t('common.custom_headers_value_placeholder')}" value="${this.escapeHtml(valueValue)}">
<button type="button" class="btn btn-small btn-danger header-remove-btn"><i class="fas fa-trash"></i></button>
</div>
`;
const removeBtn = row.querySelector('.header-remove-btn');
if (removeBtn) {
removeBtn.addEventListener('click', () => {
wrapper.removeChild(row);
if (wrapper.childElementCount === 0) {
this.addHeaderField(wrapperId);
}
});
}
wrapper.appendChild(row);
},
// 填充自定义请求头输入
populateHeaderFields(wrapperId, headers = null) {
const wrapper = document.getElementById(wrapperId);
if (!wrapper) return;
wrapper.innerHTML = '';
const entries = (headers && typeof headers === 'object')
? Object.entries(headers).filter(([key, value]) => key && value !== undefined && value !== null)
: [];
if (!entries.length) {
this.addHeaderField(wrapperId);
return;
}
entries.forEach(([key, value]) => this.addHeaderField(wrapperId, { key, value: String(value ?? '') }));
},
// 收集自定义请求头输入
collectHeaderInputs(wrapperId) {
const wrapper = document.getElementById(wrapperId);
if (!wrapper) return null;
const rows = Array.from(wrapper.querySelectorAll('.header-input-row'));
const headers = {};
rows.forEach(row => {
const keyInput = row.querySelector('.header-key-input');
const valueInput = row.querySelector('.header-value-input');
const key = keyInput ? keyInput.value.trim() : '';
const value = valueInput ? valueInput.value.trim() : '';
if (key && value) {
headers[key] = value;
}
});
return Object.keys(headers).length ? headers : null;
},
addApiKeyEntryField(wrapperId, entry = {}) {
const wrapper = document.getElementById(wrapperId);
if (!wrapper) return;
const row = document.createElement('div');
row.className = 'api-key-input-row';
const keyValue = typeof entry?.['api-key'] === 'string' ? entry['api-key'] : '';
const proxyValue = typeof entry?.['proxy-url'] === 'string' ? entry['proxy-url'] : '';
row.innerHTML = `
<div class="input-group api-key-input-group">
<input type="text" class="api-key-value-input" placeholder="${i18n.t('ai_providers.openai_key_placeholder')}" value="${this.escapeHtml(keyValue)}">
<input type="text" class="api-key-proxy-input" placeholder="${i18n.t('ai_providers.openai_proxy_placeholder')}" value="${this.escapeHtml(proxyValue)}">
<button type="button" class="btn btn-small btn-danger api-key-remove-btn"><i class="fas fa-trash"></i></button>
</div>
`;
const removeBtn = row.querySelector('.api-key-remove-btn');
if (removeBtn) {
removeBtn.addEventListener('click', () => {
wrapper.removeChild(row);
if (wrapper.childElementCount === 0) {
this.addApiKeyEntryField(wrapperId);
}
});
}
wrapper.appendChild(row);
},
populateApiKeyEntryFields(wrapperId, entries = []) {
const wrapper = document.getElementById(wrapperId);
if (!wrapper) return;
wrapper.innerHTML = '';
if (!Array.isArray(entries) || entries.length === 0) {
this.addApiKeyEntryField(wrapperId);
return;
}
entries.forEach(entry => this.addApiKeyEntryField(wrapperId, entry));
},
collectApiKeyEntryInputs(wrapperId) {
const wrapper = document.getElementById(wrapperId);
if (!wrapper) return [];
const rows = Array.from(wrapper.querySelectorAll('.api-key-input-row'));
const entries = [];
rows.forEach(row => {
const keyInput = row.querySelector('.api-key-value-input');
const proxyInput = row.querySelector('.api-key-proxy-input');
const key = keyInput ? keyInput.value.trim() : '';
const proxy = proxyInput ? proxyInput.value.trim() : '';
if (key) {
entries.push({ 'api-key': key, 'proxy-url': proxy });
}
});
return entries;
},
// 规范化并写入请求头
applyHeadersToConfig(target, headers) {
if (!target) {
return;
}
if (headers && typeof headers === 'object' && Object.keys(headers).length) {
target.headers = { ...headers };
} else {
delete target.headers;
}
},
// 渲染请求头徽章
renderHeaderBadges(headers) {
if (!headers || typeof headers !== 'object') {
return '';
}
const entries = Object.entries(headers).filter(([key, value]) => key && value !== undefined && value !== null && value !== '');
if (!entries.length) {
return '';
}
const badges = entries.map(([key, value]) => `
<span class="header-badge"><strong>${this.escapeHtml(key)}:</strong> ${this.escapeHtml(String(value))}</span>
`).join('');
return `
<div class="item-subtitle header-badges-wrapper">
<span class="header-badges-label">${i18n.t('common.custom_headers_label')}:</span>
<div class="header-badge-list">
${badges}
</div>
</div>
`;
},
// 构造Codex配置保持未展示的字段
buildCodexConfig(apiKey, baseUrl, proxyUrl, original = {}, headers = null, excludedModels = null) {
const result = {
...original,
'api-key': apiKey,
'base-url': baseUrl || '',
'proxy-url': proxyUrl || ''
};
this.applyHeadersToConfig(result, headers);
if (Array.isArray(excludedModels)) {
result['excluded-models'] = excludedModels;
}
return result;
},
// 显示添加API密钥模态框
showAddApiKeyModal() {
const modal = document.getElementById('modal');
const modalBody = document.getElementById('modal-body');
modalBody.innerHTML = `
<h3>${i18n.t('api_keys.add_modal_title')}</h3>
<div class="form-group">
<label for="new-api-key">${i18n.t('api_keys.add_modal_key_label')}</label>
<input type="text" id="new-api-key" placeholder="${i18n.t('api_keys.add_modal_key_placeholder')}">
</div>
<div class="modal-actions">
<button class="btn btn-secondary" onclick="manager.closeModal()">${i18n.t('common.cancel')}</button>
<button class="btn btn-primary" onclick="manager.addApiKey()">${i18n.t('common.add')}</button>
</div>
`;
modal.style.display = 'block';
},
// 添加API密钥
async addApiKey() {
const newKey = document.getElementById('new-api-key').value.trim();
if (!newKey) {
this.showNotification(`${i18n.t('notification.please_enter')} ${i18n.t('notification.api_key')}`, 'error');
return;
}
try {
const data = await this.makeRequest('/api-keys');
const currentKeys = data['api-keys'] || [];
currentKeys.push(newKey);
await this.makeRequest('/api-keys', {
method: 'PUT',
body: JSON.stringify(currentKeys)
});
this.clearCache('api-keys'); // 仅清除 api-keys 段缓存
this.closeModal();
this.loadApiKeys();
this.showNotification(i18n.t('notification.api_key_added'), 'success');
} catch (error) {
this.showNotification(`${i18n.t('notification.add_failed')}: ${error.message}`, 'error');
}
},
// 编辑API密钥
editApiKey(index, currentKey) {
const modal = document.getElementById('modal');
const modalBody = document.getElementById('modal-body');
modalBody.innerHTML = `
<h3>${i18n.t('api_keys.edit_modal_title')}</h3>
<div class="form-group">
<label for="edit-api-key">${i18n.t('api_keys.edit_modal_key_label')}</label>
<input type="text" id="edit-api-key" value="${currentKey}">
</div>
<div class="modal-actions">
<button class="btn btn-secondary" onclick="manager.closeModal()">${i18n.t('common.cancel')}</button>
<button class="btn btn-primary" onclick="manager.updateApiKey(${index})">${i18n.t('common.update')}</button>
</div>
`;
modal.style.display = 'block';
},
// 更新API密钥
async updateApiKey(index) {
const newKey = document.getElementById('edit-api-key').value.trim();
if (!newKey) {
this.showNotification(`${i18n.t('notification.please_enter')} ${i18n.t('notification.api_key')}`, 'error');
return;
}
try {
await this.makeRequest('/api-keys', {
method: 'PATCH',
body: JSON.stringify({ index, value: newKey })
});
this.clearCache('api-keys'); // 仅清除 api-keys 段缓存
this.closeModal();
this.loadApiKeys();
this.showNotification(i18n.t('notification.api_key_updated'), 'success');
} catch (error) {
this.showNotification(`${i18n.t('notification.update_failed')}: ${error.message}`, 'error');
}
},
// 删除API密钥
async deleteApiKey(index) {
if (!confirm(i18n.t('api_keys.delete_confirm'))) return;
try {
await this.makeRequest(`/api-keys?index=${index}`, { method: 'DELETE' });
this.clearCache('api-keys'); // 仅清除 api-keys 段缓存
this.loadApiKeys();
this.showNotification(i18n.t('notification.api_key_deleted'), 'success');
} catch (error) {
this.showNotification(`${i18n.t('notification.delete_failed')}: ${error.message}`, 'error');
}
},
bindApiKeyListEvents(container = null) {
if (this.apiKeyListEventsBound) {
return;
}
const listContainer = container || document.getElementById('api-keys-list');
if (!listContainer) return;
listContainer.addEventListener('click', (event) => {
const button = event.target.closest('[data-action][data-index]');
if (!button || !listContainer.contains(button)) return;
const action = button.dataset.action;
const index = Number(button.dataset.index);
if (!Number.isFinite(index)) return;
switch (action) {
case 'edit-api-key': {
const rawKey = button.dataset.key || '';
let decodedKey = '';
try {
decodedKey = decodeURIComponent(rawKey);
} catch (e) {
decodedKey = rawKey;
}
this.editApiKey(index, decodedKey);
break;
}
case 'delete-api-key':
this.deleteApiKey(index);
break;
default:
break;
}
});
this.apiKeyListEventsBound = true;
}
};

File diff suppressed because it is too large Load Diff

View File

@@ -1,272 +0,0 @@
export const configEditorModule = {
setupConfigEditor() {
const textarea = document.getElementById('config-editor');
const saveBtn = document.getElementById('config-save-btn');
const reloadBtn = document.getElementById('config-reload-btn');
const statusEl = document.getElementById('config-editor-status');
this.configEditorElements = {
textarea,
editorInstance: null,
saveBtn,
reloadBtn,
statusEl
};
if (!textarea || !saveBtn || !reloadBtn || !statusEl) {
return;
}
if (window.CodeMirror) {
const editorInstance = window.CodeMirror.fromTextArea(textarea, {
mode: 'yaml',
theme: 'default',
lineNumbers: true,
indentUnit: 2,
tabSize: 2,
lineWrapping: true,
autoCloseBrackets: true,
extraKeys: {
'Ctrl-/': 'toggleComment',
'Cmd-/': 'toggleComment'
}
});
editorInstance.on('change', () => {
this.isConfigEditorDirty = true;
this.updateConfigEditorStatus('info', i18n.t('config_management.status_dirty'));
});
this.configEditorElements.editorInstance = editorInstance;
} else {
textarea.addEventListener('input', () => {
this.isConfigEditorDirty = true;
this.updateConfigEditorStatus('info', i18n.t('config_management.status_dirty'));
});
}
saveBtn.addEventListener('click', () => this.saveConfigFile());
reloadBtn.addEventListener('click', () => this.loadConfigFileEditor(true));
this.refreshConfigEditor();
},
updateConfigEditorAvailability() {
const { textarea, editorInstance, saveBtn, reloadBtn } = this.configEditorElements || {};
if ((!textarea && !editorInstance) || !saveBtn || !reloadBtn) {
return;
}
const disabled = !this.isConnected;
if (editorInstance) {
editorInstance.setOption('readOnly', disabled ? 'nocursor' : false);
const wrapper = editorInstance.getWrapperElement();
if (wrapper) {
wrapper.classList.toggle('cm-readonly', disabled);
}
} else if (textarea) {
textarea.disabled = disabled;
}
saveBtn.disabled = disabled;
reloadBtn.disabled = disabled;
if (disabled) {
this.updateConfigEditorStatus('info', i18n.t('config_management.status_disconnected'));
}
this.refreshConfigEditor();
this.lastEditorConnectionState = this.isConnected;
},
refreshConfigEditor() {
const instance = this.configEditorElements && this.configEditorElements.editorInstance;
if (instance && typeof instance.refresh === 'function') {
setTimeout(() => instance.refresh(), 0);
}
},
updateConfigEditorStatus(type, message) {
const statusEl = (this.configEditorElements && this.configEditorElements.statusEl) || document.getElementById('config-editor-status');
if (!statusEl) {
return;
}
statusEl.textContent = message;
statusEl.classList.remove('success', 'error');
if (type === 'success') {
statusEl.classList.add('success');
} else if (type === 'error') {
statusEl.classList.add('error');
}
},
async loadConfigFileEditor(forceRefresh = false) {
const { textarea, editorInstance, reloadBtn } = this.configEditorElements || {};
if (!textarea && !editorInstance) {
return;
}
if (!this.isConnected) {
this.updateConfigEditorStatus('info', i18n.t('config_management.status_disconnected'));
return;
}
if (reloadBtn) {
reloadBtn.disabled = true;
}
this.updateConfigEditorStatus('info', i18n.t('config_management.status_loading'));
try {
const yamlText = await this.fetchConfigFile(forceRefresh);
if (editorInstance) {
editorInstance.setValue(yamlText || '');
if (typeof editorInstance.markClean === 'function') {
editorInstance.markClean();
}
} else if (textarea) {
textarea.value = yamlText || '';
}
this.isConfigEditorDirty = false;
this.updateConfigEditorStatus('success', i18n.t('config_management.status_loaded'));
this.refreshConfigEditor();
} catch (error) {
console.error('加载配置文件失败:', error);
this.updateConfigEditorStatus('error', `${i18n.t('config_management.status_load_failed')}: ${error.message}`);
} finally {
if (reloadBtn) {
reloadBtn.disabled = !this.isConnected;
}
}
},
async fetchConfigFile(forceRefresh = false) {
if (!forceRefresh && this.configYamlCache) {
return this.configYamlCache;
}
const requestUrl = '/config.yaml';
try {
const response = await this.apiClient.requestRaw(requestUrl, {
method: 'GET',
headers: {
'Accept': 'application/yaml'
}
});
if (!response.ok) {
const errorText = await response.text().catch(() => '');
const message = errorText || `HTTP ${response.status}`;
throw new Error(message);
}
const contentType = response.headers.get('content-type') || '';
if (!/yaml/i.test(contentType)) {
throw new Error(i18n.t('config_management.error_yaml_not_supported'));
}
const text = await response.text();
this.lastConfigFetchUrl = requestUrl;
this.configYamlCache = text;
return text;
} catch (error) {
throw error instanceof Error ? error : new Error(String(error));
}
},
async saveConfigFile() {
const { textarea, editorInstance, saveBtn, reloadBtn } = this.configEditorElements || {};
if ((!textarea && !editorInstance) || !saveBtn) {
return;
}
if (!this.isConnected) {
this.updateConfigEditorStatus('error', i18n.t('config_management.status_disconnected'));
return;
}
const yamlText = editorInstance ? editorInstance.getValue() : (textarea ? textarea.value : '');
saveBtn.disabled = true;
if (reloadBtn) {
reloadBtn.disabled = true;
}
this.updateConfigEditorStatus('info', i18n.t('config_management.status_saving'));
try {
await this.writeConfigFile('/config.yaml', yamlText);
this.lastConfigFetchUrl = '/config.yaml';
this.configYamlCache = yamlText;
this.isConfigEditorDirty = false;
if (editorInstance && typeof editorInstance.markClean === 'function') {
editorInstance.markClean();
}
this.showNotification(i18n.t('config_management.save_success'), 'success');
this.updateConfigEditorStatus('success', i18n.t('config_management.status_saved'));
this.clearCache();
if (this.events && typeof this.events.emit === 'function') {
this.events.emit('config:refresh-requested', { forceRefresh: true });
}
} catch (error) {
const errorMessage = `${i18n.t('config_management.status_save_failed')}: ${error.message}`;
this.updateConfigEditorStatus('error', errorMessage);
this.showNotification(errorMessage, 'error');
this.isConfigEditorDirty = true;
} finally {
saveBtn.disabled = !this.isConnected;
if (reloadBtn) {
reloadBtn.disabled = !this.isConnected;
}
}
},
async writeConfigFile(endpoint, yamlText) {
const response = await this.apiClient.requestRaw(endpoint, {
method: 'PUT',
headers: {
'Content-Type': 'application/yaml',
'Accept': 'application/json, text/plain, */*'
},
body: yamlText
});
if (!response.ok) {
const contentType = response.headers.get('content-type') || '';
let errorText = '';
if (contentType.includes('application/json')) {
const data = await response.json().catch(() => ({}));
errorText = data.message || data.error || '';
} else {
errorText = await response.text().catch(() => '');
}
throw new Error(errorText || `HTTP ${response.status}`);
}
const contentType = response.headers.get('content-type') || '';
if (contentType.includes('application/json')) {
const data = await response.json().catch(() => null);
if (data && data.ok === false) {
throw new Error(data.message || data.error || 'Server rejected the update');
}
}
},
registerConfigEditorListeners() {
if (!this.events || typeof this.events.on !== 'function') {
return;
}
this.events.on('data:config-loaded', async (event) => {
const detail = event?.detail || {};
try {
await this.loadConfigFileEditor(detail.forceRefresh || false);
this.refreshConfigEditor();
} catch (error) {
console.error('加载配置文件失败:', error);
}
});
}
};

View File

@@ -1,37 +0,0 @@
export const languageModule = {
setupLanguageSwitcher() {
const loginToggle = document.getElementById('language-toggle');
const mainToggle = document.getElementById('language-toggle-main');
if (loginToggle) {
loginToggle.addEventListener('click', () => this.toggleLanguage());
}
if (mainToggle) {
mainToggle.addEventListener('click', () => this.toggleLanguage());
}
},
toggleLanguage() {
if (this.isLanguageRefreshInProgress) {
return;
}
this.isLanguageRefreshInProgress = true;
const currentLang = i18n.currentLanguage;
const newLang = currentLang === 'zh-CN' ? 'en-US' : 'zh-CN';
i18n.setLanguage(newLang);
this.refreshBrandTitleAfterTextChange();
this.updateThemeButtons();
this.updateConnectionStatus();
if (this.isLoggedIn && this.isConnected && this.events && typeof this.events.emit === 'function') {
this.events.emit('config:refresh-requested', { forceRefresh: true });
}
// 简单释放锁,避免短时间内的重复触发
setTimeout(() => {
this.isLanguageRefreshInProgress = false;
}, 500);
}
};

View File

@@ -1,281 +0,0 @@
import { secureStorage } from '../utils/secure-storage.js';
export const loginModule = {
async checkLoginStatus() {
// 将旧的明文缓存迁移为加密格式
secureStorage.migratePlaintextKeys(['apiBase', 'apiUrl', 'managementKey']);
const savedBase = secureStorage.getItem('apiBase');
const savedKey = secureStorage.getItem('managementKey');
const wasLoggedIn = localStorage.getItem('isLoggedIn') === 'true';
if (savedBase && savedKey && wasLoggedIn) {
try {
console.log(i18n.t('auto_login.title'));
this.showAutoLoginLoading();
await this.attemptAutoLogin(savedBase, savedKey);
return;
} catch (error) {
console.log(`${i18n.t('notification.login_failed')}: ${error.message}`);
localStorage.removeItem('isLoggedIn');
this.hideAutoLoginLoading();
}
}
this.showLoginPage();
this.loadLoginSettings();
},
showAutoLoginLoading() {
document.getElementById('auto-login-loading').style.display = 'flex';
document.getElementById('login-page').style.display = 'none';
document.getElementById('main-page').style.display = 'none';
},
hideAutoLoginLoading() {
document.getElementById('auto-login-loading').style.display = 'none';
},
async attemptAutoLogin(apiBase, managementKey) {
try {
this.setApiBase(apiBase);
this.setManagementKey(managementKey);
const savedProxy = localStorage.getItem('proxyUrl');
if (savedProxy) {
// 代理设置会在后续的API请求中自动使用
}
await this.testConnection();
this.isLoggedIn = true;
this.hideAutoLoginLoading();
this.showMainPage();
console.log(i18n.t('auto_login.title'));
return true;
} catch (error) {
console.error('自动登录失败:', error);
this.isLoggedIn = false;
this.isConnected = false;
throw error;
}
},
showLoginPage() {
document.getElementById('login-page').style.display = 'flex';
document.getElementById('main-page').style.display = 'none';
this.isLoggedIn = false;
this.resetBrandTitleState();
this.updateLoginConnectionInfo();
},
showMainPage() {
document.getElementById('login-page').style.display = 'none';
document.getElementById('main-page').style.display = 'block';
this.isLoggedIn = true;
this.updateConnectionInfo();
this.startBrandCollapseCycle();
},
async login(apiBase, managementKey) {
try {
this.setApiBase(apiBase);
this.setManagementKey(managementKey);
await this.testConnection();
this.isLoggedIn = true;
localStorage.setItem('isLoggedIn', 'true');
this.showMainPage();
return true;
} catch (error) {
console.error('登录失败:', error);
throw error;
}
},
logout() {
this.isLoggedIn = false;
this.isConnected = false;
this.clearCache();
this.stopStatusUpdateTimer();
this.resetVersionInfo();
this.setManagementKey('', { persist: false });
this.oauthExcludedModels = {};
this._oauthExcludedLoading = false;
if (typeof this.renderOauthExcludedModels === 'function') {
this.renderOauthExcludedModels('all');
}
if (typeof this.clearAvailableModels === 'function') {
this.clearAvailableModels('common.disconnected');
}
localStorage.removeItem('isLoggedIn');
secureStorage.removeItem('managementKey');
this.showLoginPage();
},
async handleLogin() {
const apiBaseInput = document.getElementById('login-api-base');
const managementKeyInput = document.getElementById('login-management-key');
const managementKey = managementKeyInput ? managementKeyInput.value.trim() : '';
if (!managementKey) {
this.showLoginError(i18n.t('login.error_required'));
return;
}
if (apiBaseInput && apiBaseInput.value.trim()) {
this.setApiBase(apiBaseInput.value.trim());
}
const submitBtn = document.getElementById('login-submit');
const originalText = submitBtn ? submitBtn.innerHTML : '';
try {
if (submitBtn) {
submitBtn.innerHTML = `<div class=\"loading\"></div> ${i18n.t('login.submitting')}`;
submitBtn.disabled = true;
}
this.hideLoginError();
this.setManagementKey(managementKey);
await this.login(this.apiBase, this.managementKey);
} catch (error) {
this.showLoginError(`${i18n.t('login.error_title')}: ${error.message}`);
} finally {
if (submitBtn) {
submitBtn.innerHTML = originalText;
submitBtn.disabled = false;
}
}
},
toggleLoginKeyVisibility(button) {
const inputGroup = button.closest('.input-group');
const keyInput = inputGroup.querySelector('input[type=\"password\"], input[type=\"text\"]');
if (keyInput.type === 'password') {
keyInput.type = 'text';
button.innerHTML = '<i class=\"fas fa-eye-slash\"></i>';
} else {
keyInput.type = 'password';
button.innerHTML = '<i class=\"fas fa-eye\"></i>';
}
},
showLoginError(message) {
const errorDiv = document.getElementById('login-error');
const errorMessage = document.getElementById('login-error-message');
errorMessage.textContent = message;
errorDiv.style.display = 'flex';
},
hideLoginError() {
const errorDiv = document.getElementById('login-error');
errorDiv.style.display = 'none';
},
updateConnectionInfo() {
const apiUrlElement = document.getElementById('display-api-url');
const statusElement = document.getElementById('display-connection-status');
if (apiUrlElement) {
apiUrlElement.textContent = this.apiBase || '-';
}
if (statusElement) {
let statusHtml = '';
if (this.isConnected) {
statusHtml = `<span class=\"status-indicator connected\"><i class=\"fas fa-circle\"></i> ${i18n.t('common.connected')}</span>`;
} else {
statusHtml = `<span class=\"status-indicator disconnected\"><i class=\"fas fa-circle\"></i> ${i18n.t('common.disconnected')}</span>`;
}
statusElement.innerHTML = statusHtml;
}
},
loadLoginSettings() {
const savedBase = secureStorage.getItem('apiBase');
const savedKey = secureStorage.getItem('managementKey');
const loginKeyInput = document.getElementById('login-management-key');
const apiBaseInput = document.getElementById('login-api-base');
if (savedBase) {
this.setApiBase(savedBase);
} else {
this.setApiBase(this.detectApiBaseFromLocation());
}
if (apiBaseInput) {
apiBaseInput.value = this.apiBase || '';
}
if (loginKeyInput && savedKey) {
loginKeyInput.value = savedKey;
}
this.setManagementKey(savedKey || '', { persist: false });
this.setupLoginAutoSave();
},
setupLoginAutoSave() {
const loginKeyInput = document.getElementById('login-management-key');
const apiBaseInput = document.getElementById('login-api-base');
const resetButton = document.getElementById('login-reset-api-base');
const saveKey = (val) => {
const trimmed = val.trim();
if (trimmed) {
this.setManagementKey(trimmed);
}
};
const saveKeyDebounced = this.debounce(saveKey, 500);
if (loginKeyInput) {
loginKeyInput.addEventListener('change', (e) => saveKey(e.target.value));
loginKeyInput.addEventListener('input', (e) => saveKeyDebounced(e.target.value));
}
if (apiBaseInput) {
const persistBase = (val) => {
const normalized = this.normalizeBase(val);
if (normalized) {
this.setApiBase(normalized);
}
};
const persistBaseDebounced = this.debounce(persistBase, 500);
apiBaseInput.addEventListener('change', (e) => persistBase(e.target.value));
apiBaseInput.addEventListener('input', (e) => persistBaseDebounced(e.target.value));
}
if (resetButton) {
resetButton.addEventListener('click', () => {
const detected = this.detectApiBaseFromLocation();
this.setApiBase(detected);
if (apiBaseInput) {
apiBaseInput.value = detected;
}
});
}
this.updateLoginConnectionInfo();
},
updateLoginConnectionInfo() {
const connectionUrlElement = document.getElementById('login-connection-url');
const customInput = document.getElementById('login-api-base');
if (connectionUrlElement) {
connectionUrlElement.textContent = this.apiBase || '-';
}
if (customInput && customInput !== document.activeElement) {
customInput.value = this.apiBase || '';
}
}
};

View File

@@ -1,657 +0,0 @@
export const logsModule = {
toggleLogsNavItem(show) {
const logsNavItem = document.getElementById('logs-nav-item');
if (logsNavItem) {
logsNavItem.style.display = show ? '' : 'none';
}
},
async refreshLogs(incremental = false) {
const logsContent = document.getElementById('logs-content');
if (!logsContent) return;
try {
if (incremental && !this.latestLogTimestamp) {
incremental = false;
}
if (!incremental) {
logsContent.innerHTML = '<div class="loading-placeholder" data-i18n="logs.loading">' + i18n.t('logs.loading') + '</div>';
}
let url = '/logs';
const params = new URLSearchParams();
if (incremental && 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, {
method: 'GET'
});
if (response && response.lines) {
if (response['latest-timestamp']) {
this.latestLogTimestamp = response['latest-timestamp'];
}
if (incremental && response.lines.length > 0) {
this.appendLogs(response.lines, response['line-count'] || 0);
} else if (!incremental && response.lines.length > 0) {
this.renderLogs(response.lines, response['line-count'] || response.lines.length, true);
} else if (!incremental) {
this.latestLogTimestamp = null;
this.renderLogs([], 0, false);
}
} else if (!incremental) {
this.latestLogTimestamp = null;
this.renderLogs([], 0, false);
}
} catch (error) {
console.error('加载日志失败:', error);
if (!incremental) {
this.allLogLines = [];
this.displayedLogLines = [];
this.latestLogTimestamp = null;
const is404 = error.message && (error.message.includes('404') || error.message.includes('Not Found'));
if (is404) {
logsContent.innerHTML = '<div class="upgrade-notice"><i class="fas fa-arrow-circle-up"></i><h3 data-i18n="logs.upgrade_required_title">' +
i18n.t('logs.upgrade_required_title') + '</h3><p data-i18n="logs.upgrade_required_desc">' +
i18n.t('logs.upgrade_required_desc') + '</p></div>';
} else {
logsContent.innerHTML = '<div class="error-state"><i class="fas fa-exclamation-triangle"></i><p data-i18n="logs.load_error">' +
i18n.t('logs.load_error') + '</p><p>' + error.message + '</p></div>';
}
}
}
},
renderLogs(lines, lineCount, scrollToBottom = true) {
const logsContent = document.getElementById('logs-content');
if (!logsContent) return;
const sourceLines = Array.isArray(lines) ? lines : [];
const filteredLines = sourceLines.filter(line => !line.includes('/v0/management/'));
let displayedLines = filteredLines;
if (filteredLines.length > this.maxDisplayLogLines) {
const linesToRemove = filteredLines.length - this.maxDisplayLogLines;
displayedLines = filteredLines.slice(linesToRemove);
}
this.allLogLines = displayedLines.slice();
if (displayedLines.length === 0) {
this.displayedLogLines = [];
logsContent.innerHTML = '<div class="empty-state"><i class="fas fa-inbox"></i><p data-i18n="logs.empty_title">' +
i18n.t('logs.empty_title') + '</p><p data-i18n="logs.empty_desc">' +
i18n.t('logs.empty_desc') + '</p></div>';
return;
}
const visibleLines = this.filterLogLinesBySearch(displayedLines);
this.displayedLogLines = visibleLines.slice();
if (visibleLines.length === 0) {
logsContent.innerHTML = '<div class="empty-state"><i class="fas fa-search"></i><p data-i18n="logs.search_empty_title">' +
i18n.t('logs.search_empty_title') + '</p><p data-i18n="logs.search_empty_desc">' +
i18n.t('logs.search_empty_desc') + '</p></div>';
return;
}
const displayedLineCount = this.displayedLogLines.length;
logsContent.innerHTML = `
<div class="logs-info">
<span><i class="fas fa-list-ol"></i> ${displayedLineCount} ${i18n.t('logs.lines')}</span>
</div>
<pre class="logs-text">${this.buildLogsHtml(this.displayedLogLines)}</pre>
`;
if (scrollToBottom && !this.logSearchQuery) {
const logsTextElement = logsContent.querySelector('.logs-text');
if (logsTextElement) {
logsTextElement.scrollTop = logsTextElement.scrollHeight;
}
}
},
appendLogs(newLines, totalLineCount) {
const logsContent = document.getElementById('logs-content');
if (!logsContent) return;
if (!newLines || newLines.length === 0) {
return;
}
const logsTextElement = logsContent.querySelector('.logs-text');
const logsInfoElement = logsContent.querySelector('.logs-info');
const filteredNewLines = newLines.filter(line => !line.includes('/v0/management/'));
if (filteredNewLines.length === 0) {
return;
}
if (!logsTextElement) {
this.renderLogs(filteredNewLines, totalLineCount || filteredNewLines.length, true);
return;
}
const isAtBottom = logsTextElement.scrollHeight - logsTextElement.scrollTop - logsTextElement.clientHeight < 50;
const baseLines = Array.isArray(this.allLogLines) && this.allLogLines.length > 0
? this.allLogLines
: (Array.isArray(this.displayedLogLines) ? this.displayedLogLines : []);
this.allLogLines = baseLines.concat(filteredNewLines);
if (this.allLogLines.length > this.maxDisplayLogLines) {
this.allLogLines = this.allLogLines.slice(this.allLogLines.length - this.maxDisplayLogLines);
}
const visibleLines = this.filterLogLinesBySearch(this.allLogLines);
this.displayedLogLines = visibleLines.slice();
if (visibleLines.length === 0) {
this.renderLogs(this.allLogLines, this.allLogLines.length, false);
return;
}
logsTextElement.innerHTML = this.buildLogsHtml(this.displayedLogLines);
if (logsInfoElement) {
const displayedLines = this.displayedLogLines.length;
logsInfoElement.innerHTML = `<span><i class="fas fa-list-ol"></i> ${displayedLines} ${i18n.t('logs.lines')}</span>`;
}
if (isAtBottom && !this.logSearchQuery) {
logsTextElement.scrollTop = logsTextElement.scrollHeight;
}
},
filterLogLinesBySearch(lines) {
const keyword = (this.logSearchQuery || '').toLowerCase();
if (!keyword) {
return Array.isArray(lines) ? lines.slice() : [];
}
if (!Array.isArray(lines) || lines.length === 0) {
return [];
}
return lines.filter(line => (line || '').toLowerCase().includes(keyword));
},
updateLogSearchQuery(value = '') {
const normalized = (value || '').trim();
if (this.logSearchQuery === normalized) {
return;
}
this.logSearchQuery = normalized;
this.applyLogSearchFilter();
},
applyLogSearchFilter() {
const logsContent = document.getElementById('logs-content');
if (!logsContent) return;
if (logsContent.querySelector('.upgrade-notice') || logsContent.querySelector('.error-state')) {
return;
}
const baseLines = Array.isArray(this.allLogLines) ? this.allLogLines : [];
if (baseLines.length === 0 && logsContent.querySelector('.loading-placeholder')) {
return;
}
this.renderLogs(baseLines, baseLines.length, false);
},
buildLogsHtml(lines) {
if (!lines || lines.length === 0) {
return '';
}
return lines.map(line => {
let processedLine = line.replace(/\[GIN\]\s+\d{4}\/\d{2}\/\d{2}\s+-\s+\d{2}:\d{2}:\d{2}\s+/g, '');
const highlights = [];
const statusInfo = this.detectHttpStatus(line);
if (statusInfo) {
const statusPattern = new RegExp(`\\b${statusInfo.code}\\b`);
const match = statusPattern.exec(processedLine);
if (match) {
highlights.push({
start: match.index,
end: match.index + match[0].length,
className: `log-status-tag log-status-${statusInfo.bucket}`,
priority: 10
});
}
}
const timestampPattern = /\d{4}[-/]\d{2}[-/]\d{2}[T]\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})?|\[\d{2}:\d{2}:\d{2}\]/g;
let match;
while ((match = timestampPattern.exec(processedLine)) !== null) {
highlights.push({
start: match.index,
end: match.index + match[0].length,
className: 'log-timestamp',
priority: 5
});
}
const bracketTimestampPattern = /\[\d{4}[-/]\d{2}[-/]\d{2}\s+\d{2}:\d{2}:\d{2}(?:\.\d+)?\]/g;
while ((match = bracketTimestampPattern.exec(processedLine)) !== null) {
highlights.push({
start: match.index,
end: match.index + match[0].length,
className: 'log-timestamp',
priority: 5
});
}
const levelPattern = /\[(ERROR|ERRO|ERR|FATAL|CRITICAL|CRIT|WARN|WARNING|INFO|DEBUG|TRACE|PANIC)\]/gi;
while ((match = levelPattern.exec(processedLine)) !== null) {
const level = match[1].toUpperCase();
let className = 'log-level';
if (['ERROR', 'ERRO', 'ERR', 'FATAL', 'CRITICAL', 'CRIT', 'PANIC'].includes(level)) {
className += ' log-level-error';
} else if (['WARN', 'WARNING'].includes(level)) {
className += ' log-level-warn';
} else if (level === 'INFO') {
className += ' log-level-info';
} else if (['DEBUG', 'TRACE'].includes(level)) {
className += ' log-level-debug';
}
highlights.push({
start: match.index,
end: match.index + match[0].length,
className,
priority: 8
});
}
const methodPattern = /\b(GET|POST|PUT|DELETE|PATCH|HEAD|OPTIONS|CONNECT|TRACE)\b/g;
while ((match = methodPattern.exec(processedLine)) !== null) {
highlights.push({
start: match.index,
end: match.index + match[0].length,
className: 'log-http-method',
priority: 6
});
}
const urlPattern = /(https?:\/\/[^\s<>"']+)/g;
while ((match = urlPattern.exec(processedLine)) !== null) {
highlights.push({
start: match.index,
end: match.index + match[0].length,
className: 'log-path',
priority: 4
});
}
const ipPattern = /\b(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})\b/g;
while ((match = ipPattern.exec(processedLine)) !== null) {
highlights.push({
start: match.index,
end: match.index + match[0].length,
className: 'log-ip',
priority: 7
});
}
const successPattern = /\b(success|successful|succeeded|completed|ok|done|passed)\b/gi;
while ((match = successPattern.exec(processedLine)) !== null) {
highlights.push({
start: match.index,
end: match.index + match[0].length,
className: 'log-keyword-success',
priority: 3
});
}
const errorPattern = /\b(failed|failure|error|exception|panic|fatal|critical|aborted|denied|refused|timeout|invalid)\b/gi;
while ((match = errorPattern.exec(processedLine)) !== null) {
highlights.push({
start: match.index,
end: match.index + match[0].length,
className: 'log-keyword-error',
priority: 3
});
}
const headersPattern = /\b(x-[a-z0-9-]+|authorization|content-type|user-agent)\b/gi;
while ((match = headersPattern.exec(processedLine)) !== null) {
highlights.push({
start: match.index,
end: match.index + match[0].length,
className: 'log-header-key',
priority: 2
});
}
highlights.sort((a, b) => {
if (a.start === b.start) {
return b.priority - a.priority;
}
return a.start - b.start;
});
let cursor = 0;
let result = '';
highlights.forEach((highlight) => {
if (highlight.start < cursor) {
return;
}
result += this.escapeHtml(processedLine.slice(cursor, highlight.start));
result += `<span class="${highlight.className}">${this.escapeHtml(processedLine.slice(highlight.start, highlight.end))}</span>`;
cursor = highlight.end;
});
result += this.escapeHtml(processedLine.slice(cursor));
return `<span class="log-line">${result}</span>`;
}).join('');
},
detectHttpStatus(line) {
if (!line) return null;
const patterns = [
/\|\s*([1-5]\d{2})\s*\|/,
/\b([1-5]\d{2})\s*-/,
/\b(?:GET|POST|PUT|DELETE|PATCH|HEAD|OPTIONS|CONNECT|TRACE)\s+\S+\s+([1-5]\d{2})\b/,
/\b(?:status|code|http)[:\s]+([1-5]\d{2})\b/i,
/\b([1-5]\d{2})\s+(?:OK|Created|Accepted|No Content|Moved|Found|Bad Request|Unauthorized|Forbidden|Not Found|Method Not Allowed|Internal Server Error|Bad Gateway|Service Unavailable|Gateway Timeout)\b/i
];
for (const pattern of patterns) {
const match = line.match(pattern);
if (match) {
const code = parseInt(match[1], 10);
if (Number.isNaN(code)) {
continue;
}
if (code >= 500) {
return { code, bucket: '5xx', match: match[1] };
}
if (code >= 400) {
return { code, bucket: '4xx', match: match[1] };
}
if (code >= 300) {
return { code, bucket: '3xx', match: match[1] };
}
if (code >= 200) {
return { code, bucket: '2xx', match: match[1] };
}
if (code >= 100) {
return { code, bucket: '1xx', match: match[1] };
}
}
}
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() {
try {
const response = await this.makeRequest('/logs', {
method: 'GET'
});
if (response && response.lines && response.lines.length > 0) {
const logsText = response.lines.join('\n');
const blob = new Blob([logsText], { type: 'text/plain' });
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `cli-proxy-api-logs-${new Date().toISOString().slice(0, 19).replace(/:/g, '-')}.log`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
window.URL.revokeObjectURL(url);
this.showNotification(i18n.t('logs.download_success'), 'success');
} else {
this.showNotification(i18n.t('logs.empty_title'), 'info');
}
} catch (error) {
console.error('下载日志失败:', error);
this.showNotification(`${i18n.t('notification.download_failed')}: ${error.message}`, 'error');
}
},
async clearLogs() {
if (!confirm(i18n.t('logs.clear_confirm'))) {
return;
}
try {
const response = await this.makeRequest('/logs', {
method: 'DELETE'
});
if (response && response.status === 'ok') {
const removedCount = response.removed || 0;
const message = `${i18n.t('logs.clear_success')} (${i18n.t('logs.removed')}: ${removedCount} ${i18n.t('logs.lines')})`;
this.showNotification(message, 'success');
} else {
this.showNotification(i18n.t('logs.clear_success'), 'success');
}
this.latestLogTimestamp = null;
await this.refreshLogs(false);
} catch (error) {
console.error('清空日志失败:', error);
this.showNotification(`${i18n.t('notification.delete_failed')}: ${error.message}`, 'error');
}
},
toggleLogsAutoRefresh(enabled) {
if (enabled) {
if (this.logsRefreshTimer) {
clearInterval(this.logsRefreshTimer);
}
this.logsRefreshTimer = setInterval(() => {
const logsSection = document.getElementById('logs');
if (logsSection && logsSection.classList.contains('active')) {
this.refreshLogs(true);
}
}, 5000);
this.showNotification(i18n.t('logs.auto_refresh_enabled'), 'success');
} else {
if (this.logsRefreshTimer) {
clearInterval(this.logsRefreshTimer);
this.logsRefreshTimer = null;
}
this.showNotification(i18n.t('logs.auto_refresh_disabled'), 'info');
}
},
registerLogsListeners() {
if (!this.events || typeof this.events.on !== 'function') {
return;
}
this.events.on('connection:status-changed', (event) => {
const detail = event?.detail || {};
if (detail.isConnected) {
// 仅在日志页激活时刷新,避免非日志页面触发请求
const logsSection = document.getElementById('logs');
if (logsSection && logsSection.classList.contains('active')) {
this.refreshLogs(false);
}
} else {
this.latestLogTimestamp = null;
}
});
this.events.on('navigation:section-activated', (event) => {
const detail = event?.detail || {};
if (detail.sectionId === 'logs' && this.isConnected) {
this.refreshLogs(false);
}
});
}
};

View File

@@ -1,103 +0,0 @@
export const navigationModule = {
setupNavigation() {
const navItems = document.querySelectorAll('.nav-item');
navItems.forEach(item => {
item.addEventListener('click', (e) => {
e.preventDefault();
navItems.forEach(nav => nav.classList.remove('active'));
document.querySelectorAll('.content-section').forEach(section => section.classList.remove('active'));
item.classList.add('active');
const sectionId = item.getAttribute('data-section');
const section = document.getElementById(sectionId);
if (section) {
section.classList.add('active');
}
if (sectionId === 'config-management') {
this.loadConfigFileEditor();
this.refreshConfigEditor();
}
if (this.events && typeof this.events.emit === 'function') {
this.events.emit('navigation:section-activated', { sectionId });
}
});
});
},
toggleMobileSidebar() {
const sidebar = document.getElementById('sidebar');
const overlay = document.getElementById('sidebar-overlay');
const layout = document.getElementById('layout-container');
const mainWrapper = document.getElementById('main-wrapper');
if (sidebar && overlay) {
const isOpen = sidebar.classList.toggle('mobile-open');
overlay.classList.toggle('active');
if (layout) {
layout.classList.toggle('sidebar-open', isOpen);
}
if (mainWrapper) {
mainWrapper.classList.toggle('sidebar-open', isOpen);
}
}
},
closeMobileSidebar() {
const sidebar = document.getElementById('sidebar');
const overlay = document.getElementById('sidebar-overlay');
const layout = document.getElementById('layout-container');
const mainWrapper = document.getElementById('main-wrapper');
if (sidebar && overlay) {
sidebar.classList.remove('mobile-open');
overlay.classList.remove('active');
if (layout) {
layout.classList.remove('sidebar-open');
}
if (mainWrapper) {
mainWrapper.classList.remove('sidebar-open');
}
}
},
toggleSidebar() {
const sidebar = document.getElementById('sidebar');
const layout = document.getElementById('layout-container');
if (sidebar && layout) {
const isCollapsed = sidebar.classList.toggle('collapsed');
layout.classList.toggle('sidebar-collapsed', isCollapsed);
localStorage.setItem('sidebarCollapsed', isCollapsed ? 'true' : 'false');
const toggleBtn = document.getElementById('sidebar-toggle-btn-desktop');
if (toggleBtn) {
toggleBtn.setAttribute('data-i18n-title', isCollapsed ? 'sidebar.toggle_expand' : 'sidebar.toggle_collapse');
toggleBtn.title = i18n.t(isCollapsed ? 'sidebar.toggle_expand' : 'sidebar.toggle_collapse');
}
}
},
restoreSidebarState() {
if (window.innerWidth > 1024) {
const savedState = localStorage.getItem('sidebarCollapsed');
if (savedState === 'true') {
const sidebar = document.getElementById('sidebar');
const layout = document.getElementById('layout-container');
if (sidebar && layout) {
sidebar.classList.add('collapsed');
layout.classList.add('sidebar-collapsed');
const toggleBtn = document.getElementById('sidebar-toggle-btn-desktop');
if (toggleBtn) {
toggleBtn.setAttribute('data-i18n-title', 'sidebar.toggle_expand');
toggleBtn.title = i18n.t('sidebar.toggle_expand');
}
}
}
}
}
};

View File

@@ -1,949 +0,0 @@
export const oauthModule = {
// ===== Codex OAuth 相关方法 =====
// 开始 Codex OAuth 流程
async startCodexOAuth() {
try {
const response = await this.makeRequest('/codex-auth-url?is_webui=1');
const authUrl = response.url;
const state = this.extractStateFromUrl(authUrl);
// 显示授权链接
const urlInput = document.getElementById('codex-oauth-url');
const content = document.getElementById('codex-oauth-content');
const status = document.getElementById('codex-oauth-status');
if (urlInput) {
urlInput.value = authUrl;
}
if (content) {
content.style.display = 'block';
}
if (status) {
status.textContent = i18n.t('auth_login.codex_oauth_status_waiting');
status.style.color = 'var(--warning-text)';
}
// 开始轮询认证状态
this.startCodexOAuthPolling(state);
} catch (error) {
this.showNotification(`${i18n.t('auth_login.codex_oauth_start_error')} ${error.message}`, 'error');
}
},
// 从 URL 中提取 state 参数
extractStateFromUrl(url) {
try {
const urlObj = new URL(url);
return urlObj.searchParams.get('state');
} catch (error) {
console.error('Failed to extract state from URL:', error);
return null;
}
},
// 打开 Codex 授权链接
openCodexLink() {
const urlInput = document.getElementById('codex-oauth-url');
if (urlInput && urlInput.value) {
window.open(urlInput.value, '_blank');
}
},
// 复制 Codex 授权链接
async copyCodexLink() {
const urlInput = document.getElementById('codex-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');
}
}
},
// 开始轮询 OAuth 状态
startCodexOAuthPolling(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('codex-oauth-status');
if (status === 'ok') {
// 认证成功
clearInterval(pollInterval);
// 隐藏授权链接相关内容,恢复到初始状态
this.resetCodexOAuthUI();
// 显示成功通知
this.showNotification(i18n.t('auth_login.codex_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.codex_oauth_status_error')} ${errorMessage}`;
statusElement.style.color = 'var(--error-text)';
}
this.showNotification(`${i18n.t('auth_login.codex_oauth_status_error')} ${errorMessage}`, 'error');
// 3秒后重置UI让用户能够重新开始
setTimeout(() => {
this.resetCodexOAuthUI();
}, 3000);
} else if (status === 'wait') {
// 继续等待
if (statusElement) {
statusElement.textContent = i18n.t('auth_login.codex_oauth_status_waiting');
statusElement.style.color = 'var(--warning-text)';
}
}
} catch (error) {
clearInterval(pollInterval);
const statusElement = document.getElementById('codex-oauth-status');
if (statusElement) {
statusElement.textContent = `${i18n.t('auth_login.codex_oauth_polling_error')} ${error.message}`;
statusElement.style.color = 'var(--error-text)';
}
this.showNotification(`${i18n.t('auth_login.codex_oauth_polling_error')} ${error.message}`, 'error');
// 3秒后重置UI让用户能够重新开始
setTimeout(() => {
this.resetCodexOAuthUI();
}, 3000);
}
}, 2000); // 每2秒轮询一次
// 设置超时5分钟后停止轮询
setTimeout(() => {
clearInterval(pollInterval);
}, 5 * 60 * 1000);
},
// 重置 Codex OAuth UI 到初始状态
resetCodexOAuthUI() {
const urlInput = document.getElementById('codex-oauth-url');
const content = document.getElementById('codex-oauth-content');
const status = document.getElementById('codex-oauth-status');
// 清空并隐藏授权链接输入框
if (urlInput) {
urlInput.value = '';
}
// 隐藏整个授权链接内容区域
if (content) {
content.style.display = 'none';
}
// 清空状态显示
if (status) {
status.textContent = '';
status.style.color = '';
status.className = '';
}
},
// ===== Anthropic OAuth 相关方法 =====
// 开始 Anthropic OAuth 流程
async startAnthropicOAuth() {
try {
const response = await this.makeRequest('/anthropic-auth-url?is_webui=1');
const authUrl = response.url;
const state = this.extractStateFromUrl(authUrl);
// 显示授权链接
const urlInput = document.getElementById('anthropic-oauth-url');
const content = document.getElementById('anthropic-oauth-content');
const status = document.getElementById('anthropic-oauth-status');
if (urlInput) {
urlInput.value = authUrl;
}
if (content) {
content.style.display = 'block';
}
if (status) {
status.textContent = i18n.t('auth_login.anthropic_oauth_status_waiting');
status.style.color = 'var(--warning-text)';
}
// 开始轮询认证状态
this.startAnthropicOAuthPolling(state);
} catch (error) {
this.showNotification(`${i18n.t('auth_login.anthropic_oauth_start_error')} ${error.message}`, 'error');
}
},
// 打开 Anthropic 授权链接
openAnthropicLink() {
const urlInput = document.getElementById('anthropic-oauth-url');
if (urlInput && urlInput.value) {
window.open(urlInput.value, '_blank');
}
},
// 复制 Anthropic 授权链接
async copyAnthropicLink() {
const urlInput = document.getElementById('anthropic-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');
}
}
},
// 开始轮询 Anthropic OAuth 状态
startAnthropicOAuthPolling(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('anthropic-oauth-status');
if (status === 'ok') {
// 认证成功
clearInterval(pollInterval);
// 隐藏授权链接相关内容,恢复到初始状态
this.resetAnthropicOAuthUI();
// 显示成功通知
this.showNotification(i18n.t('auth_login.anthropic_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.anthropic_oauth_status_error')} ${errorMessage}`;
statusElement.style.color = 'var(--error-text)';
}
this.showNotification(`${i18n.t('auth_login.anthropic_oauth_status_error')} ${errorMessage}`, 'error');
// 3秒后重置UI让用户能够重新开始
setTimeout(() => {
this.resetAnthropicOAuthUI();
}, 3000);
} else if (status === 'wait') {
// 继续等待
if (statusElement) {
statusElement.textContent = i18n.t('auth_login.anthropic_oauth_status_waiting');
statusElement.style.color = 'var(--warning-text)';
}
}
} catch (error) {
clearInterval(pollInterval);
const statusElement = document.getElementById('anthropic-oauth-status');
if (statusElement) {
statusElement.textContent = `${i18n.t('auth_login.anthropic_oauth_polling_error')} ${error.message}`;
statusElement.style.color = 'var(--error-text)';
}
this.showNotification(`${i18n.t('auth_login.anthropic_oauth_polling_error')} ${error.message}`, 'error');
// 3秒后重置UI让用户能够重新开始
setTimeout(() => {
this.resetAnthropicOAuthUI();
}, 3000);
}
}, 2000); // 每2秒轮询一次
// 设置超时5分钟后停止轮询
setTimeout(() => {
clearInterval(pollInterval);
}, 5 * 60 * 1000);
},
// 重置 Anthropic OAuth UI 到初始状态
resetAnthropicOAuthUI() {
const urlInput = document.getElementById('anthropic-oauth-url');
const content = document.getElementById('anthropic-oauth-content');
const status = document.getElementById('anthropic-oauth-status');
// 清空并隐藏授权链接输入框
if (urlInput) {
urlInput.value = '';
}
// 隐藏整个授权链接内容区域
if (content) {
content.style.display = 'none';
}
// 清空状态显示
if (status) {
status.textContent = '';
status.style.color = '';
status.className = '';
}
},
// ===== 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 流程
async startGeminiCliOAuth() {
try {
const response = await this.makeRequest('/gemini-cli-auth-url?is_webui=1');
const authUrl = response.url;
const state = this.extractStateFromUrl(authUrl);
// 显示授权链接
const urlInput = document.getElementById('gemini-cli-oauth-url');
const content = document.getElementById('gemini-cli-oauth-content');
const status = document.getElementById('gemini-cli-oauth-status');
if (urlInput) {
urlInput.value = authUrl;
}
if (content) {
content.style.display = 'block';
}
if (status) {
status.textContent = i18n.t('auth_login.gemini_cli_oauth_status_waiting');
status.style.color = 'var(--warning-text)';
}
// 开始轮询认证状态
this.startGeminiCliOAuthPolling(state);
} catch (error) {
this.showNotification(`${i18n.t('auth_login.gemini_cli_oauth_start_error')} ${error.message}`, 'error');
}
},
// 打开 Gemini CLI 授权链接
openGeminiCliLink() {
const urlInput = document.getElementById('gemini-cli-oauth-url');
if (urlInput && urlInput.value) {
window.open(urlInput.value, '_blank');
}
},
// 复制 Gemini CLI 授权链接
async copyGeminiCliLink() {
const urlInput = document.getElementById('gemini-cli-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');
}
}
},
// 开始轮询 Gemini CLI OAuth 状态
startGeminiCliOAuthPolling(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('gemini-cli-oauth-status');
if (status === 'ok') {
// 认证成功
clearInterval(pollInterval);
// 隐藏授权链接相关内容,恢复到初始状态
this.resetGeminiCliOAuthUI();
// 显示成功通知
this.showNotification(i18n.t('auth_login.gemini_cli_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.gemini_cli_oauth_status_error')} ${errorMessage}`;
statusElement.style.color = 'var(--error-text)';
}
this.showNotification(`${i18n.t('auth_login.gemini_cli_oauth_status_error')} ${errorMessage}`, 'error');
// 3秒后重置UI让用户能够重新开始
setTimeout(() => {
this.resetGeminiCliOAuthUI();
}, 3000);
} else if (status === 'wait') {
// 继续等待
if (statusElement) {
statusElement.textContent = i18n.t('auth_login.gemini_cli_oauth_status_waiting');
statusElement.style.color = 'var(--warning-text)';
}
}
} catch (error) {
clearInterval(pollInterval);
const statusElement = document.getElementById('gemini-cli-oauth-status');
if (statusElement) {
statusElement.textContent = `${i18n.t('auth_login.gemini_cli_oauth_polling_error')} ${error.message}`;
statusElement.style.color = 'var(--error-text)';
}
this.showNotification(`${i18n.t('auth_login.gemini_cli_oauth_polling_error')} ${error.message}`, 'error');
// 3秒后重置UI让用户能够重新开始
setTimeout(() => {
this.resetGeminiCliOAuthUI();
}, 3000);
}
}, 2000); // 每2秒轮询一次
// 设置超时5分钟后停止轮询
setTimeout(() => {
clearInterval(pollInterval);
}, 5 * 60 * 1000);
},
// 重置 Gemini CLI OAuth UI 到初始状态
resetGeminiCliOAuthUI() {
const urlInput = document.getElementById('gemini-cli-oauth-url');
const content = document.getElementById('gemini-cli-oauth-content');
const status = document.getElementById('gemini-cli-oauth-status');
// 清空并隐藏授权链接输入框
if (urlInput) {
urlInput.value = '';
}
// 隐藏整个授权链接内容区域
if (content) {
content.style.display = 'none';
}
// 清空状态显示
if (status) {
status.textContent = '';
status.style.color = '';
status.className = '';
}
},
// ===== Qwen OAuth 相关方法 =====
// 开始 Qwen OAuth 流程
async startQwenOAuth() {
try {
const response = await this.makeRequest('/qwen-auth-url?is_webui=1');
const authUrl = response.url;
const state = this.extractStateFromUrl(authUrl);
// 显示授权链接
const urlInput = document.getElementById('qwen-oauth-url');
const content = document.getElementById('qwen-oauth-content');
const status = document.getElementById('qwen-oauth-status');
if (urlInput) {
urlInput.value = authUrl;
}
if (content) {
content.style.display = 'block';
}
if (status) {
status.textContent = i18n.t('auth_login.qwen_oauth_status_waiting');
status.style.color = 'var(--warning-text)';
}
// 开始轮询认证状态
this.startQwenOAuthPolling(state);
} catch (error) {
this.showNotification(`${i18n.t('auth_login.qwen_oauth_start_error')} ${error.message}`, 'error');
}
},
// 打开 Qwen 授权链接
openQwenLink() {
const urlInput = document.getElementById('qwen-oauth-url');
if (urlInput && urlInput.value) {
window.open(urlInput.value, '_blank');
}
},
// 复制 Qwen 授权链接
async copyQwenLink() {
const urlInput = document.getElementById('qwen-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');
}
}
},
// 开始轮询 Qwen OAuth 状态
startQwenOAuthPolling(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('qwen-oauth-status');
if (status === 'ok') {
// 认证成功
clearInterval(pollInterval);
// 隐藏授权链接相关内容,恢复到初始状态
this.resetQwenOAuthUI();
// 显示成功通知
this.showNotification(i18n.t('auth_login.qwen_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.qwen_oauth_status_error')} ${errorMessage}`;
statusElement.style.color = 'var(--error-text)';
}
this.showNotification(`${i18n.t('auth_login.qwen_oauth_status_error')} ${errorMessage}`, 'error');
// 3秒后重置UI让用户能够重新开始
setTimeout(() => {
this.resetQwenOAuthUI();
}, 3000);
} else if (status === 'wait') {
// 继续等待
if (statusElement) {
statusElement.textContent = i18n.t('auth_login.qwen_oauth_status_waiting');
statusElement.style.color = 'var(--warning-text)';
}
}
} catch (error) {
clearInterval(pollInterval);
const statusElement = document.getElementById('qwen-oauth-status');
if (statusElement) {
statusElement.textContent = `${i18n.t('auth_login.qwen_oauth_polling_error')} ${error.message}`;
statusElement.style.color = 'var(--error-text)';
}
this.showNotification(`${i18n.t('auth_login.qwen_oauth_polling_error')} ${error.message}`, 'error');
// 3秒后重置UI让用户能够重新开始
setTimeout(() => {
this.resetQwenOAuthUI();
}, 3000);
}
}, 2000); // 每2秒轮询一次
// 设置超时5分钟后停止轮询
setTimeout(() => {
clearInterval(pollInterval);
}, 5 * 60 * 1000);
},
// 重置 Qwen OAuth UI 到初始状态
resetQwenOAuthUI() {
const urlInput = document.getElementById('qwen-oauth-url');
const content = document.getElementById('qwen-oauth-content');
const status = document.getElementById('qwen-oauth-status');
// 清空并隐藏授权链接输入框
if (urlInput) {
urlInput.value = '';
}
// 隐藏整个授权链接内容区域
if (content) {
content.style.display = 'none';
}
// 清空状态显示
if (status) {
status.textContent = '';
status.style.color = '';
status.className = '';
}
},
// ===== iFlow OAuth 相关方法 =====
// 开始 iFlow OAuth 流程
async startIflowOAuth() {
try {
const response = await this.makeRequest('/iflow-auth-url?is_webui=1');
const authUrl = response.url;
const state = this.extractStateFromUrl(authUrl);
// 显示授权链接
const urlInput = document.getElementById('iflow-oauth-url');
const content = document.getElementById('iflow-oauth-content');
const status = document.getElementById('iflow-oauth-status');
if (urlInput) {
urlInput.value = authUrl;
}
if (content) {
content.style.display = 'block';
}
if (status) {
status.textContent = i18n.t('auth_login.iflow_oauth_status_waiting');
status.style.color = 'var(--warning-text)';
}
// 开始轮询认证状态
this.startIflowOAuthPolling(state);
} catch (error) {
this.showNotification(`${i18n.t('auth_login.iflow_oauth_start_error')} ${error.message}`, 'error');
}
},
// 打开 iFlow 授权链接
openIflowLink() {
const urlInput = document.getElementById('iflow-oauth-url');
if (urlInput && urlInput.value) {
window.open(urlInput.value, '_blank');
}
},
// 复制 iFlow 授权链接
async copyIflowLink() {
const urlInput = document.getElementById('iflow-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');
}
}
},
// 开始轮询 iFlow OAuth 状态
startIflowOAuthPolling(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('iflow-oauth-status');
if (status === 'ok') {
// 认证成功
clearInterval(pollInterval);
// 隐藏授权链接相关内容,恢复到初始状态
this.resetIflowOAuthUI();
// 显示成功通知
this.showNotification(i18n.t('auth_login.iflow_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.iflow_oauth_status_error')} ${errorMessage}`;
statusElement.style.color = 'var(--error-text)';
}
this.showNotification(`${i18n.t('auth_login.iflow_oauth_status_error')} ${errorMessage}`, 'error');
// 3秒后重置UI让用户能够重新开始
setTimeout(() => {
this.resetIflowOAuthUI();
}, 3000);
} else if (status === 'wait') {
// 继续等待
if (statusElement) {
statusElement.textContent = i18n.t('auth_login.iflow_oauth_status_waiting');
statusElement.style.color = 'var(--warning-text)';
}
}
} catch (error) {
clearInterval(pollInterval);
const statusElement = document.getElementById('iflow-oauth-status');
if (statusElement) {
statusElement.textContent = `${i18n.t('auth_login.iflow_oauth_polling_error')} ${error.message}`;
statusElement.style.color = 'var(--error-text)';
}
this.showNotification(`${i18n.t('auth_login.iflow_oauth_polling_error')} ${error.message}`, 'error');
// 3秒后重置UI让用户能够重新开始
setTimeout(() => {
this.resetIflowOAuthUI();
}, 3000);
}
}, 2000); // 每2秒轮询一次
// 设置超时5分钟后停止轮询
setTimeout(() => {
clearInterval(pollInterval);
}, 5 * 60 * 1000);
},
// 重置 iFlow OAuth UI 到初始状态
resetIflowOAuthUI() {
const urlInput = document.getElementById('iflow-oauth-url');
const content = document.getElementById('iflow-oauth-content');
const status = document.getElementById('iflow-oauth-status');
// 清空并隐藏授权链接输入框
if (urlInput) {
urlInput.value = '';
}
// 隐藏整个授权链接内容区域
if (content) {
content.style.display = 'none';
}
// 清空状态显示
if (status) {
status.textContent = '';
status.style.color = '';
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,411 +0,0 @@
// 设置与开关相关方法模块
// 注意:这些函数依赖于在 CLIProxyManager 实例上提供的 makeRequest/clearCache/showNotification/errorHandler 等基础能力
export async function updateDebug(enabled) {
const previousValue = !enabled;
try {
await this.makeRequest('/debug', {
method: 'PUT',
body: JSON.stringify({ value: enabled })
});
this.clearCache('debug'); // 仅清除 debug 配置段的缓存
this.showNotification(i18n.t('notification.debug_updated'), 'success');
} catch (error) {
this.errorHandler.handleUpdateError(
error,
i18n.t('settings.debug_mode') || '调试模式',
() => document.getElementById('debug-toggle').checked = previousValue
);
}
}
export async function updateProxyUrl() {
const proxyUrl = document.getElementById('proxy-url').value.trim();
const previousValue = document.getElementById('proxy-url').getAttribute('data-previous-value') || '';
try {
await this.makeRequest('/proxy-url', {
method: 'PUT',
body: JSON.stringify({ value: proxyUrl })
});
this.clearCache('proxy-url'); // 仅清除 proxy-url 配置段的缓存
document.getElementById('proxy-url').setAttribute('data-previous-value', proxyUrl);
this.showNotification(i18n.t('notification.proxy_updated'), 'success');
} catch (error) {
this.errorHandler.handleUpdateError(
error,
i18n.t('settings.proxy_url') || '代理设置',
() => document.getElementById('proxy-url').value = previousValue
);
}
}
export async function clearProxyUrl() {
const previousValue = document.getElementById('proxy-url').value;
try {
await this.makeRequest('/proxy-url', { method: 'DELETE' });
document.getElementById('proxy-url').value = '';
document.getElementById('proxy-url').setAttribute('data-previous-value', '');
this.clearCache('proxy-url'); // 仅清除 proxy-url 配置段的缓存
this.showNotification(i18n.t('notification.proxy_cleared'), 'success');
} catch (error) {
this.errorHandler.handleUpdateError(
error,
i18n.t('settings.proxy_url') || '代理设置',
() => document.getElementById('proxy-url').value = previousValue
);
}
}
export async function updateRequestRetry() {
const retryInput = document.getElementById('request-retry');
const retryCount = parseInt(retryInput.value);
const previousValue = retryInput.getAttribute('data-previous-value') || '0';
try {
await this.makeRequest('/request-retry', {
method: 'PUT',
body: JSON.stringify({ value: retryCount })
});
this.clearCache('request-retry'); // 仅清除 request-retry 配置段的缓存
retryInput.setAttribute('data-previous-value', retryCount.toString());
this.showNotification(i18n.t('notification.retry_updated'), 'success');
} catch (error) {
this.errorHandler.handleUpdateError(
error,
i18n.t('settings.request_retry') || '重试设置',
() => retryInput.value = previousValue
);
}
}
export async function loadDebugSettings() {
try {
const debugValue = await this.getConfig('debug'); // 仅获取 debug 配置段
if (debugValue !== undefined) {
document.getElementById('debug-toggle').checked = debugValue;
}
} catch (error) {
this.errorHandler.handleLoadError(error, i18n.t('settings.debug_mode') || '调试设置');
}
}
export async function loadProxySettings() {
try {
const proxyUrl = await this.getConfig('proxy-url'); // 仅获取 proxy-url 配置段
const proxyInput = document.getElementById('proxy-url');
if (proxyUrl !== undefined) {
proxyInput.value = proxyUrl || '';
proxyInput.setAttribute('data-previous-value', proxyUrl || '');
}
} catch (error) {
this.errorHandler.handleLoadError(error, i18n.t('settings.proxy_settings') || '代理设置');
}
}
export async function loadRetrySettings() {
try {
const config = await this.getConfig();
if (config['request-retry'] !== undefined) {
document.getElementById('request-retry').value = config['request-retry'];
}
} catch (error) {
console.error('加载重试设置失败:', error);
}
}
export async function loadQuotaSettings() {
try {
const config = await this.getConfig();
if (config['quota-exceeded']) {
if (config['quota-exceeded']['switch-project'] !== undefined) {
document.getElementById('switch-project-toggle').checked = config['quota-exceeded']['switch-project'];
}
if (config['quota-exceeded']['switch-preview-model'] !== undefined) {
document.getElementById('switch-preview-model-toggle').checked = config['quota-exceeded']['switch-preview-model'];
}
}
} catch (error) {
console.error('加载配额设置失败:', error);
}
}
export async function loadUsageStatisticsSettings() {
try {
const config = await this.getConfig();
if (config['usage-statistics-enabled'] !== undefined) {
const usageToggle = document.getElementById('usage-statistics-enabled-toggle');
if (usageToggle) {
usageToggle.checked = config['usage-statistics-enabled'];
}
}
} catch (error) {
console.error('加载使用统计设置失败:', error);
}
}
export async function loadRequestLogSetting() {
try {
const config = await this.getConfig();
if (config['request-log'] !== undefined) {
const requestLogToggle = document.getElementById('request-log-toggle');
if (requestLogToggle) {
requestLogToggle.checked = config['request-log'];
}
}
} catch (error) {
console.error('加载请求日志设置失败:', error);
}
}
export async function loadWsAuthSetting() {
try {
const config = await this.getConfig();
if (config['ws-auth'] !== undefined) {
const wsAuthToggle = document.getElementById('ws-auth-toggle');
if (wsAuthToggle) {
wsAuthToggle.checked = config['ws-auth'];
}
}
} catch (error) {
console.error('加载 WebSocket 鉴权设置失败:', error);
}
}
export async function updateUsageStatisticsEnabled(enabled) {
try {
await this.makeRequest('/usage-statistics-enabled', {
method: 'PUT',
body: JSON.stringify({ value: enabled })
});
this.clearCache();
this.showNotification(i18n.t('notification.usage_statistics_updated'), 'success');
} catch (error) {
this.showNotification(`${i18n.t('notification.update_failed')}: ${error.message}`, 'error');
const usageToggle = document.getElementById('usage-statistics-enabled-toggle');
if (usageToggle) {
usageToggle.checked = !enabled;
}
}
}
export async function updateRequestLog(enabled) {
try {
await this.makeRequest('/request-log', {
method: 'PUT',
body: JSON.stringify({ value: enabled })
});
this.clearCache();
this.showNotification(i18n.t('notification.request_log_updated'), 'success');
} catch (error) {
this.showNotification(`${i18n.t('notification.update_failed')}: ${error.message}`, 'error');
const requestLogToggle = document.getElementById('request-log-toggle');
if (requestLogToggle) {
requestLogToggle.checked = !enabled;
}
}
}
export async function updateWsAuth(enabled) {
try {
await this.makeRequest('/ws-auth', {
method: 'PUT',
body: JSON.stringify({ value: enabled })
});
this.clearCache();
this.showNotification(i18n.t('notification.ws_auth_updated'), 'success');
} catch (error) {
this.showNotification(`${i18n.t('notification.update_failed')}: ${error.message}`, 'error');
const wsAuthToggle = document.getElementById('ws-auth-toggle');
if (wsAuthToggle) {
wsAuthToggle.checked = !enabled;
}
}
}
export async function updateLoggingToFile(enabled) {
try {
await this.makeRequest('/logging-to-file', {
method: 'PUT',
body: JSON.stringify({ value: enabled })
});
this.clearCache();
this.showNotification(i18n.t('notification.logging_to_file_updated'), 'success');
// 显示或隐藏日志查看栏目
this.toggleLogsNavItem(enabled);
// 如果启用了日志记录,自动刷新日志
if (enabled) {
setTimeout(() => this.refreshLogs(), 500);
}
} catch (error) {
this.showNotification(`${i18n.t('notification.update_failed')}: ${error.message}`, 'error');
const loggingToggle = document.getElementById('logging-to-file-toggle');
if (loggingToggle) {
loggingToggle.checked = !enabled;
}
}
}
export async function updateSwitchProject(enabled) {
try {
await this.makeRequest('/quota-exceeded/switch-project', {
method: 'PUT',
body: JSON.stringify({ value: enabled })
});
this.clearCache(); // 清除缓存
this.showNotification(i18n.t('notification.quota_switch_project_updated'), 'success');
} catch (error) {
this.showNotification(`${i18n.t('notification.update_failed')}: ${error.message}`, 'error');
document.getElementById('switch-project-toggle').checked = !enabled;
}
}
export async function updateSwitchPreviewModel(enabled) {
try {
await this.makeRequest('/quota-exceeded/switch-preview-model', {
method: 'PUT',
body: JSON.stringify({ value: enabled })
});
this.clearCache(); // 清除缓存
this.showNotification(i18n.t('notification.quota_switch_preview_updated'), 'success');
} catch (error) {
this.showNotification(`${i18n.t('notification.update_failed')}: ${error.message}`, 'error');
document.getElementById('switch-preview-model-toggle').checked = !enabled;
}
}
// 统一应用配置到界面,供 connection 模块或事件总线调用
export async function applySettingsFromConfig(config = {}, keyStats = null) {
if (!config || typeof config !== 'object') {
return;
}
// 调试设置
if (config.debug !== undefined) {
const toggle = document.getElementById('debug-toggle');
if (toggle) {
toggle.checked = config.debug;
}
}
// 代理设置
if (config['proxy-url'] !== undefined) {
const proxyInput = document.getElementById('proxy-url');
if (proxyInput) {
proxyInput.value = config['proxy-url'] || '';
}
}
// 请求重试设置
if (config['request-retry'] !== undefined) {
const retryInput = document.getElementById('request-retry');
if (retryInput) {
retryInput.value = config['request-retry'];
}
}
// 配额超出行为
if (config['quota-exceeded']) {
if (config['quota-exceeded']['switch-project'] !== undefined) {
const toggle = document.getElementById('switch-project-toggle');
if (toggle) {
toggle.checked = config['quota-exceeded']['switch-project'];
}
}
if (config['quota-exceeded']['switch-preview-model'] !== undefined) {
const toggle = document.getElementById('switch-preview-model-toggle');
if (toggle) {
toggle.checked = config['quota-exceeded']['switch-preview-model'];
}
}
}
if (config['usage-statistics-enabled'] !== undefined) {
const usageToggle = document.getElementById('usage-statistics-enabled-toggle');
if (usageToggle) {
usageToggle.checked = config['usage-statistics-enabled'];
}
}
// 日志记录设置
if (config['logging-to-file'] !== undefined) {
const loggingToggle = document.getElementById('logging-to-file-toggle');
if (loggingToggle) {
loggingToggle.checked = config['logging-to-file'];
}
if (typeof this.toggleLogsNavItem === 'function') {
this.toggleLogsNavItem(config['logging-to-file']);
}
}
if (config['request-log'] !== undefined) {
const requestLogToggle = document.getElementById('request-log-toggle');
if (requestLogToggle) {
requestLogToggle.checked = config['request-log'];
}
}
if (config['ws-auth'] !== undefined) {
const wsAuthToggle = document.getElementById('ws-auth-toggle');
if (wsAuthToggle) {
wsAuthToggle.checked = config['ws-auth'];
}
}
// API 密钥
if (config['api-keys'] && typeof this.renderApiKeys === 'function') {
this.renderApiKeys(config['api-keys']);
}
// Gemini keys
if (typeof this.renderGeminiKeys === 'function') {
await this.renderGeminiKeys(this.getGeminiKeysFromConfig(config), keyStats);
}
// Codex 密钥
if (typeof this.renderCodexKeys === 'function') {
await this.renderCodexKeys(Array.isArray(config['codex-api-key']) ? config['codex-api-key'] : [], keyStats);
}
// Claude 密钥
if (typeof this.renderClaudeKeys === 'function') {
await this.renderClaudeKeys(Array.isArray(config['claude-api-key']) ? config['claude-api-key'] : [], keyStats);
}
// OpenAI 兼容提供商
if (typeof this.renderOpenAIProviders === 'function') {
await this.renderOpenAIProviders(Array.isArray(config['openai-compatibility']) ? config['openai-compatibility'] : [], keyStats);
}
}
// 设置模块订阅全局事件,减少与连接层耦合
export function registerSettingsListeners() {
if (!this.events || typeof this.events.on !== 'function') {
return;
}
this.events.on('data:config-loaded', (event) => {
const detail = event?.detail || {};
this.applySettingsFromConfig(detail.config || {}, detail.keyStats || null);
});
}
export const settingsModule = {
updateDebug,
updateProxyUrl,
clearProxyUrl,
updateRequestRetry,
loadDebugSettings,
loadProxySettings,
loadRetrySettings,
loadQuotaSettings,
loadUsageStatisticsSettings,
loadRequestLogSetting,
loadWsAuthSetting,
updateUsageStatisticsEnabled,
updateRequestLog,
updateWsAuth,
updateLoggingToFile,
updateSwitchProject,
updateSwitchPreviewModel,
applySettingsFromConfig,
registerSettingsListeners
};

View File

@@ -1,73 +0,0 @@
export const themeModule = {
initializeTheme() {
const savedTheme = localStorage.getItem('preferredTheme');
if (savedTheme && ['light', 'dark'].includes(savedTheme)) {
this.currentTheme = savedTheme;
} else if (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) {
this.currentTheme = 'dark';
} else {
this.currentTheme = 'light';
}
this.applyTheme(this.currentTheme);
this.updateThemeButtons();
if (window.matchMedia) {
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', (e) => {
if (!localStorage.getItem('preferredTheme')) {
this.currentTheme = e.matches ? 'dark' : 'light';
this.applyTheme(this.currentTheme);
this.updateThemeButtons();
}
});
}
},
applyTheme(theme) {
if (theme === 'dark') {
document.documentElement.setAttribute('data-theme', 'dark');
} else {
document.documentElement.removeAttribute('data-theme');
}
this.currentTheme = theme;
},
toggleTheme() {
const newTheme = this.currentTheme === 'light' ? 'dark' : 'light';
this.applyTheme(newTheme);
this.updateThemeButtons();
localStorage.setItem('preferredTheme', newTheme);
},
updateThemeButtons() {
const loginThemeBtn = document.getElementById('theme-toggle');
const mainThemeBtn = document.getElementById('theme-toggle-main');
const updateButton = (btn) => {
if (!btn) return;
const icon = btn.querySelector('i');
if (this.currentTheme === 'dark') {
icon.className = 'fas fa-sun';
btn.title = i18n.t('theme.switch_to_light');
} else {
icon.className = 'fas fa-moon';
btn.title = i18n.t('theme.switch_to_dark');
}
};
updateButton(loginThemeBtn);
updateButton(mainThemeBtn);
},
setupThemeSwitcher() {
const loginToggle = document.getElementById('theme-toggle');
const mainToggle = document.getElementById('theme-toggle-main');
if (loginToggle) {
loginToggle.addEventListener('click', () => this.toggleTheme());
}
if (mainToggle) {
mainToggle.addEventListener('click', () => this.toggleTheme());
}
}
};

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,46 @@
.container {
width: 100%;
}
.pageTitle {
font-size: 28px;
font-weight: 700;
color: var(--text-primary);
margin: 0 0 $spacing-xl 0;
}
.content {
display: flex;
flex-direction: column;
gap: $spacing-xl;
}
.section {
display: flex;
flex-direction: column;
gap: $spacing-md;
}
.sectionHeader {
display: flex;
justify-content: space-between;
align-items: center;
gap: $spacing-md;
h3 {
margin: 0;
font-size: 20px;
font-weight: 600;
color: var(--text-primary);
}
}
.providerList {
display: grid;
gap: $spacing-md;
grid-template-columns: repeat(auto-fill, minmax(350px, 1fr));
@include mobile {
grid-template-columns: 1fr;
}
}

View File

@@ -0,0 +1,782 @@
import { Fragment, useEffect, useMemo, useState, type ReactNode } from 'react';
import { useTranslation } from 'react-i18next';
import { Card } from '@/components/ui/Card';
import { Button } from '@/components/ui/Button';
import { Input } from '@/components/ui/Input';
import { Modal } from '@/components/ui/Modal';
import { EmptyState } from '@/components/ui/EmptyState';
import { HeaderInputList } from '@/components/ui/HeaderInputList';
import { useAuthStore, useConfigStore, useNotificationStore } from '@/stores';
import { providersApi } from '@/services/api';
import type {
GeminiKeyConfig,
ProviderKeyConfig,
OpenAIProviderConfig,
ApiKeyEntry,
ModelAlias
} from '@/types';
import { headersToEntries, buildHeaderObject, type HeaderEntry } from '@/utils/headers';
import { maskApiKey } from '@/utils/format';
type ProviderModal =
| { type: 'gemini'; index: number | null }
| { type: 'codex'; index: number | null }
| { type: 'claude'; index: number | null }
| { type: 'openai'; index: number | null };
interface OpenAIFormState {
name: string;
baseUrl: string;
headers: HeaderEntry[];
priority?: number;
testModel?: string;
modelsText: string;
apiKeyEntries: ApiKeyEntry[];
}
const parseModelsText = (value: string): ModelAlias[] => {
return value
.split(/\n+/)
.map((line) => line.trim())
.filter(Boolean)
.map((line) => {
const [namePart, aliasPart] = line.split(',').map((item) => item.trim());
if (!namePart) return null;
const entry: ModelAlias = { name: namePart };
if (aliasPart && aliasPart !== namePart) entry.alias = aliasPart;
return entry;
})
.filter(Boolean) as ModelAlias[];
};
const modelsToText = (models?: ModelAlias[]) =>
Array.isArray(models)
? models
.map((m) => (m.alias && m.alias !== m.name ? `${m.name}, ${m.alias}` : m.name))
.filter(Boolean)
.join('\n')
: '';
const parseExcludedModels = (text: string): string[] =>
text
.split(/[\n,]+/)
.map((item) => item.trim())
.filter(Boolean);
const excludedModelsToText = (models?: string[]) => (Array.isArray(models) ? models.join('\n') : '');
const buildApiKeyEntry = (input?: Partial<ApiKeyEntry>): ApiKeyEntry => ({
apiKey: input?.apiKey ?? '',
proxyUrl: input?.proxyUrl ?? '',
headers: input?.headers ?? {}
});
export function AiProvidersPage() {
const { t } = useTranslation();
const { showNotification } = useNotificationStore();
const connectionStatus = useAuthStore((state) => state.connectionStatus);
const config = useConfigStore((state) => state.config);
const fetchConfig = useConfigStore((state) => state.fetchConfig);
const updateConfigValue = useConfigStore((state) => state.updateConfigValue);
const clearCache = useConfigStore((state) => state.clearCache);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [geminiKeys, setGeminiKeys] = useState<GeminiKeyConfig[]>([]);
const [codexConfigs, setCodexConfigs] = useState<ProviderKeyConfig[]>([]);
const [claudeConfigs, setClaudeConfigs] = useState<ProviderKeyConfig[]>([]);
const [openaiProviders, setOpenaiProviders] = useState<OpenAIProviderConfig[]>([]);
const [modal, setModal] = useState<ProviderModal | null>(null);
const [geminiForm, setGeminiForm] = useState<GeminiKeyConfig & { excludedText: string }>({
apiKey: '',
baseUrl: '',
headers: {},
excludedModels: [],
excludedText: ''
});
const [providerForm, setProviderForm] = useState<ProviderKeyConfig & { modelsText: string }>({
apiKey: '',
baseUrl: '',
proxyUrl: '',
headers: {},
models: [],
modelsText: ''
});
const [openaiForm, setOpenaiForm] = useState<OpenAIFormState>({
name: '',
baseUrl: '',
headers: [],
apiKeyEntries: [buildApiKeyEntry()],
modelsText: ''
});
const [saving, setSaving] = useState(false);
const disableControls = useMemo(() => connectionStatus !== 'connected', [connectionStatus]);
const loadConfigs = async () => {
setLoading(true);
setError('');
try {
const data = await fetchConfig(undefined, true);
setGeminiKeys(data?.geminiApiKeys || []);
setCodexConfigs(data?.codexApiKeys || []);
setClaudeConfigs(data?.claudeApiKeys || []);
setOpenaiProviders(data?.openaiCompatibility || []);
} catch (err: any) {
setError(err?.message || t('notification.refresh_failed'));
} finally {
setLoading(false);
}
};
useEffect(() => {
loadConfigs();
}, []);
useEffect(() => {
if (config?.geminiApiKeys) setGeminiKeys(config.geminiApiKeys);
if (config?.codexApiKeys) setCodexConfigs(config.codexApiKeys);
if (config?.claudeApiKeys) setClaudeConfigs(config.claudeApiKeys);
if (config?.openaiCompatibility) setOpenaiProviders(config.openaiCompatibility);
}, [config?.geminiApiKeys, config?.codexApiKeys, config?.claudeApiKeys, config?.openaiCompatibility]);
const closeModal = () => {
setModal(null);
setGeminiForm({
apiKey: '',
baseUrl: '',
headers: {},
excludedModels: [],
excludedText: ''
});
setProviderForm({
apiKey: '',
baseUrl: '',
proxyUrl: '',
headers: {},
models: [],
modelsText: ''
});
setOpenaiForm({
name: '',
baseUrl: '',
headers: [],
apiKeyEntries: [buildApiKeyEntry()],
modelsText: '',
priority: undefined,
testModel: undefined
});
};
const openGeminiModal = (index: number | null) => {
if (index !== null) {
const entry = geminiKeys[index];
setGeminiForm({
...entry,
excludedText: excludedModelsToText(entry?.excludedModels)
});
}
setModal({ type: 'gemini', index });
};
const openProviderModal = (type: 'codex' | 'claude', index: number | null) => {
const source = type === 'codex' ? codexConfigs : claudeConfigs;
if (index !== null) {
const entry = source[index];
setProviderForm({
...entry,
modelsText: modelsToText(entry?.models)
});
}
setModal({ type, index });
};
const openOpenaiModal = (index: number | null) => {
if (index !== null) {
const entry = openaiProviders[index];
setOpenaiForm({
name: entry.name,
baseUrl: entry.baseUrl,
headers: headersToEntries(entry.headers),
priority: entry.priority,
testModel: entry.testModel,
modelsText: modelsToText(entry.models),
apiKeyEntries: entry.apiKeyEntries?.length ? entry.apiKeyEntries : [buildApiKeyEntry()]
});
}
setModal({ type: 'openai', index });
};
const saveGemini = async () => {
setSaving(true);
try {
const payload: GeminiKeyConfig = {
apiKey: geminiForm.apiKey.trim(),
baseUrl: geminiForm.baseUrl?.trim() || undefined,
headers: buildHeaderObject(headersToEntries(geminiForm.headers as any)),
excludedModels: parseExcludedModels(geminiForm.excludedText)
};
const nextList =
modal?.type === 'gemini' && modal.index !== null
? geminiKeys.map((item, idx) => (idx === modal.index ? payload : item))
: [...geminiKeys, payload];
await providersApi.saveGeminiKeys(nextList);
setGeminiKeys(nextList);
updateConfigValue('gemini-api-key', nextList);
clearCache('gemini-api-key');
const message =
modal?.index !== null ? t('notification.gemini_key_updated') : t('notification.gemini_key_added');
showNotification(message, 'success');
closeModal();
} catch (err: any) {
showNotification(`${t('notification.update_failed')}: ${err?.message || ''}`, 'error');
} finally {
setSaving(false);
}
};
const deleteGemini = async (apiKey: string) => {
if (!window.confirm(t('ai_providers.gemini_delete_confirm'))) return;
try {
await providersApi.deleteGeminiKey(apiKey);
const next = geminiKeys.filter((item) => item.apiKey !== apiKey);
setGeminiKeys(next);
updateConfigValue('gemini-api-key', next);
clearCache('gemini-api-key');
showNotification(t('notification.gemini_key_deleted'), 'success');
} catch (err: any) {
showNotification(`${t('notification.delete_failed')}: ${err?.message || ''}`, 'error');
}
};
const saveProvider = async (type: 'codex' | 'claude') => {
setSaving(true);
try {
const payload: ProviderKeyConfig = {
apiKey: providerForm.apiKey.trim(),
baseUrl: providerForm.baseUrl?.trim() || undefined,
proxyUrl: providerForm.proxyUrl?.trim() || undefined,
headers: buildHeaderObject(headersToEntries(providerForm.headers as any)),
models: parseModelsText(providerForm.modelsText)
};
const source = type === 'codex' ? codexConfigs : claudeConfigs;
const nextList =
modal?.type === type && modal.index !== null
? source.map((item, idx) => (idx === modal.index ? payload : item))
: [...source, payload];
if (type === 'codex') {
await providersApi.saveCodexConfigs(nextList);
setCodexConfigs(nextList);
updateConfigValue('codex-api-key', nextList);
clearCache('codex-api-key');
const message =
modal?.index !== null ? t('notification.codex_config_updated') : t('notification.codex_config_added');
showNotification(message, 'success');
} else {
await providersApi.saveClaudeConfigs(nextList);
setClaudeConfigs(nextList);
updateConfigValue('claude-api-key', nextList);
clearCache('claude-api-key');
const message =
modal?.index !== null ? t('notification.claude_config_updated') : t('notification.claude_config_added');
showNotification(message, 'success');
}
closeModal();
} catch (err: any) {
showNotification(`${t('notification.update_failed')}: ${err?.message || ''}`, 'error');
} finally {
setSaving(false);
}
};
const deleteProviderEntry = async (type: 'codex' | 'claude', apiKey: string) => {
if (!window.confirm(t(`ai_providers.${type}_delete_confirm` as any))) return;
try {
if (type === 'codex') {
await providersApi.deleteCodexConfig(apiKey);
const next = codexConfigs.filter((item) => item.apiKey !== apiKey);
setCodexConfigs(next);
updateConfigValue('codex-api-key', next);
clearCache('codex-api-key');
showNotification(t('notification.codex_config_deleted'), 'success');
} else {
await providersApi.deleteClaudeConfig(apiKey);
const next = claudeConfigs.filter((item) => item.apiKey !== apiKey);
setClaudeConfigs(next);
updateConfigValue('claude-api-key', next);
clearCache('claude-api-key');
showNotification(t('notification.claude_config_deleted'), 'success');
}
} catch (err: any) {
showNotification(`${t('notification.delete_failed')}: ${err?.message || ''}`, 'error');
}
};
const saveOpenai = async () => {
setSaving(true);
try {
const payload: OpenAIProviderConfig = {
name: openaiForm.name.trim(),
baseUrl: openaiForm.baseUrl.trim(),
headers: buildHeaderObject(openaiForm.headers),
apiKeyEntries: openaiForm.apiKeyEntries.map((entry) => ({
apiKey: entry.apiKey.trim(),
proxyUrl: entry.proxyUrl?.trim() || undefined,
headers: entry.headers
}))
};
if (openaiForm.priority !== undefined) payload.priority = openaiForm.priority;
if (openaiForm.testModel) payload.testModel = openaiForm.testModel.trim();
const models = parseModelsText(openaiForm.modelsText);
if (models.length) payload.models = models;
const nextList =
modal?.type === 'openai' && modal.index !== null
? openaiProviders.map((item, idx) => (idx === modal.index ? payload : item))
: [...openaiProviders, payload];
await providersApi.saveOpenAIProviders(nextList);
setOpenaiProviders(nextList);
updateConfigValue('openai-compatibility', nextList);
clearCache('openai-compatibility');
const message =
modal?.index !== null ? t('notification.openai_provider_updated') : t('notification.openai_provider_added');
showNotification(message, 'success');
closeModal();
} catch (err: any) {
showNotification(`${t('notification.update_failed')}: ${err?.message || ''}`, 'error');
} finally {
setSaving(false);
}
};
const deleteOpenai = async (name: string) => {
if (!window.confirm(t('ai_providers.openai_delete_confirm'))) return;
try {
await providersApi.deleteOpenAIProvider(name);
const next = openaiProviders.filter((item) => item.name !== name);
setOpenaiProviders(next);
updateConfigValue('openai-compatibility', next);
clearCache('openai-compatibility');
showNotification(t('notification.openai_provider_deleted'), 'success');
} catch (err: any) {
showNotification(`${t('notification.delete_failed')}: ${err?.message || ''}`, 'error');
}
};
const renderKeyEntries = (entries: ApiKeyEntry[]) => {
const list = entries.length ? entries : [buildApiKeyEntry()];
const updateEntry = (idx: number, field: keyof ApiKeyEntry, value: string) => {
const next = list.map((entry, i) => (i === idx ? { ...entry, [field]: value } : entry));
setOpenaiForm((prev) => ({ ...prev, apiKeyEntries: next }));
};
const removeEntry = (idx: number) => {
const next = list.filter((_, i) => i !== idx);
setOpenaiForm((prev) => ({ ...prev, apiKeyEntries: next.length ? next : [buildApiKeyEntry()] }));
};
const addEntry = () => {
setOpenaiForm((prev) => ({ ...prev, apiKeyEntries: [...list, buildApiKeyEntry()] }));
};
return (
<div className="stack">
{list.map((entry, index) => (
<div key={index} className="item-row">
<div className="item-meta">
<Input
label={`${t('common.api_key')} #${index + 1}`}
value={entry.apiKey}
onChange={(e) => updateEntry(index, 'apiKey', e.target.value)}
/>
<Input
label={t('common.proxy_url')}
value={entry.proxyUrl ?? ''}
onChange={(e) => updateEntry(index, 'proxyUrl', e.target.value)}
/>
</div>
<div className="item-actions">
<Button
variant="ghost"
size="sm"
onClick={() => removeEntry(index)}
disabled={list.length <= 1 || saving}
>
{t('common.delete')}
</Button>
</div>
</div>
))}
<Button variant="secondary" size="sm" onClick={addEntry} disabled={saving}>
{t('ai_providers.openai_keys_add_btn')}
</Button>
</div>
);
};
const renderList = <T,>(
items: T[],
keyField: (item: T) => string,
renderContent: (item: T, index: number) => ReactNode,
onEdit: (index: number) => void,
onDelete: (item: T) => void,
addLabel: string,
deleteLabel?: string
) => {
if (loading) {
return <div className="hint">{t('common.loading')}</div>;
}
if (!items.length) {
return (
<EmptyState
title={t('common.info')}
description={t('ai_providers.gemini_empty_desc')}
action={
<Button onClick={() => onEdit(-1)} disabled={disableControls}>
{addLabel}
</Button>
}
/>
);
}
return (
<div className="item-list">
{items.map((item, index) => (
<div key={keyField(item)} className="item-row">
<div className="item-meta">{renderContent(item, index)}</div>
<div className="item-actions">
<Button variant="secondary" size="sm" onClick={() => onEdit(index)} disabled={disableControls}>
{t('common.edit')}
</Button>
<Button variant="danger" size="sm" onClick={() => onDelete(item)} disabled={disableControls}>
{deleteLabel || t('common.delete')}
</Button>
</div>
</div>
))}
</div>
);
};
return (
<div className="stack">
{error && <div className="error-box">{error}</div>}
<Card
title={t('ai_providers.gemini_title')}
extra={
<Button size="sm" onClick={() => openGeminiModal(null)} disabled={disableControls}>
{t('ai_providers.gemini_add_button')}
</Button>
}
>
{renderList<GeminiKeyConfig>(
geminiKeys,
(item) => item.apiKey,
(item, index) => (
<Fragment>
<div className="item-title">
{t('ai_providers.gemini_item_title')} #{index + 1}
</div>
<div className="item-subtitle">{maskApiKey(item.apiKey)}</div>
{item.baseUrl && <div className="pill">{item.baseUrl}</div>}
{item.excludedModels?.length ? (
<div className="item-subtitle">
{t('ai_providers.excluded_models_count', { count: item.excludedModels.length })}
</div>
) : null}
</Fragment>
),
(index) => openGeminiModal(index),
(item) => deleteGemini(item.apiKey),
t('ai_providers.gemini_add_button')
)}
</Card>
<Card
title={t('ai_providers.codex_title')}
extra={
<Button size="sm" onClick={() => openProviderModal('codex', null)} disabled={disableControls}>
{t('ai_providers.codex_add_button')}
</Button>
}
>
{renderList<ProviderKeyConfig>(
codexConfigs,
(item) => item.apiKey,
(item) => (
<Fragment>
<div className="item-title">{item.baseUrl || t('ai_providers.codex_item_title')}</div>
<div className="item-subtitle">{maskApiKey(item.apiKey)}</div>
{item.proxyUrl && <div className="pill">{item.proxyUrl}</div>}
</Fragment>
),
(index) => openProviderModal('codex', index),
(item) => deleteProviderEntry('codex', item.apiKey),
t('ai_providers.codex_add_button')
)}
</Card>
<Card
title={t('ai_providers.claude_title')}
extra={
<Button size="sm" onClick={() => openProviderModal('claude', null)} disabled={disableControls}>
{t('ai_providers.claude_add_button')}
</Button>
}
>
{renderList<ProviderKeyConfig>(
claudeConfigs,
(item) => item.apiKey,
(item) => (
<Fragment>
<div className="item-title">{item.baseUrl || t('ai_providers.claude_item_title')}</div>
<div className="item-subtitle">{maskApiKey(item.apiKey)}</div>
{item.proxyUrl && <div className="pill">{item.proxyUrl}</div>}
{item.models?.length ? (
<div className="item-subtitle">
{t('ai_providers.claude_models_count')}: {item.models.length}
</div>
) : null}
</Fragment>
),
(index) => openProviderModal('claude', index),
(item) => deleteProviderEntry('claude', item.apiKey),
t('ai_providers.claude_add_button')
)}
</Card>
<Card
title={t('ai_providers.openai_title')}
extra={
<Button size="sm" onClick={() => openOpenaiModal(null)} disabled={disableControls}>
{t('ai_providers.openai_add_button')}
</Button>
}
>
{renderList<OpenAIProviderConfig>(
openaiProviders,
(item) => item.name,
(item) => (
<Fragment>
<div className="item-title">{item.name}</div>
<div className="item-subtitle">{item.baseUrl}</div>
<div className="pill">
{t('ai_providers.openai_keys_count')}: {item.apiKeyEntries?.length || 0}
</div>
<div className="pill">
{t('ai_providers.openai_models_count')}: {item.models?.length || 0}
</div>
{item.priority !== undefined && <div className="pill">Priority: {item.priority}</div>}
{item.testModel && <div className="pill">{item.testModel}</div>}
</Fragment>
),
(index) => openOpenaiModal(index),
(item) => deleteOpenai(item.name),
t('ai_providers.openai_add_button'),
t('ai_providers.openai_delete_confirm')
)}
</Card>
{/* Gemini Modal */}
<Modal
open={modal?.type === 'gemini'}
onClose={closeModal}
title={
modal?.index !== null ? t('ai_providers.gemini_edit_modal_title') : t('ai_providers.gemini_add_modal_title')
}
footer={
<>
<Button variant="secondary" onClick={closeModal} disabled={saving}>
{t('common.cancel')}
</Button>
<Button onClick={saveGemini} loading={saving}>
{t('common.save')}
</Button>
</>
}
>
<Input
label={t('ai_providers.gemini_add_modal_key_label')}
placeholder={t('ai_providers.gemini_add_modal_key_placeholder')}
value={geminiForm.apiKey}
onChange={(e) => setGeminiForm((prev) => ({ ...prev, apiKey: e.target.value }))}
/>
<Input
label={t('ai_providers.gemini_base_url_placeholder')}
placeholder={t('ai_providers.gemini_base_url_placeholder')}
value={geminiForm.baseUrl ?? ''}
onChange={(e) => setGeminiForm((prev) => ({ ...prev, baseUrl: e.target.value }))}
/>
<HeaderInputList
entries={headersToEntries(geminiForm.headers as any)}
onChange={(entries) => setGeminiForm((prev) => ({ ...prev, headers: buildHeaderObject(entries) }))}
addLabel={t('common.custom_headers_add')}
keyPlaceholder={t('common.custom_headers_key_placeholder')}
valuePlaceholder={t('common.custom_headers_value_placeholder')}
/>
<div className="form-group">
<label>{t('ai_providers.excluded_models_label')}</label>
<textarea
className="input"
placeholder={t('ai_providers.excluded_models_placeholder')}
value={geminiForm.excludedText}
onChange={(e) => setGeminiForm((prev) => ({ ...prev, excludedText: e.target.value }))}
rows={4}
/>
<div className="hint">{t('ai_providers.excluded_models_hint')}</div>
</div>
</Modal>
{/* Codex / Claude Modal */}
<Modal
open={modal?.type === 'codex' || modal?.type === 'claude'}
onClose={closeModal}
title={
modal?.type === 'codex'
? modal.index !== null
? t('ai_providers.codex_edit_modal_title')
: t('ai_providers.codex_add_modal_title')
: modal?.type === 'claude' && modal.index !== null
? t('ai_providers.claude_edit_modal_title')
: t('ai_providers.claude_add_modal_title')
}
footer={
<>
<Button variant="secondary" onClick={closeModal} disabled={saving}>
{t('common.cancel')}
</Button>
<Button onClick={() => saveProvider(modal?.type as 'codex' | 'claude')} loading={saving}>
{t('common.save')}
</Button>
</>
}
>
<Input
label={
modal?.type === 'codex'
? t('ai_providers.codex_add_modal_key_label')
: t('ai_providers.claude_add_modal_key_label')
}
value={providerForm.apiKey}
onChange={(e) => setProviderForm((prev) => ({ ...prev, apiKey: e.target.value }))}
/>
<Input
label={
modal?.type === 'codex'
? t('ai_providers.codex_add_modal_url_label')
: t('ai_providers.claude_add_modal_url_label')
}
value={providerForm.baseUrl ?? ''}
onChange={(e) => setProviderForm((prev) => ({ ...prev, baseUrl: e.target.value }))}
/>
<Input
label={
modal?.type === 'codex'
? t('ai_providers.codex_add_modal_proxy_label')
: t('ai_providers.claude_add_modal_proxy_label')
}
value={providerForm.proxyUrl ?? ''}
onChange={(e) => setProviderForm((prev) => ({ ...prev, proxyUrl: e.target.value }))}
/>
<HeaderInputList
entries={headersToEntries(providerForm.headers as any)}
onChange={(entries) => setProviderForm((prev) => ({ ...prev, headers: buildHeaderObject(entries) }))}
addLabel={t('common.custom_headers_add')}
keyPlaceholder={t('common.custom_headers_key_placeholder')}
valuePlaceholder={t('common.custom_headers_value_placeholder')}
/>
<div className="form-group">
<label>{t('ai_providers.claude_models_label')}</label>
<textarea
className="input"
placeholder={t('ai_providers.claude_models_hint')}
value={providerForm.modelsText}
onChange={(e) => setProviderForm((prev) => ({ ...prev, modelsText: e.target.value }))}
rows={4}
/>
<div className="hint">{t('ai_providers.claude_models_hint')}</div>
</div>
</Modal>
{/* OpenAI Modal */}
<Modal
open={modal?.type === 'openai'}
onClose={closeModal}
title={
modal?.index !== null ? t('ai_providers.openai_edit_modal_title') : t('ai_providers.openai_add_modal_title')
}
footer={
<>
<Button variant="secondary" onClick={closeModal} disabled={saving}>
{t('common.cancel')}
</Button>
<Button onClick={saveOpenai} loading={saving}>
{t('common.save')}
</Button>
</>
}
>
<Input
label={t('ai_providers.openai_add_modal_name_label')}
value={openaiForm.name}
onChange={(e) => setOpenaiForm((prev) => ({ ...prev, name: e.target.value }))}
/>
<Input
label={t('ai_providers.openai_add_modal_url_label')}
value={openaiForm.baseUrl}
onChange={(e) => setOpenaiForm((prev) => ({ ...prev, baseUrl: e.target.value }))}
/>
<Input
label="Priority"
type="number"
value={openaiForm.priority ?? ''}
onChange={(e) =>
setOpenaiForm((prev) => ({ ...prev, priority: e.target.value ? Number(e.target.value) : undefined }))
}
/>
<Input
label={t('ai_providers.openai_test_model_placeholder')}
value={openaiForm.testModel ?? ''}
onChange={(e) => setOpenaiForm((prev) => ({ ...prev, testModel: e.target.value }))}
/>
<HeaderInputList
entries={openaiForm.headers}
onChange={(entries) => setOpenaiForm((prev) => ({ ...prev, headers: entries }))}
addLabel={t('common.custom_headers_add')}
keyPlaceholder={t('common.custom_headers_key_placeholder')}
valuePlaceholder={t('common.custom_headers_value_placeholder')}
/>
<div className="form-group">
<label>{t('ai_providers.openai_models_fetch_title')}</label>
<textarea
className="input"
placeholder={t('ai_providers.openai_models_hint')}
value={openaiForm.modelsText}
onChange={(e) => setOpenaiForm((prev) => ({ ...prev, modelsText: e.target.value }))}
rows={4}
/>
<div className="hint">{t('ai_providers.openai_models_hint')}</div>
</div>
<div className="form-group">
<label>{t('ai_providers.openai_add_modal_keys_label')}</label>
{renderKeyEntries(openaiForm.apiKeyEntries)}
</div>
</Modal>
</div>
);
}

View File

@@ -0,0 +1,54 @@
.container {
width: 100%;
}
.pageTitle {
font-size: 28px;
font-weight: 700;
color: var(--text-primary);
margin: 0 0 $spacing-xl 0;
}
.content {
display: flex;
flex-direction: column;
gap: $spacing-lg;
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
gap: $spacing-md;
@include mobile {
flex-direction: column;
align-items: stretch;
}
}
.actions {
display: flex;
gap: $spacing-sm;
}
.emptyState {
text-align: center;
padding: $spacing-2xl;
color: var(--text-secondary);
i {
font-size: 48px;
margin-bottom: $spacing-md;
opacity: 0.5;
}
h3 {
margin: 0 0 $spacing-sm 0;
color: var(--text-primary);
}
p {
margin: 0;
}
}

217
src/pages/ApiKeysPage.tsx Normal file
View File

@@ -0,0 +1,217 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Card } from '@/components/ui/Card';
import { Button } from '@/components/ui/Button';
import { Input } from '@/components/ui/Input';
import { Modal } from '@/components/ui/Modal';
import { EmptyState } from '@/components/ui/EmptyState';
import { LoadingSpinner } from '@/components/ui/LoadingSpinner';
import { useAuthStore, useConfigStore, useNotificationStore } from '@/stores';
import { apiKeysApi } from '@/services/api';
import { maskApiKey } from '@/utils/format';
export function ApiKeysPage() {
const { t } = useTranslation();
const { showNotification } = useNotificationStore();
const connectionStatus = useAuthStore((state) => state.connectionStatus);
const config = useConfigStore((state) => state.config);
const fetchConfig = useConfigStore((state) => state.fetchConfig);
const updateConfigValue = useConfigStore((state) => state.updateConfigValue);
const clearCache = useConfigStore((state) => state.clearCache);
const [apiKeys, setApiKeys] = useState<string[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [modalOpen, setModalOpen] = useState(false);
const [editingIndex, setEditingIndex] = useState<number | null>(null);
const [inputValue, setInputValue] = useState('');
const [saving, setSaving] = useState(false);
const [deletingIndex, setDeletingIndex] = useState<number | null>(null);
const disableControls = useMemo(() => connectionStatus !== 'connected', [connectionStatus]);
const loadApiKeys = useCallback(
async (force = false) => {
setLoading(true);
setError('');
try {
const result = (await fetchConfig('api-keys', force)) as string[] | undefined;
const list = Array.isArray(result) ? result : [];
setApiKeys(list);
} catch (err: any) {
setError(err?.message || t('notification.refresh_failed'));
} finally {
setLoading(false);
}
},
[fetchConfig, t]
);
useEffect(() => {
loadApiKeys(true);
}, [loadApiKeys]);
useEffect(() => {
if (Array.isArray(config?.apiKeys)) {
setApiKeys(config.apiKeys);
}
}, [config?.apiKeys]);
const openAddModal = () => {
setEditingIndex(null);
setInputValue('');
setModalOpen(true);
};
const openEditModal = (index: number) => {
setEditingIndex(index);
setInputValue(apiKeys[index] ?? '');
setModalOpen(true);
};
const closeModal = () => {
setModalOpen(false);
setInputValue('');
setEditingIndex(null);
};
const handleSave = async () => {
const trimmed = inputValue.trim();
if (!trimmed) {
showNotification(`${t('notification.please_enter')} ${t('notification.api_key')}`, 'error');
return;
}
const isEdit = editingIndex !== null;
const nextKeys = isEdit
? apiKeys.map((key, idx) => (idx === editingIndex ? trimmed : key))
: [...apiKeys, trimmed];
setSaving(true);
try {
if (isEdit && editingIndex !== null) {
await apiKeysApi.update(editingIndex, trimmed);
showNotification(t('notification.api_key_updated'), 'success');
} else {
await apiKeysApi.replace(nextKeys);
showNotification(t('notification.api_key_added'), 'success');
}
setApiKeys(nextKeys);
updateConfigValue('api-keys', nextKeys);
clearCache('api-keys');
closeModal();
} catch (err: any) {
showNotification(`${t('notification.update_failed')}: ${err?.message || ''}`, 'error');
} finally {
setSaving(false);
}
};
const handleDelete = async (index: number) => {
if (!window.confirm(t('api_keys.delete_confirm'))) return;
setDeletingIndex(index);
try {
await apiKeysApi.delete(index);
const nextKeys = apiKeys.filter((_, idx) => idx !== index);
setApiKeys(nextKeys);
updateConfigValue('api-keys', nextKeys);
clearCache('api-keys');
showNotification(t('notification.api_key_deleted'), 'success');
} catch (err: any) {
showNotification(`${t('notification.delete_failed')}: ${err?.message || ''}`, 'error');
} finally {
setDeletingIndex(null);
}
};
const actionButtons = (
<div style={{ display: 'flex', gap: 8 }}>
<Button variant="secondary" size="sm" onClick={() => loadApiKeys(true)} disabled={loading}>
{t('common.refresh')}
</Button>
<Button size="sm" onClick={openAddModal} disabled={disableControls}>
{t('api_keys.add_button')}
</Button>
</div>
);
return (
<Card title={t('api_keys.proxy_auth_title')} extra={actionButtons}>
{error && <div className="error-box">{error}</div>}
{loading ? (
<div className="flex-center" style={{ padding: '24px 0' }}>
<LoadingSpinner size={28} />
</div>
) : apiKeys.length === 0 ? (
<EmptyState
title={t('api_keys.empty_title')}
description={t('api_keys.empty_desc')}
action={
<Button onClick={openAddModal} disabled={disableControls}>
{t('api_keys.add_button')}
</Button>
}
/>
) : (
<div className="item-list">
{apiKeys.map((key, index) => (
<div key={index} className="item-row">
<div className="item-meta">
<div className="pill">#{index + 1}</div>
<div className="item-title">{t('api_keys.item_title')}</div>
<div className="item-subtitle">{maskApiKey(String(key || ''))}</div>
</div>
<div className="item-actions">
<Button variant="secondary" size="sm" onClick={() => openEditModal(index)} disabled={disableControls}>
{t('common.edit')}
</Button>
<Button
variant="danger"
size="sm"
onClick={() => handleDelete(index)}
disabled={disableControls || deletingIndex === index}
loading={deletingIndex === index}
>
{t('common.delete')}
</Button>
</div>
</div>
))}
</div>
)}
<Modal
open={modalOpen}
onClose={closeModal}
title={editingIndex !== null ? t('api_keys.edit_modal_title') : t('api_keys.add_modal_title')}
footer={
<>
<Button variant="secondary" onClick={closeModal} disabled={saving}>
{t('common.cancel')}
</Button>
<Button onClick={handleSave} loading={saving}>
{editingIndex !== null ? t('common.update') : t('common.add')}
</Button>
</>
}
>
<Input
label={
editingIndex !== null ? t('api_keys.edit_modal_key_label') : t('api_keys.add_modal_key_label')
}
placeholder={
editingIndex !== null
? t('api_keys.edit_modal_key_label')
: t('api_keys.add_modal_key_placeholder')
}
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
disabled={saving}
/>
</Modal>
</Card>
);
}

View File

@@ -0,0 +1,58 @@
.container {
width: 100%;
}
.pageTitle {
font-size: 28px;
font-weight: 700;
color: var(--text-primary);
margin: 0 0 $spacing-md 0;
}
.description {
font-size: 14px;
color: var(--text-secondary);
margin: 0 0 $spacing-xl 0;
}
.content {
display: flex;
flex-direction: column;
gap: $spacing-lg;
}
.controls {
display: flex;
justify-content: space-between;
align-items: center;
gap: $spacing-md;
@include mobile {
flex-direction: column;
align-items: stretch;
}
}
.filters {
display: flex;
gap: $spacing-sm;
flex-wrap: wrap;
}
.fileGrid {
display: grid;
gap: $spacing-md;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
@include mobile {
grid-template-columns: 1fr;
}
}
.pagination {
display: flex;
justify-content: center;
align-items: center;
gap: $spacing-md;
margin-top: $spacing-lg;
}

416
src/pages/AuthFilesPage.tsx Normal file
View File

@@ -0,0 +1,416 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Card } from '@/components/ui/Card';
import { Button } from '@/components/ui/Button';
import { Input } from '@/components/ui/Input';
import { Modal } from '@/components/ui/Modal';
import { EmptyState } from '@/components/ui/EmptyState';
import { useAuthStore, useNotificationStore } from '@/stores';
import { authFilesApi } from '@/services/api';
import { apiClient } from '@/services/api/client';
import type { AuthFileItem } from '@/types';
import { formatFileSize } from '@/utils/format';
interface ExcludedFormState {
provider: string;
modelsText: string;
}
export function AuthFilesPage() {
const { t } = useTranslation();
const { showNotification } = useNotificationStore();
const connectionStatus = useAuthStore((state) => state.connectionStatus);
const [files, setFiles] = useState<AuthFileItem[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [filter, setFilter] = useState<'all' | string>('all');
const [search, setSearch] = useState('');
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(10);
const [uploading, setUploading] = useState(false);
const [deleting, setDeleting] = useState<string | null>(null);
const [excluded, setExcluded] = useState<Record<string, string[]>>({});
const [excludedModalOpen, setExcludedModalOpen] = useState(false);
const [excludedForm, setExcludedForm] = useState<ExcludedFormState>({ provider: '', modelsText: '' });
const [savingExcluded, setSavingExcluded] = useState(false);
const fileInputRef = useRef<HTMLInputElement | null>(null);
const disableControls = connectionStatus !== 'connected';
const loadFiles = async () => {
setLoading(true);
setError('');
try {
const data = await authFilesApi.list();
setFiles(data?.files || []);
} catch (err: any) {
setError(err?.message || t('notification.refresh_failed'));
} finally {
setLoading(false);
}
};
const loadExcluded = async () => {
try {
const res = await authFilesApi.getOauthExcludedModels();
setExcluded(res || {});
} catch (err) {
// ignore silently
}
};
useEffect(() => {
loadFiles();
loadExcluded();
}, []);
const filtered = useMemo(() => {
return files.filter((item) => {
const matchType = filter === 'all' || item.type === filter;
const term = search.trim().toLowerCase();
const matchSearch =
!term ||
item.name.toLowerCase().includes(term) ||
(item.type || '').toString().toLowerCase().includes(term) ||
(item.provider || '').toString().toLowerCase().includes(term);
return matchType && matchSearch;
});
}, [files, filter, search]);
const totalPages = Math.max(1, Math.ceil(filtered.length / pageSize));
const currentPage = Math.min(page, totalPages);
const start = (currentPage - 1) * pageSize;
const pageItems = filtered.slice(start, start + pageSize);
const totalSize = useMemo(() => files.reduce((sum, item) => sum + (item.size || 0), 0), [files]);
const handleUploadClick = () => {
fileInputRef.current?.click();
};
const handleFileChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (!file) return;
setUploading(true);
try {
await authFilesApi.upload(file);
showNotification(t('auth_files.upload_success'), 'success');
await loadFiles();
} catch (err: any) {
showNotification(`${t('notification.upload_failed')}: ${err?.message || ''}`, 'error');
} finally {
setUploading(false);
event.target.value = '';
}
};
const handleDelete = async (name: string) => {
if (!window.confirm(t('auth_files.delete_confirm'))) return;
setDeleting(name);
try {
await authFilesApi.deleteFile(name);
showNotification(t('auth_files.delete_success'), 'success');
setFiles((prev) => prev.filter((item) => item.name !== name));
} catch (err: any) {
showNotification(`${t('notification.delete_failed')}: ${err?.message || ''}`, 'error');
} finally {
setDeleting(null);
}
};
const handleDeleteAll = async () => {
if (!window.confirm(t('auth_files.delete_all_confirm'))) return;
try {
await authFilesApi.deleteAll();
showNotification(t('auth_files.delete_all_success'), 'success');
setFiles([]);
} catch (err: any) {
showNotification(`${t('notification.delete_failed')}: ${err?.message || ''}`, 'error');
}
};
const handleDownload = async (name: string) => {
try {
const response = await apiClient.getRaw(`/auth-files/${encodeURIComponent(name)}`, {
responseType: 'blob'
});
const blob = new Blob([response.data]);
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = name;
a.click();
window.URL.revokeObjectURL(url);
showNotification(t('auth_files.download_success'), 'success');
} catch (err: any) {
showNotification(`${t('notification.download_failed')}: ${err?.message || ''}`, 'error');
}
};
const openExcludedModal = (provider?: string) => {
const models = provider ? excluded[provider] : [];
setExcludedForm({
provider: provider || '',
modelsText: Array.isArray(models) ? models.join('\n') : ''
});
setExcludedModalOpen(true);
};
const saveExcludedModels = async () => {
const provider = excludedForm.provider.trim();
if (!provider) {
showNotification(t('oauth_excluded.provider_required'), 'error');
return;
}
const models = excludedForm.modelsText
.split(/[\n,]+/)
.map((item) => item.trim())
.filter(Boolean);
setSavingExcluded(true);
try {
if (models.length) {
await authFilesApi.saveOauthExcludedModels(provider, models);
} else {
await authFilesApi.deleteOauthExcludedEntry(provider);
}
await loadExcluded();
showNotification(t('oauth_excluded.save_success'), 'success');
setExcludedModalOpen(false);
} catch (err: any) {
showNotification(`${t('oauth_excluded.save_failed')}: ${err?.message || ''}`, 'error');
} finally {
setSavingExcluded(false);
}
};
const deleteExcluded = async (provider: string) => {
if (!window.confirm(t('oauth_excluded.delete_confirm', { provider }))) return;
try {
await authFilesApi.deleteOauthExcludedEntry(provider);
await loadExcluded();
showNotification(t('oauth_excluded.delete_success'), 'success');
} catch (err: any) {
showNotification(`${t('oauth_excluded.delete_failed')}: ${err?.message || ''}`, 'error');
}
};
const typeOptions: { value: string; label: string }[] = [
{ value: 'all', label: t('auth_files.filter_all') },
{ value: 'qwen', label: t('auth_files.filter_qwen') },
{ value: 'gemini', label: t('auth_files.filter_gemini') },
{ value: 'gemini-cli', label: t('auth_files.filter_gemini-cli') },
{ value: 'aistudio', label: t('auth_files.filter_aistudio') },
{ value: 'claude', label: t('auth_files.filter_claude') },
{ value: 'codex', label: t('auth_files.filter_codex') },
{ value: 'antigravity', label: t('auth_files.filter_antigravity') },
{ value: 'iflow', label: t('auth_files.filter_iflow') },
{ value: 'vertex', label: t('auth_files.filter_vertex') },
{ value: 'empty', label: t('auth_files.filter_empty') },
{ value: 'unknown', label: t('auth_files.filter_unknown') }
];
return (
<div className="stack">
<Card
title={t('auth_files.title')}
extra={
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
<Button variant="secondary" size="sm" onClick={loadFiles} disabled={loading}>
{t('common.refresh')}
</Button>
<Button variant="secondary" size="sm" onClick={handleDeleteAll} disabled={disableControls || loading}>
{t('auth_files.delete_all_button')}
</Button>
<Button size="sm" onClick={handleUploadClick} disabled={disableControls || uploading}>
{t('auth_files.upload_button')}
</Button>
<input
ref={fileInputRef}
type="file"
accept=".json,application/json"
style={{ display: 'none' }}
onChange={handleFileChange}
/>
</div>
}
>
{error && <div className="error-box">{error}</div>}
<div className="filters">
<div className="filter-item">
<label>{t('auth_files.search_label')}</label>
<Input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder={t('auth_files.search_placeholder')}
/>
</div>
<div className="filter-item">
<label>{t('auth_files.page_size_label')}</label>
<input
className="input"
type="number"
min={1}
value={pageSize}
onChange={(e) => setPageSize(Number(e.target.value) || 10)}
/>
</div>
<div className="filter-item">
<label>{t('common.info')}</label>
<div className="pill">
{files.length} {t('auth_files.files_count')} · {formatFileSize(totalSize)}
</div>
</div>
<div className="filter-item">
<label>{t('auth_files.filter_all')}</label>
<select className="input" value={filter} onChange={(e) => setFilter(e.target.value)}>
{typeOptions.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
</div>
{loading ? (
<div className="hint">{t('common.loading')}</div>
) : pageItems.length === 0 ? (
<EmptyState title={t('auth_files.search_empty_title')} description={t('auth_files.search_empty_desc')} />
) : (
<div className="table">
<div className="table-header">
<div>{t('auth_files.title_section')}</div>
<div>{t('auth_files.file_size')}</div>
<div>{t('auth_files.file_modified')}</div>
<div>Actions</div>
</div>
{pageItems.map((item) => (
<div key={item.name} className="table-row">
<div className="cell">
<div className="item-title">{item.name}</div>
<div className="item-subtitle">
{item.type || t('auth_files.type_unknown')} {item.provider ? `· ${item.provider}` : ''}
</div>
</div>
<div className="cell">{item.size ? formatFileSize(item.size) : '-'}</div>
<div className="cell">
{item.modified ? new Date(item.modified).toLocaleString() : t('auth_files.file_modified')}
</div>
<div className="cell">
<div className="item-actions">
<Button variant="secondary" size="sm" onClick={() => handleDownload(item.name)} disabled={disableControls}>
{t('auth_files.download_button')}
</Button>
<Button
variant="danger"
size="sm"
onClick={() => handleDelete(item.name)}
loading={deleting === item.name}
disabled={disableControls}
>
{t('auth_files.delete_button')}
</Button>
</div>
</div>
</div>
))}
</div>
)}
<div className="pagination">
<Button variant="secondary" size="sm" onClick={() => setPage(Math.max(1, currentPage - 1))}>
{t('auth_files.pagination_prev')}
</Button>
<div className="pill">
{t('auth_files.pagination_info', {
current: currentPage,
total: totalPages,
count: filtered.length
})}
</div>
<Button
variant="secondary"
size="sm"
onClick={() => setPage(Math.min(totalPages, currentPage + 1))}
>
{t('auth_files.pagination_next')}
</Button>
</div>
</Card>
<Card
title={t('oauth_excluded.title')}
extra={
<Button size="sm" onClick={() => openExcludedModal()} disabled={disableControls}>
{t('oauth_excluded.add')}
</Button>
}
>
{Object.keys(excluded).length === 0 ? (
<EmptyState title={t('oauth_excluded.list_empty_all')} />
) : (
<div className="item-list">
{Object.entries(excluded).map(([provider, models]) => (
<div key={provider} className="item-row">
<div className="item-meta">
<div className="item-title">{provider}</div>
<div className="item-subtitle">
{models?.length
? t('oauth_excluded.model_count', { count: models.length })
: t('oauth_excluded.no_models')}
</div>
</div>
<div className="item-actions">
<Button variant="secondary" size="sm" onClick={() => openExcludedModal(provider)}>
{t('common.edit')}
</Button>
<Button variant="danger" size="sm" onClick={() => deleteExcluded(provider)}>
{t('oauth_excluded.delete')}
</Button>
</div>
</div>
))}
</div>
)}
</Card>
<Modal
open={excludedModalOpen}
onClose={() => setExcludedModalOpen(false)}
title={t('oauth_excluded.add_title')}
footer={
<>
<Button variant="secondary" onClick={() => setExcludedModalOpen(false)} disabled={savingExcluded}>
{t('common.cancel')}
</Button>
<Button onClick={saveExcludedModels} loading={savingExcluded}>
{t('oauth_excluded.save')}
</Button>
</>
}
>
<Input
label={t('oauth_excluded.provider_label')}
placeholder={t('oauth_excluded.provider_placeholder')}
value={excludedForm.provider}
onChange={(e) => setExcludedForm((prev) => ({ ...prev, provider: e.target.value }))}
/>
<div className="form-group">
<label>{t('oauth_excluded.models_label')}</label>
<textarea
className="input"
rows={4}
placeholder={t('oauth_excluded.models_placeholder')}
value={excludedForm.modelsText}
onChange={(e) => setExcludedForm((prev) => ({ ...prev, modelsText: e.target.value }))}
/>
<div className="hint">{t('oauth_excluded.models_hint')}</div>
</div>
</Modal>
</div>
);
}

View File

@@ -0,0 +1,65 @@
.container {
width: 100%;
}
.pageTitle {
font-size: 28px;
font-weight: 700;
color: var(--text-primary);
margin: 0 0 $spacing-md 0;
}
.description {
font-size: 14px;
color: var(--text-secondary);
margin: 0 0 $spacing-xl 0;
}
.content {
display: flex;
flex-direction: column;
gap: $spacing-lg;
}
.controls {
display: flex;
justify-content: space-between;
align-items: center;
gap: $spacing-md;
@include mobile {
flex-direction: column;
align-items: stretch;
}
}
.status {
font-size: 14px;
color: var(--text-secondary);
&.modified {
color: #f59e0b;
}
&.saved {
color: #16a34a;
}
&.error {
color: #dc2626;
}
}
.editorWrapper {
width: 100%;
height: 500px;
border: 1px solid var(--border-color);
border-radius: $radius-lg;
overflow: hidden;
}
.actions {
display: flex;
gap: $spacing-sm;
justify-content: flex-end;
}

86
src/pages/ConfigPage.tsx Normal file
View File

@@ -0,0 +1,86 @@
import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Card } from '@/components/ui/Card';
import { Button } from '@/components/ui/Button';
import { useNotificationStore, useAuthStore } from '@/stores';
import { configFileApi } from '@/services/api/configFile';
export function ConfigPage() {
const { t } = useTranslation();
const { showNotification } = useNotificationStore();
const connectionStatus = useAuthStore((state) => state.connectionStatus);
const [content, setContent] = useState('');
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [error, setError] = useState('');
const [dirty, setDirty] = useState(false);
const disableControls = connectionStatus !== 'connected';
const loadConfig = async () => {
setLoading(true);
setError('');
try {
const data = await configFileApi.fetchConfigYaml();
setContent(data);
setDirty(false);
} catch (err: any) {
setError(err?.message || t('notification.refresh_failed'));
} finally {
setLoading(false);
}
};
useEffect(() => {
loadConfig();
}, []);
const handleSave = async () => {
setSaving(true);
try {
await configFileApi.saveConfigYaml(content);
setDirty(false);
showNotification(t('notification.saved_success'), 'success');
} catch (err: any) {
showNotification(`${t('notification.save_failed')}: ${err?.message || ''}`, 'error');
} finally {
setSaving(false);
}
};
return (
<Card
title={t('nav.config_management')}
extra={
<div style={{ display: 'flex', gap: 8 }}>
<Button variant="secondary" size="sm" onClick={loadConfig} disabled={loading}>
{t('common.refresh')}
</Button>
<Button size="sm" onClick={handleSave} loading={saving} disabled={disableControls || loading || !dirty}>
{t('common.save')}
</Button>
</div>
}
>
{error && <div className="error-box">{error}</div>}
<div className="form-group">
<label>{t('nav.config_management')}</label>
<textarea
className="input"
rows={20}
value={content}
onChange={(e) => {
setContent(e.target.value);
setDirty(true);
}}
disabled={disableControls}
placeholder="config.yaml"
/>
<div className="hint">
{dirty ? t('system_info.version_current_missing') : loading ? t('common.loading') : t('system_info.version_is_latest')}
</div>
</div>
</Card>
);
}

View File

@@ -0,0 +1,124 @@
.container {
min-height: 100vh;
display: flex;
flex-direction: column;
background: linear-gradient(135deg, var(--primary-color) 0%, #1d4ed8 100%);
padding: $spacing-md;
}
.header {
display: flex;
justify-content: flex-end;
padding: $spacing-md 0;
}
.controls {
display: flex;
gap: $spacing-sm;
}
.iconButton {
@include button-reset;
display: flex;
align-items: center;
gap: $spacing-xs;
padding: $spacing-sm $spacing-md;
background-color: rgba(255, 255, 255, 0.2);
color: white;
border-radius: $radius-md;
transition: background-color $transition-fast;
&:hover {
background-color: rgba(255, 255, 255, 0.3);
}
i {
font-size: 18px;
}
span {
font-size: 14px;
font-weight: 500;
}
}
.loginBox {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
max-width: 450px;
width: 100%;
margin: 0 auto;
}
.logo {
width: 80px;
height: 80px;
background-color: white;
border-radius: $radius-full;
display: flex;
align-items: center;
justify-content: center;
font-size: 40px;
color: var(--primary-color);
margin-bottom: $spacing-lg;
box-shadow: var(--shadow-lg);
}
.title {
font-size: 28px;
font-weight: 700;
color: white;
margin: 0 0 $spacing-sm 0;
text-align: center;
}
.subtitle {
font-size: 14px;
color: rgba(255, 255, 255, 0.9);
margin: 0 0 $spacing-2xl 0;
text-align: center;
}
.form {
width: 100%;
background-color: var(--bg-primary);
padding: $spacing-2xl;
border-radius: $radius-lg;
box-shadow: var(--shadow-lg);
display: flex;
flex-direction: column;
gap: $spacing-lg;
}
.detectedInfo {
display: flex;
align-items: center;
gap: $spacing-sm;
padding: $spacing-md;
background-color: rgba(59, 130, 246, 0.1);
border-radius: $radius-md;
font-size: 13px;
color: var(--text-secondary);
i {
color: var(--primary-color);
}
strong {
color: var(--text-primary);
}
}
.footer {
margin-top: $spacing-xl;
text-align: center;
}
.version {
font-size: 12px;
color: rgba(255, 255, 255, 0.7);
margin: 0;
}

150
src/pages/LoginPage.tsx Normal file
View File

@@ -0,0 +1,150 @@
import { useEffect, useMemo, useState } from 'react';
import { useNavigate, useLocation } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { Button } from '@/components/ui/Button';
import { Input } from '@/components/ui/Input';
import { useAuthStore, useNotificationStore } from '@/stores';
import { detectApiBaseFromLocation, normalizeApiBase } from '@/utils/connection';
export function LoginPage() {
const { t } = useTranslation();
const navigate = useNavigate();
const location = useLocation();
const { showNotification } = useNotificationStore();
const isAuthenticated = useAuthStore((state) => state.isAuthenticated);
const login = useAuthStore((state) => state.login);
const restoreSession = useAuthStore((state) => state.restoreSession);
const storedBase = useAuthStore((state) => state.apiBase);
const storedKey = useAuthStore((state) => state.managementKey);
const [apiBase, setApiBase] = useState('');
const [managementKey, setManagementKey] = useState('');
const [showCustomBase, setShowCustomBase] = useState(false);
const [showKey, setShowKey] = useState(false);
const [loading, setLoading] = useState(false);
const [autoLoading, setAutoLoading] = useState(true);
const [error, setError] = useState('');
const detectedBase = useMemo(() => detectApiBaseFromLocation(), []);
useEffect(() => {
const init = async () => {
try {
const autoLoggedIn = await restoreSession();
if (!autoLoggedIn) {
setApiBase(storedBase || detectedBase);
setManagementKey(storedKey || '');
}
} finally {
setAutoLoading(false);
}
};
init();
}, [detectedBase, restoreSession, storedBase, storedKey]);
useEffect(() => {
if (isAuthenticated) {
const redirect = (location.state as any)?.from?.pathname || '/';
navigate(redirect, { replace: true });
}
}, [isAuthenticated, navigate, location.state]);
const handleUseCurrent = () => {
setApiBase(detectedBase);
};
const handleSubmit = async () => {
if (!managementKey.trim()) {
setError(t('login.error_required'));
return;
}
const baseToUse = apiBase ? normalizeApiBase(apiBase) : detectedBase;
setLoading(true);
setError('');
try {
await login({ apiBase: baseToUse, managementKey: managementKey.trim() });
showNotification(t('common.connected_status'), 'success');
navigate('/', { replace: true });
} catch (err: any) {
const message = err?.message || t('login.error_invalid');
setError(message);
showNotification(`${t('notification.login_failed')}: ${message}`, 'error');
} finally {
setLoading(false);
}
};
return (
<div className="login-page">
<div className="login-card">
<div className="login-header">
<div className="title">{t('title.login')}</div>
<div className="subtitle">{t('login.subtitle')}</div>
</div>
<div className="connection-box">
<div className="label">{t('login.connection_current')}</div>
<div className="value">{apiBase || detectedBase}</div>
<div className="hint">{t('login.connection_auto_hint')}</div>
</div>
<div className="toggle-advanced">
<input
id="custom-connection-toggle"
type="checkbox"
checked={showCustomBase}
onChange={(e) => setShowCustomBase(e.target.checked)}
/>
<label htmlFor="custom-connection-toggle">{t('login.custom_connection_label')}</label>
</div>
{showCustomBase && (
<Input
label={t('login.custom_connection_label')}
placeholder={t('login.custom_connection_placeholder')}
value={apiBase}
onChange={(e) => setApiBase(e.target.value)}
hint={t('login.custom_connection_hint')}
/>
)}
<Input
label={t('login.management_key_label')}
placeholder={t('login.management_key_placeholder')}
type={showKey ? 'text' : 'password'}
value={managementKey}
onChange={(e) => setManagementKey(e.target.value)}
rightElement={
<button
type="button"
className="btn btn-ghost btn-sm"
onClick={() => setShowKey((prev) => !prev)}
>
{showKey ? '🙈' : '👁️'}
</button>
}
/>
<div style={{ display: 'flex', gap: 12, alignItems: 'center' }}>
<Button variant="secondary" onClick={handleUseCurrent}>
{t('login.use_current_address')}
</Button>
<Button fullWidth onClick={handleSubmit} loading={loading}>
{loading ? t('login.submitting') : t('login.submit_button')}
</Button>
</div>
{error && <div className="error-box">{error}</div>}
{autoLoading && (
<div className="connection-box">
<div className="label">{t('auto_login.title')}</div>
<div className="value">{t('auto_login.message')}</div>
</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,81 @@
.container {
width: 100%;
}
.pageTitle {
font-size: 28px;
font-weight: 700;
color: var(--text-primary);
margin: 0 0 $spacing-xl 0;
}
.content {
display: flex;
flex-direction: column;
gap: $spacing-lg;
}
.controls {
display: flex;
justify-content: space-between;
align-items: center;
gap: $spacing-md;
@include mobile {
flex-direction: column;
align-items: stretch;
}
}
.actions {
display: flex;
gap: $spacing-sm;
@include mobile {
flex-wrap: wrap;
}
}
.logViewer {
background-color: #1e1e1e;
color: #d4d4d4;
padding: $spacing-lg;
border-radius: $radius-lg;
font-family: 'Consolas', 'Monaco', 'Courier New', monospace;
font-size: 13px;
line-height: 1.6;
overflow-x: auto;
max-height: 600px;
overflow-y: auto;
pre {
margin: 0;
white-space: pre-wrap;
word-wrap: break-word;
}
}
.searchBox {
margin-bottom: $spacing-md;
}
.emptyState {
text-align: center;
padding: $spacing-2xl;
color: var(--text-secondary);
i {
font-size: 48px;
margin-bottom: $spacing-md;
opacity: 0.5;
}
h3 {
margin: 0 0 $spacing-sm 0;
color: var(--text-primary);
}
p {
margin: 0;
}
}

198
src/pages/LogsPage.tsx Normal file
View File

@@ -0,0 +1,198 @@
import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Card } from '@/components/ui/Card';
import { Button } from '@/components/ui/Button';
import { EmptyState } from '@/components/ui/EmptyState';
import { useNotificationStore, useAuthStore } from '@/stores';
import { logsApi } from '@/services/api/logs';
interface ErrorLogItem {
name: string;
size?: number;
modified?: number;
}
export function LogsPage() {
const { t } = useTranslation();
const { showNotification } = useNotificationStore();
const connectionStatus = useAuthStore((state) => state.connectionStatus);
const [logs, setLogs] = useState<string>('');
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [autoRefresh, setAutoRefresh] = useState(false);
const [intervalId, setIntervalId] = useState<number | null>(null);
const [errorLogs, setErrorLogs] = useState<ErrorLogItem[]>([]);
const [loadingErrors, setLoadingErrors] = useState(false);
const disableControls = connectionStatus !== 'connected';
const loadLogs = async () => {
if (connectionStatus !== 'connected') {
setLoading(false);
return;
}
setLoading(true);
setError('');
try {
const data = await logsApi.fetchLogs({ limit: 500 });
const text = Array.isArray(data) ? data.join('\n') : data?.logs || data || '';
setLogs(text);
} catch (err: any) {
console.error('Failed to load logs:', err);
setError(err?.message || t('logs.load_error'));
} finally {
setLoading(false);
}
};
const clearLogs = async () => {
if (!window.confirm(t('logs.clear_confirm'))) return;
try {
await logsApi.clearLogs();
setLogs('');
showNotification(t('logs.clear_success'), 'success');
} catch (err: any) {
showNotification(`${t('notification.delete_failed')}: ${err?.message || ''}`, 'error');
}
};
const downloadLogs = () => {
const blob = new Blob([logs], { type: 'text/plain' });
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'logs.txt';
a.click();
window.URL.revokeObjectURL(url);
showNotification(t('logs.download_success'), 'success');
};
const loadErrorLogs = async () => {
if (connectionStatus !== 'connected') {
setLoadingErrors(false);
return;
}
setLoadingErrors(true);
try {
const res = await logsApi.fetchErrorLogs();
const list: ErrorLogItem[] = Array.isArray(res)
? res
: Object.entries(res || {}).map(([name, meta]) => ({
name,
size: (meta as any)?.size,
modified: (meta as any)?.modified
}));
setErrorLogs(list);
} catch (err: any) {
console.error('Failed to load error logs:', err);
// 静默失败,不影响主日志显示
setErrorLogs([]);
} finally {
setLoadingErrors(false);
}
};
const downloadErrorLog = async (name: string) => {
try {
const response = await logsApi.downloadErrorLog(name);
const blob = new Blob([response.data], { type: 'text/plain' });
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = name;
a.click();
window.URL.revokeObjectURL(url);
showNotification(t('logs.error_log_download_success'), 'success');
} catch (err: any) {
showNotification(`${t('notification.download_failed')}: ${err?.message || ''}`, 'error');
}
};
useEffect(() => {
if (connectionStatus === 'connected') {
loadLogs();
loadErrorLogs();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [connectionStatus]);
useEffect(() => {
if (autoRefresh) {
const id = window.setInterval(loadLogs, 8000);
setIntervalId(id);
return () => window.clearInterval(id);
}
if (intervalId) {
window.clearInterval(intervalId);
setIntervalId(null);
}
}, [autoRefresh]);
return (
<div className="stack">
<Card
title={t('logs.title')}
extra={
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
<Button variant="secondary" size="sm" onClick={loadLogs} disabled={loading}>
{t('logs.refresh_button')}
</Button>
<Button variant="secondary" size="sm" onClick={() => setAutoRefresh((v) => !v)}>
{t('logs.auto_refresh')}: {autoRefresh ? t('common.yes') : t('common.no')}
</Button>
<Button variant="secondary" size="sm" onClick={downloadLogs} disabled={!logs}>
{t('logs.download_button')}
</Button>
<Button variant="danger" size="sm" onClick={clearLogs} disabled={disableControls}>
{t('logs.clear_button')}
</Button>
</div>
}
>
{error && <div className="error-box">{error}</div>}
{loading ? (
<div className="hint">{t('logs.loading')}</div>
) : logs ? (
<pre className="log-viewer">{logs}</pre>
) : (
<EmptyState title={t('logs.empty_title')} description={t('logs.empty_desc')} />
)}
</Card>
<Card
title={t('logs.error_logs_modal_title')}
extra={
<Button variant="secondary" size="sm" onClick={loadErrorLogs} loading={loadingErrors}>
{t('common.refresh')}
</Button>
}
>
{errorLogs.length === 0 ? (
<div className="hint">{t('logs.error_logs_empty')}</div>
) : (
<div className="item-list">
{errorLogs.map((item) => (
<div key={item.name} className="item-row">
<div className="item-meta">
<div className="item-title">{item.name}</div>
<div className="item-subtitle">
{item.size ? `${(item.size / 1024).toFixed(1)} KB` : ''}{' '}
{item.modified ? new Date(item.modified).toLocaleString() : ''}
</div>
</div>
<div className="item-actions">
<Button variant="secondary" size="sm" onClick={() => downloadErrorLog(item.name)}>
{t('logs.error_logs_download')}
</Button>
</div>
</div>
))}
</div>
)}
</Card>
</div>
);
}

View File

@@ -0,0 +1,59 @@
.container {
width: 100%;
}
.pageTitle {
font-size: 28px;
font-weight: 700;
color: var(--text-primary);
margin: 0 0 $spacing-xl 0;
}
.content {
display: flex;
flex-direction: column;
gap: $spacing-xl;
}
.oauthSection {
display: flex;
flex-direction: column;
gap: $spacing-lg;
}
.oauthGrid {
display: grid;
gap: $spacing-lg;
grid-template-columns: repeat(auto-fit, minmax(400px, 1fr));
@include mobile {
grid-template-columns: 1fr;
}
}
.oauthCard {
display: flex;
flex-direction: column;
gap: $spacing-md;
}
.oauthStatus {
padding: $spacing-md;
border-radius: $radius-md;
font-size: 14px;
&.success {
background-color: rgba(34, 197, 94, 0.1);
color: #16a34a;
}
&.error {
background-color: rgba(239, 68, 68, 0.1);
color: #dc2626;
}
&.waiting {
background-color: rgba(59, 130, 246, 0.1);
color: #3b82f6;
}
}

160
src/pages/OAuthPage.tsx Normal file
View File

@@ -0,0 +1,160 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Card } from '@/components/ui/Card';
import { Button } from '@/components/ui/Button';
import { useNotificationStore } from '@/stores';
import { oauthApi, type OAuthProvider } from '@/services/api/oauth';
import { isLocalhost } from '@/utils/connection';
interface ProviderState {
url?: string;
state?: string;
status?: 'idle' | 'waiting' | 'success' | 'error';
error?: string;
polling?: boolean;
}
const PROVIDERS: { id: OAuthProvider; titleKey: string; hintKey: string; urlLabelKey: string }[] = [
{ id: 'codex', titleKey: 'auth_login.codex_oauth_title', hintKey: 'auth_login.codex_oauth_hint', urlLabelKey: 'auth_login.codex_oauth_url_label' },
{ id: 'anthropic', titleKey: 'auth_login.anthropic_oauth_title', hintKey: 'auth_login.anthropic_oauth_hint', urlLabelKey: 'auth_login.anthropic_oauth_url_label' },
{ id: 'antigravity', titleKey: 'auth_login.antigravity_oauth_title', hintKey: 'auth_login.antigravity_oauth_hint', urlLabelKey: 'auth_login.antigravity_oauth_url_label' },
{ id: 'gemini-cli', titleKey: 'auth_login.gemini_cli_oauth_title', hintKey: 'auth_login.gemini_cli_oauth_hint', urlLabelKey: 'auth_login.gemini_cli_oauth_url_label' },
{ id: 'qwen', titleKey: 'auth_login.qwen_oauth_title', hintKey: 'auth_login.qwen_oauth_hint', urlLabelKey: 'auth_login.qwen_oauth_url_label' },
{ id: 'iflow', titleKey: 'auth_login.iflow_oauth_title', hintKey: 'auth_login.iflow_oauth_hint', urlLabelKey: 'auth_login.iflow_oauth_url_label' }
];
export function OAuthPage() {
const { t } = useTranslation();
const { showNotification } = useNotificationStore();
const [states, setStates] = useState<Record<OAuthProvider, ProviderState>>({} as Record<OAuthProvider, ProviderState>);
const timers = useRef<Record<string, number>>({});
const isLocal = useMemo(() => isLocalhost(window.location.hostname), []);
useEffect(() => {
return () => {
Object.values(timers.current).forEach((timer) => window.clearInterval(timer));
};
}, []);
const startPolling = (provider: OAuthProvider, state: string) => {
if (timers.current[provider]) {
clearInterval(timers.current[provider]);
}
const timer = window.setInterval(async () => {
try {
const res = await oauthApi.getAuthStatus(state);
if (res.status === 'ok') {
setStates((prev) => ({
...prev,
[provider]: { ...prev[provider], status: 'success', polling: false }
}));
showNotification(t('auth_login.codex_oauth_status_success'), 'success');
window.clearInterval(timer);
delete timers.current[provider];
} else if (res.status === 'error') {
setStates((prev) => ({
...prev,
[provider]: { ...prev[provider], status: 'error', error: res.error, polling: false }
}));
showNotification(`${t('auth_login.codex_oauth_status_error')} ${res.error || ''}`, 'error');
window.clearInterval(timer);
delete timers.current[provider];
}
} catch (err: any) {
setStates((prev) => ({
...prev,
[provider]: { ...prev[provider], status: 'error', error: err?.message, polling: false }
}));
window.clearInterval(timer);
delete timers.current[provider];
}
}, 3000);
timers.current[provider] = timer;
};
const startAuth = async (provider: OAuthProvider) => {
setStates((prev) => ({
...prev,
[provider]: { ...prev[provider], status: 'waiting', polling: true, error: undefined }
}));
try {
const res = await oauthApi.startAuth(provider);
setStates((prev) => ({
...prev,
[provider]: { ...prev[provider], url: res.url, state: res.state, status: 'waiting', polling: true }
}));
if (res.state) {
startPolling(provider, res.state);
}
} catch (err: any) {
setStates((prev) => ({
...prev,
[provider]: { ...prev[provider], status: 'error', error: err?.message, polling: false }
}));
showNotification(`${t('auth_login.codex_oauth_start_error')} ${err?.message || ''}`, 'error');
}
};
const copyLink = async (url?: string) => {
if (!url) return;
try {
await navigator.clipboard.writeText(url);
showNotification(t('notification.link_copied'), 'success');
} catch {
showNotification('Copy failed', 'error');
}
};
if (!isLocal) {
return <Card title="OAuth">OAuth is only available on localhost.</Card>;
}
return (
<div className="stack">
{PROVIDERS.map((provider) => {
const state = states[provider.id] || {};
return (
<Card
key={provider.id}
title={t(provider.titleKey)}
extra={
<Button onClick={() => startAuth(provider.id)} loading={state.polling}>
{t('common.login')}
</Button>
}
>
<div className="hint">{t(provider.hintKey)}</div>
{state.url && (
<div className="connection-box">
<div className="label">{t(provider.urlLabelKey)}</div>
<div className="value">{state.url}</div>
<div className="item-actions" style={{ marginTop: 8 }}>
<Button variant="secondary" size="sm" onClick={() => copyLink(state.url!)}>
{t('auth_login.codex_copy_link')}
</Button>
<Button
variant="secondary"
size="sm"
onClick={() => window.open(state.url, '_blank', 'noopener,noreferrer')}
>
{t('auth_login.codex_open_link')}
</Button>
</div>
</div>
)}
<div className="status-badge" style={{ marginTop: 8 }}>
{state.status === 'success'
? t('auth_login.codex_oauth_status_success')
: state.status === 'error'
? `${t('auth_login.codex_oauth_status_error')} ${state.error || ''}`
: state.status === 'waiting'
? t('auth_login.codex_oauth_status_waiting')
: t('common.info')}
</div>
</Card>
);
})}
</div>
);
}

View File

@@ -0,0 +1,32 @@
.container {
width: 100%;
min-height: 400px;
display: flex;
align-items: center;
justify-content: center;
}
.placeholder {
text-align: center;
padding: $spacing-2xl;
.icon {
font-size: 64px;
color: var(--text-secondary);
opacity: 0.5;
margin-bottom: $spacing-lg;
}
h2 {
font-size: 24px;
font-weight: 600;
color: var(--text-primary);
margin: 0 0 $spacing-md 0;
}
p {
font-size: 14px;
color: var(--text-secondary);
margin: 0;
}
}

View File

@@ -0,0 +1,12 @@
import { useTranslation } from 'react-i18next';
import { Card } from '@/components/ui/Card';
export function PlaceholderPage({ titleKey }: { titleKey: string }) {
const { t } = useTranslation();
return (
<Card title={t(titleKey)}>
<p style={{ color: 'var(--text-secondary)' }}>{t('common.loading')}</p>
</Card>
);
}

View File

@@ -0,0 +1,105 @@
.container {
width: 100%;
}
.pageTitle {
font-size: 28px;
font-weight: 700;
color: var(--text-primary);
margin: 0 0 $spacing-xl 0;
}
.grid {
display: grid;
gap: $spacing-lg;
grid-template-columns: repeat(auto-fit, minmax(400px, 1fr));
@include mobile {
grid-template-columns: 1fr;
}
}
.settingRow {
display: flex;
align-items: center;
justify-content: space-between;
gap: $spacing-md;
}
.settingInfo {
flex: 1;
h4 {
font-size: 16px;
font-weight: 600;
color: var(--text-primary);
margin: 0 0 $spacing-xs 0;
}
p {
font-size: 14px;
color: var(--text-secondary);
margin: 0;
}
}
.switch {
position: relative;
display: inline-block;
width: 52px;
height: 28px;
flex-shrink: 0;
input {
opacity: 0;
width: 0;
height: 0;
&:checked + .slider {
background-color: var(--primary-color);
&:before {
transform: translateX(24px);
}
}
&:focus + .slider {
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.2);
}
}
}
.slider {
position: absolute;
cursor: pointer;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: var(--border-color);
transition: $transition-fast;
border-radius: $radius-full;
&:before {
position: absolute;
content: '';
height: 20px;
width: 20px;
left: 4px;
bottom: 4px;
background-color: white;
transition: $transition-fast;
border-radius: $radius-full;
}
}
.formGroup {
display: flex;
flex-direction: column;
gap: $spacing-md;
}
.buttonGroup {
display: flex;
gap: $spacing-sm;
}

326
src/pages/SettingsPage.tsx Normal file
View File

@@ -0,0 +1,326 @@
import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Card } from '@/components/ui/Card';
import { ToggleSwitch } from '@/components/ui/ToggleSwitch';
import { Button } from '@/components/ui/Button';
import { Input } from '@/components/ui/Input';
import { useAuthStore, useConfigStore, useNotificationStore } from '@/stores';
import { configApi } from '@/services/api';
import type { Config } from '@/types';
type PendingKey =
| 'debug'
| 'proxy'
| 'retry'
| 'switchProject'
| 'switchPreview'
| 'usage'
| 'requestLog'
| 'loggingToFile'
| 'wsAuth';
export function SettingsPage() {
const { t } = useTranslation();
const { showNotification } = useNotificationStore();
const connectionStatus = useAuthStore((state) => state.connectionStatus);
const config = useConfigStore((state) => state.config);
const fetchConfig = useConfigStore((state) => state.fetchConfig);
const updateConfigValue = useConfigStore((state) => state.updateConfigValue);
const clearCache = useConfigStore((state) => state.clearCache);
const [loading, setLoading] = useState(true);
const [proxyValue, setProxyValue] = useState('');
const [retryValue, setRetryValue] = useState(0);
const [pending, setPending] = useState<Record<PendingKey, boolean>>({} as Record<PendingKey, boolean>);
const [error, setError] = useState('');
const disableControls = connectionStatus !== 'connected';
useEffect(() => {
const load = async () => {
setLoading(true);
setError('');
try {
const data = (await fetchConfig(undefined, true)) as Config;
setProxyValue(data?.proxyUrl ?? '');
setRetryValue(typeof data?.requestRetry === 'number' ? data.requestRetry : 0);
} catch (err: any) {
setError(err?.message || t('notification.refresh_failed'));
} finally {
setLoading(false);
}
};
load();
}, [fetchConfig, t]);
useEffect(() => {
if (config) {
setProxyValue(config.proxyUrl ?? '');
if (typeof config.requestRetry === 'number') {
setRetryValue(config.requestRetry);
}
}
}, [config?.proxyUrl, config?.requestRetry]);
const setPendingFlag = (key: PendingKey, value: boolean) => {
setPending((prev) => ({ ...prev, [key]: value }));
};
const toggleSetting = async (
section: PendingKey,
rawKey: 'debug' | 'usage-statistics-enabled' | 'request-log' | 'logging-to-file' | 'ws-auth',
value: boolean,
updater: (val: boolean) => Promise<any>,
successMessage: string
) => {
const previous = (() => {
switch (rawKey) {
case 'debug':
return config?.debug ?? false;
case 'usage-statistics-enabled':
return config?.usageStatisticsEnabled ?? false;
case 'request-log':
return config?.requestLog ?? false;
case 'logging-to-file':
return config?.loggingToFile ?? false;
case 'ws-auth':
return config?.wsAuth ?? false;
default:
return false;
}
})();
setPendingFlag(section, true);
updateConfigValue(rawKey, value);
try {
await updater(value);
clearCache(rawKey);
showNotification(successMessage, 'success');
} catch (err: any) {
updateConfigValue(rawKey, previous);
showNotification(`${t('notification.update_failed')}: ${err?.message || ''}`, 'error');
} finally {
setPendingFlag(section, false);
}
};
const handleProxyUpdate = async () => {
const previous = config?.proxyUrl ?? '';
setPendingFlag('proxy', true);
updateConfigValue('proxy-url', proxyValue);
try {
await configApi.updateProxyUrl(proxyValue.trim());
clearCache('proxy-url');
showNotification(t('notification.proxy_updated'), 'success');
} catch (err: any) {
setProxyValue(previous);
updateConfigValue('proxy-url', previous);
showNotification(`${t('notification.update_failed')}: ${err?.message || ''}`, 'error');
} finally {
setPendingFlag('proxy', false);
}
};
const handleProxyClear = async () => {
const previous = config?.proxyUrl ?? '';
setPendingFlag('proxy', true);
updateConfigValue('proxy-url', '');
try {
await configApi.clearProxyUrl();
clearCache('proxy-url');
setProxyValue('');
showNotification(t('notification.proxy_cleared'), 'success');
} catch (err: any) {
setProxyValue(previous);
updateConfigValue('proxy-url', previous);
showNotification(`${t('notification.update_failed')}: ${err?.message || ''}`, 'error');
} finally {
setPendingFlag('proxy', false);
}
};
const handleRetryUpdate = async () => {
const previous = config?.requestRetry ?? 0;
const parsed = Number(retryValue);
if (!Number.isFinite(parsed) || parsed < 0) {
showNotification(t('login.error_invalid'), 'error');
setRetryValue(previous);
return;
}
setPendingFlag('retry', true);
updateConfigValue('request-retry', parsed);
try {
await configApi.updateRequestRetry(parsed);
clearCache('request-retry');
showNotification(t('notification.retry_updated'), 'success');
} catch (err: any) {
setRetryValue(previous);
updateConfigValue('request-retry', previous);
showNotification(`${t('notification.update_failed')}: ${err?.message || ''}`, 'error');
} finally {
setPendingFlag('retry', false);
}
};
const quotaSwitchProject = config?.quotaExceeded?.switchProject ?? false;
const quotaSwitchPreview = config?.quotaExceeded?.switchPreviewModel ?? false;
return (
<div className="grid cols-2">
<Card title={t('basic_settings.title')}>
{error && <div className="error-box">{error}</div>}
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
<ToggleSwitch
label={t('basic_settings.debug_enable')}
checked={config?.debug ?? false}
disabled={disableControls || pending.debug || loading}
onChange={(value) =>
toggleSetting('debug', 'debug', value, configApi.updateDebug, t('notification.debug_updated'))
}
/>
<ToggleSwitch
label={t('basic_settings.usage_statistics_enable')}
checked={config?.usageStatisticsEnabled ?? false}
disabled={disableControls || pending.usage || loading}
onChange={(value) =>
toggleSetting(
'usage',
'usage-statistics-enabled',
value,
configApi.updateUsageStatistics,
t('notification.usage_statistics_updated')
)
}
/>
<ToggleSwitch
label={t('basic_settings.request_log_enable')}
checked={config?.requestLog ?? false}
disabled={disableControls || pending.requestLog || loading}
onChange={(value) =>
toggleSetting(
'requestLog',
'request-log',
value,
configApi.updateRequestLog,
t('notification.request_log_updated')
)
}
/>
<ToggleSwitch
label={t('basic_settings.logging_to_file_enable')}
checked={config?.loggingToFile ?? false}
disabled={disableControls || pending.loggingToFile || loading}
onChange={(value) =>
toggleSetting(
'loggingToFile',
'logging-to-file',
value,
configApi.updateLoggingToFile,
t('notification.logging_to_file_updated')
)
}
/>
<ToggleSwitch
label={t('basic_settings.ws_auth_enable')}
checked={config?.wsAuth ?? false}
disabled={disableControls || pending.wsAuth || loading}
onChange={(value) =>
toggleSetting(
'wsAuth',
'ws-auth',
value,
configApi.updateWsAuth,
t('notification.ws_auth_updated')
)
}
/>
</div>
</Card>
<Card title={t('basic_settings.proxy_title')}>
<Input
label={t('basic_settings.proxy_url_label')}
placeholder={t('basic_settings.proxy_url_placeholder')}
value={proxyValue}
onChange={(e) => setProxyValue(e.target.value)}
disabled={disableControls || loading}
/>
<div style={{ display: 'flex', gap: 12 }}>
<Button variant="secondary" onClick={handleProxyClear} disabled={disableControls || pending.proxy || loading}>
{t('basic_settings.proxy_clear')}
</Button>
<Button onClick={handleProxyUpdate} loading={pending.proxy} disabled={disableControls || loading}>
{t('basic_settings.proxy_update')}
</Button>
</div>
<Input
label={t('basic_settings.retry_count_label')}
type="number"
value={retryValue}
onChange={(e) => setRetryValue(Number(e.target.value))}
disabled={disableControls || loading}
/>
<Button onClick={handleRetryUpdate} loading={pending.retry} disabled={disableControls || loading} fullWidth>
{t('basic_settings.retry_update')}
</Button>
</Card>
<Card title={t('basic_settings.quota_title')}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
<ToggleSwitch
label={t('basic_settings.quota_switch_project')}
checked={quotaSwitchProject}
disabled={disableControls || pending.switchProject || loading}
onChange={(value) =>
(async () => {
const previous = config?.quotaExceeded?.switchProject ?? false;
const nextQuota = { ...(config?.quotaExceeded || {}), switchProject: value };
setPendingFlag('switchProject', true);
updateConfigValue('quota-exceeded', nextQuota);
try {
await configApi.updateSwitchProject(value);
clearCache('quota-exceeded');
showNotification(t('notification.quota_switch_project_updated'), 'success');
} catch (err: any) {
updateConfigValue('quota-exceeded', { ...(config?.quotaExceeded || {}), switchProject: previous });
showNotification(`${t('notification.update_failed')}: ${err?.message || ''}`, 'error');
} finally {
setPendingFlag('switchProject', false);
}
})()
}
/>
<ToggleSwitch
label={t('basic_settings.quota_switch_preview')}
checked={quotaSwitchPreview}
disabled={disableControls || pending.switchPreview || loading}
onChange={(value) =>
(async () => {
const previous = config?.quotaExceeded?.switchPreviewModel ?? false;
const nextQuota = { ...(config?.quotaExceeded || {}), switchPreviewModel: value };
setPendingFlag('switchPreview', true);
updateConfigValue('quota-exceeded', nextQuota);
try {
await configApi.updateSwitchPreviewModel(value);
clearCache('quota-exceeded');
showNotification(t('notification.quota_switch_preview_updated'), 'success');
} catch (err: any) {
updateConfigValue('quota-exceeded', { ...(config?.quotaExceeded || {}), switchPreviewModel: previous });
showNotification(`${t('notification.update_failed')}: ${err?.message || ''}`, 'error');
} finally {
setPendingFlag('switchPreview', false);
}
})()
}
/>
</div>
</Card>
</div>
);
}

View File

@@ -0,0 +1,109 @@
.container {
width: 100%;
}
.pageTitle {
font-size: 28px;
font-weight: 700;
color: var(--text-primary);
margin: 0 0 $spacing-xl 0;
}
.content {
display: flex;
flex-direction: column;
gap: $spacing-xl;
}
.section {
display: flex;
flex-direction: column;
gap: $spacing-md;
}
.sectionTitle {
font-size: 20px;
font-weight: 600;
color: var(--text-primary);
margin: 0 0 $spacing-md 0;
}
.sectionDescription {
font-size: 14px;
color: var(--text-secondary);
margin: 0 0 $spacing-md 0;
}
.infoGrid {
display: grid;
gap: $spacing-sm;
.infoRow {
display: flex;
justify-content: space-between;
padding: $spacing-sm $spacing-md;
background-color: var(--bg-secondary);
border-radius: $radius-md;
.label {
font-weight: 500;
color: var(--text-secondary);
}
.value {
color: var(--text-primary);
font-family: 'Consolas', 'Monaco', 'Courier New', monospace;
}
}
}
.modelsList {
display: flex;
flex-direction: column;
gap: $spacing-sm;
max-height: 400px;
overflow-y: auto;
}
.modelItem {
padding: $spacing-sm $spacing-md;
background-color: var(--bg-secondary);
border-radius: $radius-md;
font-family: 'Consolas', 'Monaco', 'Courier New', monospace;
font-size: 13px;
&:hover {
background-color: var(--bg-hover);
}
}
.versionCheck {
display: flex;
flex-direction: column;
gap: $spacing-md;
}
.versionInfo {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: $spacing-md;
.versionItem {
padding: $spacing-md;
background-color: var(--bg-secondary);
border-radius: $radius-md;
.label {
font-size: 12px;
color: var(--text-secondary);
margin-bottom: $spacing-xs;
}
.version {
font-size: 16px;
font-weight: 600;
color: var(--text-primary);
font-family: 'Consolas', 'Monaco', 'Courier New', monospace;
}
}
}

113
src/pages/SystemPage.tsx Normal file
View File

@@ -0,0 +1,113 @@
import { useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Card } from '@/components/ui/Card';
import { Button } from '@/components/ui/Button';
import { useAuthStore, useConfigStore, useNotificationStore } from '@/stores';
import { modelsApi } from '@/services/api/models';
import { classifyModels, type ModelInfo } from '@/utils/models';
export function SystemPage() {
const { t } = useTranslation();
const { showNotification } = useNotificationStore();
const auth = useAuthStore();
const configStore = useConfigStore();
const [models, setModels] = useState<ModelInfo[]>([]);
const [loadingModels, setLoadingModels] = useState(false);
const [error, setError] = useState('');
const openaiProviders = configStore.config?.openaiCompatibility || [];
const primaryProvider = openaiProviders[0];
const primaryKey = primaryProvider?.apiKeyEntries?.[0]?.apiKey;
const groupedModels = useMemo(() => classifyModels(models, { otherLabel: 'Other' }), [models]);
const fetchModels = async () => {
if (!primaryProvider?.baseUrl) {
showNotification('No OpenAI provider configured for model fetch', 'warning');
return;
}
setLoadingModels(true);
setError('');
try {
const list = await modelsApi.fetchModels(primaryProvider.baseUrl, primaryKey);
setModels(list);
} catch (err: any) {
setError(err?.message || t('notification.refresh_failed'));
} finally {
setLoadingModels(false);
}
};
useEffect(() => {
configStore.fetchConfig().catch(() => {
// ignore
});
}, []);
return (
<div className="stack">
<Card
title={t('nav.system_info')}
extra={
<Button variant="secondary" size="sm" onClick={() => configStore.fetchConfig(undefined, true)}>
{t('common.refresh')}
</Button>
}
>
<div className="grid cols-2">
<div className="stat-card">
<div className="stat-label">{t('connection.server_address')}</div>
<div className="stat-value">{auth.apiBase || '-'}</div>
</div>
<div className="stat-card">
<div className="stat-label">{t('footer.api_version')}</div>
<div className="stat-value">{auth.serverVersion || t('system_info.version_unknown')}</div>
</div>
<div className="stat-card">
<div className="stat-label">{t('footer.build_date')}</div>
<div className="stat-value">
{auth.serverBuildDate ? new Date(auth.serverBuildDate).toLocaleString() : t('system_info.version_unknown')}
</div>
</div>
<div className="stat-card">
<div className="stat-label">{t('connection.status')}</div>
<div className="stat-value">{t(`common.${auth.connectionStatus}_status` as any)}</div>
</div>
</div>
</Card>
<Card
title="Models"
extra={
<Button variant="secondary" size="sm" onClick={fetchModels} loading={loadingModels}>
{t('common.refresh')}
</Button>
}
>
{error && <div className="error-box">{error}</div>}
{loadingModels ? (
<div className="hint">{t('common.loading')}</div>
) : models.length === 0 ? (
<div className="hint">{t('usage_stats.no_data')}</div>
) : (
<div className="item-list">
{groupedModels.map((group) => (
<div key={group.id} className="item-row">
<div className="item-meta">
<div className="item-title">
{group.label} ({group.items.length})
</div>
<div className="item-subtitle">
{group.items.map((model) => model.name).slice(0, 5).join(', ')}
{group.items.length > 5 ? '…' : ''}
</div>
</div>
</div>
))}
</div>
)}
</Card>
</div>
);
}

View File

@@ -0,0 +1,71 @@
.container {
width: 100%;
}
.pageTitle {
font-size: 28px;
font-weight: 700;
color: var(--text-primary);
margin: 0 0 $spacing-xl 0;
}
.content {
display: flex;
flex-direction: column;
gap: $spacing-xl;
}
.statsGrid {
display: grid;
gap: $spacing-md;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
@include mobile {
grid-template-columns: 1fr;
}
}
.statCard {
padding: $spacing-lg;
background-color: var(--bg-primary);
border-radius: $radius-lg;
border: 1px solid var(--border-color);
.label {
font-size: 14px;
color: var(--text-secondary);
margin-bottom: $spacing-sm;
}
.value {
font-size: 24px;
font-weight: 700;
color: var(--text-primary);
}
}
.chartSection {
display: flex;
flex-direction: column;
gap: $spacing-md;
}
.chartControls {
display: flex;
justify-content: space-between;
align-items: center;
gap: $spacing-md;
@include mobile {
flex-direction: column;
align-items: stretch;
}
}
.chartWrapper {
padding: $spacing-lg;
background-color: var(--bg-primary);
border-radius: $radius-lg;
border: 1px solid var(--border-color);
min-height: 300px;
}

108
src/pages/UsagePage.tsx Normal file
View File

@@ -0,0 +1,108 @@
import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Card } from '@/components/ui/Card';
import { Button } from '@/components/ui/Button';
import { usageApi } from '@/services/api/usage';
import type { KeyStats } from '@/utils/usage';
interface UsagePayload {
total_requests?: number;
success_requests?: number;
failed_requests?: number;
total_tokens?: number;
cached_tokens?: number;
reasoning_tokens?: number;
rpm_30m?: number;
tpm_30m?: number;
[key: string]: any;
}
export function UsagePage() {
const { t } = useTranslation();
const [usage, setUsage] = useState<UsagePayload | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [keyStats, setKeyStats] = useState<KeyStats | null>(null);
const loadUsage = async () => {
setLoading(true);
setError('');
try {
const data = await usageApi.getUsage();
const payload = data?.usage ?? data;
setUsage(payload);
const stats = await usageApi.getKeyStats(payload);
setKeyStats(stats);
} catch (err: any) {
setError(err?.message || t('usage_stats.loading_error'));
} finally {
setLoading(false);
}
};
useEffect(() => {
loadUsage();
}, []);
const overviewItems = [
{ label: t('usage_stats.total_requests'), value: usage?.total_requests },
{ label: t('usage_stats.success_requests'), value: usage?.success_requests },
{ label: t('usage_stats.failed_requests'), value: usage?.failed_requests },
{ label: t('usage_stats.total_tokens'), value: usage?.total_tokens },
{ label: t('usage_stats.cached_tokens'), value: usage?.cached_tokens },
{ label: t('usage_stats.reasoning_tokens'), value: usage?.reasoning_tokens },
{ label: t('usage_stats.rpm_30m'), value: usage?.rpm_30m },
{ label: t('usage_stats.tpm_30m'), value: usage?.tpm_30m }
];
return (
<div className="stack">
<Card
title={t('usage_stats.title')}
extra={
<Button variant="secondary" size="sm" onClick={loadUsage} disabled={loading}>
{t('usage_stats.refresh')}
</Button>
}
>
{error && <div className="error-box">{error}</div>}
{loading ? (
<div className="hint">{t('common.loading')}</div>
) : (
<div className="grid cols-2">
{overviewItems.map((item) => (
<div key={item.label} className="stat-card">
<div className="stat-label">{item.label}</div>
<div className="stat-value">{item.value ?? '-'}</div>
</div>
))}
</div>
)}
</Card>
<Card title={t('usage_stats.api_details')}>
{loading ? (
<div className="hint">{t('common.loading')}</div>
) : keyStats && Object.keys(keyStats.bySource || {}).length ? (
<div className="table">
<div className="table-header">
<div>{t('usage_stats.api_endpoint')}</div>
<div>{t('stats.success')}</div>
<div>{t('stats.failure')}</div>
</div>
{Object.entries(keyStats.bySource || {}).map(([source, bucket]) => (
<div key={source} className="table-row">
<div className="cell item-subtitle">{source}</div>
<div className="cell">{bucket.success}</div>
<div className="cell">{bucket.failure}</div>
</div>
))}
</div>
) : (
<div className="hint">{t('usage_stats.no_data')}</div>
)}
</Card>
</div>
);
}

View File

@@ -0,0 +1,41 @@
import { useEffect, useState, type ReactElement } from 'react';
import { Navigate, useLocation } from 'react-router-dom';
import { useAuthStore } from '@/stores';
import { LoadingSpinner } from '@/components/ui/LoadingSpinner';
export function ProtectedRoute({ children }: { children: ReactElement }) {
const location = useLocation();
const isAuthenticated = useAuthStore((state) => state.isAuthenticated);
const managementKey = useAuthStore((state) => state.managementKey);
const apiBase = useAuthStore((state) => state.apiBase);
const checkAuth = useAuthStore((state) => state.checkAuth);
const [checking, setChecking] = useState(false);
useEffect(() => {
const tryRestore = async () => {
if (!isAuthenticated && managementKey && apiBase) {
setChecking(true);
try {
await checkAuth();
} finally {
setChecking(false);
}
}
};
tryRestore();
}, [apiBase, isAuthenticated, managementKey, checkAuth]);
if (checking) {
return (
<div className="main-content">
<LoadingSpinner />
</div>
);
}
if (!isAuthenticated) {
return <Navigate to="/login" replace state={{ from: location }} />;
}
return children;
}

View File

@@ -0,0 +1,19 @@
/**
* API 密钥管理
*/
import { apiClient } from './client';
export const apiKeysApi = {
async list(): Promise<string[]> {
const data = await apiClient.get('/api-keys');
const keys = (data && (data['api-keys'] ?? data.apiKeys)) as unknown;
return Array.isArray(keys) ? (keys as string[]) : [];
},
replace: (keys: string[]) => apiClient.put('/api-keys', keys),
update: (index: number, value: string) => apiClient.patch('/api-keys', { index, value }),
delete: (index: number) => apiClient.delete(`/api-keys?index=${index}`)
};

View File

@@ -0,0 +1,29 @@
/**
* 认证文件与 OAuth 排除模型相关 API
*/
import { apiClient } from './client';
import type { AuthFilesResponse } from '@/types/authFile';
export const authFilesApi = {
list: () => apiClient.get<AuthFilesResponse>('/auth-files'),
upload: (file: File) => {
const formData = new FormData();
formData.append('file', file, file.name);
return apiClient.postForm('/auth-files', formData);
},
deleteFile: (name: string) => apiClient.delete(`/auth-files?name=${encodeURIComponent(name)}`),
deleteAll: () => apiClient.delete('/auth-files', { params: { all: true } }),
// OAuth 排除模型
getOauthExcludedModels: () => apiClient.get('/oauth-excluded-models'),
saveOauthExcludedModels: (provider: string, models: string[]) =>
apiClient.patch('/oauth-excluded-models', { provider, models }),
deleteOauthExcludedEntry: (provider: string) =>
apiClient.delete(`/oauth-excluded-models?provider=${encodeURIComponent(provider)}`)
};

215
src/services/api/client.ts Normal file
View File

@@ -0,0 +1,215 @@
/**
* Axios API 客户端
* 替代原项目 src/core/api-client.js
*/
import axios, { AxiosInstance, AxiosRequestConfig, AxiosResponse } from 'axios';
import type { ApiClientConfig, ApiError } from '@/types';
import {
BUILD_DATE_HEADER_KEYS,
MANAGEMENT_API_PREFIX,
REQUEST_TIMEOUT_MS,
VERSION_HEADER_KEYS
} from '@/utils/constants';
class ApiClient {
private instance: AxiosInstance;
private apiBase: string = '';
private managementKey: string = '';
constructor() {
this.instance = axios.create({
timeout: REQUEST_TIMEOUT_MS,
headers: {
'Content-Type': 'application/json'
}
});
this.setupInterceptors();
}
/**
* 设置 API 配置
*/
setConfig(config: ApiClientConfig): void {
this.apiBase = this.normalizeApiBase(config.apiBase);
this.managementKey = config.managementKey;
if (config.timeout) {
this.instance.defaults.timeout = config.timeout;
} else {
this.instance.defaults.timeout = REQUEST_TIMEOUT_MS;
}
}
/**
* 规范化 API Base URL
*/
private normalizeApiBase(base: string): string {
let normalized = base.trim();
// 移除尾部的 /v0/management
normalized = normalized.replace(/\/?v0\/management\/?$/i, '');
// 移除尾部斜杠
normalized = normalized.replace(/\/+$/, '');
// 添加协议
if (!/^https?:\/\//i.test(normalized)) {
normalized = `http://${normalized}`;
}
return `${normalized}${MANAGEMENT_API_PREFIX}`;
}
private readHeader(headers: Record<string, any>, keys: string[]): string | null {
const normalized = Object.fromEntries(
Object.entries(headers || {}).map(([key, value]) => [key.toLowerCase(), value as string | undefined])
);
for (const key of keys) {
const match = normalized[key.toLowerCase()];
if (match) return match;
}
return null;
}
/**
* 设置请求/响应拦截器
*/
private setupInterceptors(): void {
// 请求拦截器
this.instance.interceptors.request.use(
(config) => {
// 设置 baseURL
config.baseURL = this.apiBase;
// 添加认证头
if (this.managementKey) {
config.headers.Authorization = `Bearer ${this.managementKey}`;
}
return config;
},
(error) => Promise.reject(this.handleError(error))
);
// 响应拦截器
this.instance.interceptors.response.use(
(response) => {
const headers = response.headers as Record<string, string | undefined>;
const version = this.readHeader(headers, VERSION_HEADER_KEYS);
const buildDate = this.readHeader(headers, BUILD_DATE_HEADER_KEYS);
// 触发版本更新事件(后续通过 store 处理)
if (version || buildDate) {
window.dispatchEvent(
new CustomEvent('server-version-update', {
detail: { version: version || null, buildDate: buildDate || null }
})
);
}
return response;
},
(error) => Promise.reject(this.handleError(error))
);
}
/**
* 错误处理
*/
private handleError(error: any): ApiError {
if (axios.isAxiosError(error)) {
const responseData = error.response?.data as any;
const message = responseData?.error || responseData?.message || error.message || 'Request failed';
const apiError = new Error(message) as ApiError;
apiError.name = 'ApiError';
apiError.status = error.response?.status;
apiError.code = error.code;
apiError.details = responseData;
apiError.data = responseData;
// 401 未授权 - 触发登出事件
if (error.response?.status === 401) {
window.dispatchEvent(new Event('unauthorized'));
}
return apiError;
}
const fallback = new Error(error?.message || 'Unknown error occurred') as ApiError;
fallback.name = 'ApiError';
return fallback;
}
/**
* GET 请求
*/
async get<T = any>(url: string, config?: AxiosRequestConfig): Promise<T> {
const response = await this.instance.get<T>(url, config);
return response.data;
}
/**
* POST 请求
*/
async post<T = any>(url: string, data?: any, config?: AxiosRequestConfig): Promise<T> {
const response = await this.instance.post<T>(url, data, config);
return response.data;
}
/**
* PUT 请求
*/
async put<T = any>(url: string, data?: any, config?: AxiosRequestConfig): Promise<T> {
const response = await this.instance.put<T>(url, data, config);
return response.data;
}
/**
* PATCH 请求
*/
async patch<T = any>(url: string, data?: any, config?: AxiosRequestConfig): Promise<T> {
const response = await this.instance.patch<T>(url, data, config);
return response.data;
}
/**
* DELETE 请求
*/
async delete<T = any>(url: string, config?: AxiosRequestConfig): Promise<T> {
const response = await this.instance.delete<T>(url, config);
return response.data;
}
/**
* 获取原始响应(用于下载等场景)
*/
async getRaw(url: string, config?: AxiosRequestConfig): Promise<AxiosResponse> {
return this.instance.get(url, config);
}
/**
* 发送 FormData
*/
async postForm<T = any>(url: string, formData: FormData, config?: AxiosRequestConfig): Promise<T> {
const response = await this.instance.post<T>(url, formData, {
...config,
headers: {
...(config?.headers || {}),
'Content-Type': 'multipart/form-data'
}
});
return response.data;
}
/**
* 保留对 axios.request 的访问,便于下载等场景
*/
async requestRaw(config: AxiosRequestConfig): Promise<AxiosResponse> {
return this.instance.request(config);
}
}
// 导出单例
export const apiClient = new ApiClient();

View File

@@ -0,0 +1,80 @@
/**
* 配置相关 API
*/
import { apiClient } from './client';
import type { Config } from '@/types';
import { normalizeConfigResponse } from './transformers';
export const configApi = {
/**
* 获取配置(会进行字段规范化)
*/
async getConfig(): Promise<Config> {
const raw = await apiClient.get('/config');
return normalizeConfigResponse(raw);
},
/**
* 获取原始配置(不做转换)
*/
getRawConfig: () => apiClient.get('/config'),
/**
* 更新 Debug 模式
*/
updateDebug: (enabled: boolean) => apiClient.put('/debug', { value: enabled }),
/**
* 更新代理 URL
*/
updateProxyUrl: (proxyUrl: string) => apiClient.put('/proxy-url', { value: proxyUrl }),
/**
* 清除代理 URL
*/
clearProxyUrl: () => apiClient.delete('/proxy-url'),
/**
* 更新重试次数
*/
updateRequestRetry: (retryCount: number) => apiClient.put('/request-retry', { value: retryCount }),
/**
* 配额回退:切换项目
*/
updateSwitchProject: (enabled: boolean) =>
apiClient.put('/quota-exceeded/switch-project', { value: enabled }),
/**
* 配额回退:切换预览模型
*/
updateSwitchPreviewModel: (enabled: boolean) =>
apiClient.put('/quota-exceeded/switch-preview-model', { value: enabled }),
/**
* 使用统计开关
*/
updateUsageStatistics: (enabled: boolean) =>
apiClient.put('/usage-statistics-enabled', { value: enabled }),
/**
* 请求日志开关
*/
updateRequestLog: (enabled: boolean) => apiClient.put('/request-log', { value: enabled }),
/**
* 写日志到文件开关
*/
updateLoggingToFile: (enabled: boolean) => apiClient.put('/logging-to-file', { value: enabled }),
/**
* WebSocket 鉴权开关
*/
updateWsAuth: (enabled: boolean) => apiClient.put('/ws-auth', { value: enabled }),
/**
* 重载配置
*/
reloadConfig: () => apiClient.post('/config/reload')
};

View File

@@ -0,0 +1,27 @@
/**
* 配置文件相关 API/config.yaml
*/
import { apiClient } from './client';
export const configFileApi = {
async fetchConfigYaml(): Promise<string> {
const response = await apiClient.getRaw('/config.yaml', {
responseType: 'text',
headers: { Accept: 'application/yaml, text/yaml, text/plain' }
});
const data = response.data as any;
if (typeof data === 'string') return data;
if (data === undefined || data === null) return '';
return String(data);
},
async saveConfigYaml(content: string): Promise<void> {
await apiClient.put('/config.yaml', content, {
headers: {
'Content-Type': 'application/yaml',
Accept: 'application/json, text/plain, */*'
}
});
}
};

12
src/services/api/index.ts Normal file
View File

@@ -0,0 +1,12 @@
export * from './client';
export * from './config';
export * from './configFile';
export * from './apiKeys';
export * from './providers';
export * from './authFiles';
export * from './oauth';
export * from './usage';
export * from './logs';
export * from './version';
export * from './models';
export * from './transformers';

23
src/services/api/logs.ts Normal file
View File

@@ -0,0 +1,23 @@
/**
* 日志相关 API
*/
import { apiClient } from './client';
export interface LogsQuery {
after?: string | number;
limit?: number;
}
export const logsApi = {
fetchLogs: (params: LogsQuery = {}) => apiClient.get('/logs', { params }),
clearLogs: () => apiClient.delete('/logs'),
fetchErrorLogs: () => apiClient.get('/request-error-logs'),
downloadErrorLog: (filename: string) =>
apiClient.getRaw(`/request-error-logs/${encodeURIComponent(filename)}`, {
responseType: 'blob'
})
};

View File

@@ -0,0 +1,31 @@
/**
* 可用模型获取
*/
import axios from 'axios';
import { normalizeModelList } from '@/utils/models';
const buildModelsEndpoint = (baseUrl: string): string => {
if (!baseUrl) return '';
const trimmed = String(baseUrl).trim().replace(/\/+$/g, '');
if (!trimmed) return '';
if (trimmed.endsWith('/v1')) {
return `${trimmed}/models`;
}
return `${trimmed}/v1/models`;
};
export const modelsApi = {
async fetchModels(baseUrl: string, apiKey?: string) {
const endpoint = buildModelsEndpoint(baseUrl);
if (!endpoint) {
throw new Error('Invalid base url');
}
const response = await axios.get(endpoint, {
headers: apiKey ? { Authorization: `Bearer ${apiKey}` } : undefined
});
const payload = response.data?.data ?? response.data?.models ?? response.data;
return normalizeModelList(payload, { dedupe: true });
}
};

27
src/services/api/oauth.ts Normal file
View File

@@ -0,0 +1,27 @@
/**
* OAuth 与设备码登录相关 API
*/
import { apiClient } from './client';
export type OAuthProvider =
| 'codex'
| 'anthropic'
| 'antigravity'
| 'gemini-cli'
| 'qwen'
| 'iflow';
export interface OAuthStartResponse {
url: string;
state?: string;
}
export const oauthApi = {
startAuth: (provider: OAuthProvider) => apiClient.get<OAuthStartResponse>(`/${provider}-auth-url`, { params: { is_webui: 1 } }),
getAuthStatus: (state: string) =>
apiClient.get<{ status: 'ok' | 'wait' | 'error'; error?: string }>(`/get-auth-status`, {
params: { state }
})
};

View File

@@ -0,0 +1,152 @@
/**
* AI 提供商相关 API
*/
import { apiClient } from './client';
import {
normalizeGeminiKeyConfig,
normalizeOpenAIProvider,
normalizeProviderKeyConfig
} from './transformers';
import type {
GeminiKeyConfig,
OpenAIProviderConfig,
ProviderKeyConfig,
ApiKeyEntry,
ModelAlias
} from '@/types';
const serializeHeaders = (headers?: Record<string, string>) => (headers && Object.keys(headers).length ? headers : undefined);
const serializeModelAliases = (models?: ModelAlias[]) =>
Array.isArray(models)
? models
.map((model) => {
if (!model?.name) return null;
const payload: Record<string, any> = { name: model.name };
if (model.alias && model.alias !== model.name) {
payload.alias = model.alias;
}
if (model.priority !== undefined) {
payload.priority = model.priority;
}
if (model.testModel) {
payload['test-model'] = model.testModel;
}
return payload;
})
.filter(Boolean)
: undefined;
const serializeApiKeyEntry = (entry: ApiKeyEntry) => {
const payload: Record<string, any> = { 'api-key': entry.apiKey };
if (entry.proxyUrl) payload['proxy-url'] = entry.proxyUrl;
const headers = serializeHeaders(entry.headers);
if (headers) payload.headers = headers;
return payload;
};
const serializeProviderKey = (config: ProviderKeyConfig) => {
const payload: Record<string, any> = { 'api-key': config.apiKey };
if (config.baseUrl) payload['base-url'] = config.baseUrl;
if (config.proxyUrl) payload['proxy-url'] = config.proxyUrl;
const headers = serializeHeaders(config.headers);
if (headers) payload.headers = headers;
const models = serializeModelAliases(config.models);
if (models && models.length) payload.models = models;
return payload;
};
const serializeGeminiKey = (config: GeminiKeyConfig) => {
const payload: Record<string, any> = { 'api-key': config.apiKey };
if (config.baseUrl) payload['base-url'] = config.baseUrl;
const headers = serializeHeaders(config.headers);
if (headers) payload.headers = headers;
if (config.excludedModels && config.excludedModels.length) {
payload['excluded-models'] = config.excludedModels;
}
return payload;
};
const serializeOpenAIProvider = (provider: OpenAIProviderConfig) => {
const payload: Record<string, any> = {
name: provider.name,
'base-url': provider.baseUrl,
'api-key-entries': Array.isArray(provider.apiKeyEntries)
? provider.apiKeyEntries.map((entry) => serializeApiKeyEntry(entry))
: []
};
const headers = serializeHeaders(provider.headers);
if (headers) payload.headers = headers;
const models = serializeModelAliases(provider.models);
if (models && models.length) payload.models = models;
if (provider.priority !== undefined) payload.priority = provider.priority;
if (provider.testModel) payload['test-model'] = provider.testModel;
return payload;
};
export const providersApi = {
async getGeminiKeys(): Promise<GeminiKeyConfig[]> {
const data = await apiClient.get('/gemini-api-key');
const list = (data && (data['gemini-api-key'] ?? data.items ?? data)) as any;
if (!Array.isArray(list)) return [];
return list.map((item) => normalizeGeminiKeyConfig(item)).filter(Boolean) as GeminiKeyConfig[];
},
saveGeminiKeys: (configs: GeminiKeyConfig[]) =>
apiClient.put('/gemini-api-key', configs.map((item) => serializeGeminiKey(item))),
updateGeminiKey: (index: number, value: GeminiKeyConfig) =>
apiClient.patch('/gemini-api-key', { index, value: serializeGeminiKey(value) }),
deleteGeminiKey: (apiKey: string) =>
apiClient.delete(`/gemini-api-key?api-key=${encodeURIComponent(apiKey)}`),
async getCodexConfigs(): Promise<ProviderKeyConfig[]> {
const data = await apiClient.get('/codex-api-key');
const list = (data && (data['codex-api-key'] ?? data.items ?? data)) as any;
if (!Array.isArray(list)) return [];
return list.map((item) => normalizeProviderKeyConfig(item)).filter(Boolean) as ProviderKeyConfig[];
},
saveCodexConfigs: (configs: ProviderKeyConfig[]) =>
apiClient.put('/codex-api-key', configs.map((item) => serializeProviderKey(item))),
updateCodexConfig: (index: number, value: ProviderKeyConfig) =>
apiClient.patch('/codex-api-key', { index, value: serializeProviderKey(value) }),
deleteCodexConfig: (apiKey: string) =>
apiClient.delete(`/codex-api-key?api-key=${encodeURIComponent(apiKey)}`),
async getClaudeConfigs(): Promise<ProviderKeyConfig[]> {
const data = await apiClient.get('/claude-api-key');
const list = (data && (data['claude-api-key'] ?? data.items ?? data)) as any;
if (!Array.isArray(list)) return [];
return list.map((item) => normalizeProviderKeyConfig(item)).filter(Boolean) as ProviderKeyConfig[];
},
saveClaudeConfigs: (configs: ProviderKeyConfig[]) =>
apiClient.put('/claude-api-key', configs.map((item) => serializeProviderKey(item))),
updateClaudeConfig: (index: number, value: ProviderKeyConfig) =>
apiClient.patch('/claude-api-key', { index, value: serializeProviderKey(value) }),
deleteClaudeConfig: (apiKey: string) =>
apiClient.delete(`/claude-api-key?api-key=${encodeURIComponent(apiKey)}`),
async getOpenAIProviders(): Promise<OpenAIProviderConfig[]> {
const data = await apiClient.get('/openai-compatibility');
const list = (data && (data['openai-compatibility'] ?? data.items ?? data)) as any;
if (!Array.isArray(list)) return [];
return list.map((item) => normalizeOpenAIProvider(item)).filter(Boolean) as OpenAIProviderConfig[];
},
saveOpenAIProviders: (providers: OpenAIProviderConfig[]) =>
apiClient.put('/openai-compatibility', providers.map((item) => serializeOpenAIProvider(item))),
updateOpenAIProvider: (index: number, value: OpenAIProviderConfig) =>
apiClient.patch('/openai-compatibility', { index, value: serializeOpenAIProvider(value) }),
deleteOpenAIProvider: (name: string) =>
apiClient.delete(`/openai-compatibility?name=${encodeURIComponent(name)}`)
};

View File

@@ -0,0 +1,232 @@
import type {
ApiKeyEntry,
GeminiKeyConfig,
ModelAlias,
OpenAIProviderConfig,
ProviderKeyConfig
} from '@/types';
import type { Config } from '@/types/config';
import { buildHeaderObject } from '@/utils/headers';
const normalizeModelAliases = (models: any): ModelAlias[] => {
if (!Array.isArray(models)) return [];
return models
.map((item) => {
if (!item) return null;
const name = item.name || item.id || item.model;
if (!name) return null;
const alias = item.alias || item.display_name || item.displayName;
const priority = item.priority ?? item['priority'];
const testModel = item['test-model'] ?? item.testModel;
const entry: ModelAlias = { name: String(name) };
if (alias && alias !== name) {
entry.alias = String(alias);
}
if (priority !== undefined) {
entry.priority = Number(priority);
}
if (testModel) {
entry.testModel = String(testModel);
}
return entry;
})
.filter(Boolean) as ModelAlias[];
};
const normalizeHeaders = (headers: any) => {
if (!headers || typeof headers !== 'object') return undefined;
const normalized = buildHeaderObject(headers as Record<string, string>);
return Object.keys(normalized).length ? normalized : undefined;
};
const normalizeExcludedModels = (input: any): string[] => {
const rawList = Array.isArray(input) ? input : typeof input === 'string' ? input.split(/[\n,]/) : [];
const seen = new Set<string>();
const normalized: string[] = [];
rawList.forEach((item) => {
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;
};
const normalizeApiKeyEntry = (entry: any): ApiKeyEntry | null => {
if (!entry) return null;
const apiKey = entry['api-key'] ?? entry.apiKey ?? entry.key ?? (typeof entry === 'string' ? entry : '');
const trimmed = String(apiKey || '').trim();
if (!trimmed) return null;
const proxyUrl = entry['proxy-url'] ?? entry.proxyUrl;
const headers = normalizeHeaders(entry.headers);
return {
apiKey: trimmed,
proxyUrl: proxyUrl ? String(proxyUrl) : undefined,
headers
};
};
const normalizeProviderKeyConfig = (item: any): ProviderKeyConfig | null => {
if (!item) return null;
const apiKey = item['api-key'] ?? item.apiKey ?? (typeof item === 'string' ? item : '');
const trimmed = String(apiKey || '').trim();
if (!trimmed) return null;
const config: ProviderKeyConfig = { apiKey: trimmed };
const baseUrl = item['base-url'] ?? item.baseUrl;
const proxyUrl = item['proxy-url'] ?? item.proxyUrl;
if (baseUrl) config.baseUrl = String(baseUrl);
if (proxyUrl) config.proxyUrl = String(proxyUrl);
const headers = normalizeHeaders(item.headers);
if (headers) config.headers = headers;
const models = normalizeModelAliases(item.models);
if (models.length) config.models = models;
return config;
};
const normalizeGeminiKeyConfig = (item: any): GeminiKeyConfig | null => {
if (!item) return null;
let apiKey = item['api-key'] ?? item.apiKey;
if (!apiKey && typeof item === 'string') {
apiKey = item;
}
const trimmed = String(apiKey || '').trim();
if (!trimmed) return null;
const config: GeminiKeyConfig = { apiKey: trimmed };
const baseUrl = item['base-url'] ?? item.baseUrl ?? item['base_url'];
if (baseUrl) config.baseUrl = String(baseUrl);
const headers = normalizeHeaders(item.headers);
if (headers) config.headers = headers;
const excludedModels = normalizeExcludedModels(item['excluded-models'] ?? item.excludedModels);
if (excludedModels.length) config.excludedModels = excludedModels;
return config;
};
const normalizeOpenAIProvider = (provider: any): OpenAIProviderConfig | null => {
if (!provider || typeof provider !== 'object') return null;
const name = provider.name || provider.id;
const baseUrl = provider['base-url'] ?? provider.baseUrl;
if (!name || !baseUrl) return null;
let apiKeyEntries: ApiKeyEntry[] = [];
if (Array.isArray(provider['api-key-entries'])) {
apiKeyEntries = provider['api-key-entries']
.map((entry: any) => normalizeApiKeyEntry(entry))
.filter(Boolean) as ApiKeyEntry[];
} else if (Array.isArray(provider['api-keys'])) {
apiKeyEntries = provider['api-keys']
.map((key: any) => normalizeApiKeyEntry({ 'api-key': key }))
.filter(Boolean) as ApiKeyEntry[];
}
const headers = normalizeHeaders(provider.headers);
const models = normalizeModelAliases(provider.models);
const priority = provider.priority ?? provider['priority'];
const testModel = provider['test-model'] ?? provider.testModel;
const result: OpenAIProviderConfig = {
name: String(name),
baseUrl: String(baseUrl),
apiKeyEntries
};
if (headers) result.headers = headers;
if (models.length) result.models = models;
if (priority !== undefined) result.priority = Number(priority);
if (testModel) result.testModel = String(testModel);
return result;
};
const normalizeOauthExcluded = (payload: any): Record<string, string[]> | undefined => {
if (!payload || typeof payload !== 'object') return undefined;
const source = payload['oauth-excluded-models'] ?? payload.items ?? payload;
if (!source || typeof source !== 'object') return undefined;
const map: Record<string, string[]> = {};
Object.entries(source).forEach(([provider, models]) => {
const key = String(provider || '').trim();
if (!key) return;
const normalized = normalizeExcludedModels(models);
map[key.toLowerCase()] = normalized;
});
return map;
};
/**
* 规范化 /config 返回值
*/
export const normalizeConfigResponse = (raw: any): Config => {
const config: Config = { raw: raw || {} };
if (!raw || typeof raw !== 'object') {
return config;
}
config.debug = raw.debug;
config.proxyUrl = raw['proxy-url'] ?? raw.proxyUrl;
config.requestRetry = raw['request-retry'] ?? raw.requestRetry;
const quota = raw['quota-exceeded'] ?? raw.quotaExceeded;
if (quota && typeof quota === 'object') {
config.quotaExceeded = {
switchProject: quota['switch-project'] ?? quota.switchProject,
switchPreviewModel: quota['switch-preview-model'] ?? quota.switchPreviewModel
};
}
config.usageStatisticsEnabled = raw['usage-statistics-enabled'] ?? raw.usageStatisticsEnabled;
config.requestLog = raw['request-log'] ?? raw.requestLog;
config.loggingToFile = raw['logging-to-file'] ?? raw.loggingToFile;
config.wsAuth = raw['ws-auth'] ?? raw.wsAuth;
config.apiKeys = Array.isArray(raw['api-keys']) ? raw['api-keys'].slice() : raw.apiKeys;
const geminiList = raw['gemini-api-key'] ?? raw.geminiApiKey ?? raw.geminiApiKeys;
if (Array.isArray(geminiList)) {
config.geminiApiKeys = geminiList
.map((item: any) => normalizeGeminiKeyConfig(item))
.filter(Boolean) as GeminiKeyConfig[];
}
const codexList = raw['codex-api-key'] ?? raw.codexApiKey ?? raw.codexApiKeys;
if (Array.isArray(codexList)) {
config.codexApiKeys = codexList
.map((item: any) => normalizeProviderKeyConfig(item))
.filter(Boolean) as ProviderKeyConfig[];
}
const claudeList = raw['claude-api-key'] ?? raw.claudeApiKey ?? raw.claudeApiKeys;
if (Array.isArray(claudeList)) {
config.claudeApiKeys = claudeList
.map((item: any) => normalizeProviderKeyConfig(item))
.filter(Boolean) as ProviderKeyConfig[];
}
const openaiList = raw['openai-compatibility'] ?? raw.openaiCompatibility ?? raw.openAICompatibility;
if (Array.isArray(openaiList)) {
config.openaiCompatibility = openaiList
.map((item: any) => normalizeOpenAIProvider(item))
.filter(Boolean) as OpenAIProviderConfig[];
}
const oauthExcluded = normalizeOauthExcluded(raw['oauth-excluded-models'] ?? raw.oauthExcludedModels);
if (oauthExcluded) {
config.oauthExcludedModels = oauthExcluded;
}
return config;
};
export {
normalizeApiKeyEntry,
normalizeGeminiKeyConfig,
normalizeModelAliases,
normalizeOpenAIProvider,
normalizeProviderKeyConfig,
normalizeHeaders,
normalizeExcludedModels
};

25
src/services/api/usage.ts Normal file
View File

@@ -0,0 +1,25 @@
/**
* 使用统计相关 API
*/
import { apiClient } from './client';
import { computeKeyStats, KeyStats } from '@/utils/usage';
export const usageApi = {
/**
* 获取使用统计原始数据
*/
getUsage: () => apiClient.get('/usage'),
/**
* 计算密钥成功/失败统计,必要时会先获取 usage 数据
*/
async getKeyStats(usageData?: any): Promise<KeyStats> {
let payload = usageData;
if (!payload) {
const response = await apiClient.get('/usage');
payload = response?.usage ?? response;
}
return computeKeyStats(payload);
}
};

View File

@@ -0,0 +1,9 @@
/**
* 版本相关 API
*/
import { apiClient } from './client';
export const versionApi = {
checkLatest: () => apiClient.get('/latest-version')
};

View File

@@ -0,0 +1,111 @@
/**
* 安全存储服务
* 基于原项目 src/utils/secure-storage.js
*/
import { encryptData, decryptData } from '@/utils/encryption';
interface StorageOptions {
encrypt?: boolean;
}
class SecureStorageService {
/**
* 存储数据
*/
setItem(key: string, value: any, options: StorageOptions = {}): void {
const { encrypt = true } = options;
if (value === null || value === undefined) {
this.removeItem(key);
return;
}
const stringValue = JSON.stringify(value);
const storedValue = encrypt ? encryptData(stringValue) : stringValue;
localStorage.setItem(key, storedValue);
}
/**
* 获取数据
*/
getItem<T = any>(key: string, options: StorageOptions = {}): T | null {
const { encrypt = true } = options;
const raw = localStorage.getItem(key);
if (raw === null) return null;
try {
const decrypted = encrypt ? decryptData(raw) : raw;
return JSON.parse(decrypted) as T;
} catch {
// JSON解析失败,尝试兼容旧的纯字符串数据 (非JSON格式)
try {
// 如果是加密的,尝试解密后直接返回
if (encrypt && raw.startsWith('enc::v1::')) {
const decrypted = decryptData(raw);
// 解密后如果还不是JSON,返回原始字符串
return decrypted as T;
}
// 非加密的纯字符串,直接返回
return raw as T;
} catch {
// 完全失败,静默返回null (避免控制台污染)
return null;
}
}
}
/**
* 删除数据
*/
removeItem(key: string): void {
localStorage.removeItem(key);
}
/**
* 清空所有数据
*/
clear(): void {
localStorage.clear();
}
/**
* 迁移旧的明文缓存为加密格式
*/
migratePlaintextKeys(keys: string[]): void {
keys.forEach((key) => {
const raw = localStorage.getItem(key);
if (!raw) return;
// 如果已经是加密格式,跳过
if (raw.startsWith('enc::v1::')) {
return;
}
let parsed: any = raw;
try {
parsed = JSON.parse(raw);
} catch {
// 原值不是 JSON直接使用字符串
parsed = raw;
}
try {
this.setItem(key, parsed);
} catch (error) {
console.warn(`Failed to migrate key "${key}":`, error);
}
});
}
/**
* 检查键是否存在
*/
hasItem(key: string): boolean {
return localStorage.getItem(key) !== null;
}
}
export const secureStorage = new SecureStorageService();

9
src/stores/index.ts Normal file
View File

@@ -0,0 +1,9 @@
/**
* Zustand Stores 统一导出
*/
export { useNotificationStore } from './useNotificationStore';
export { useThemeStore } from './useThemeStore';
export { useLanguageStore } from './useLanguageStore';
export { useAuthStore } from './useAuthStore';
export { useConfigStore } from './useConfigStore';

Some files were not shown because too many files have changed in this diff Show More