feat: 新增Web UI管理控制台和认证系统

新增Web UI管理控制台,支持实时配置管理和健康状态监控
添加登录认证系统,包含token生成和验证机制
实现供应商池的启用/禁用功能
更新README文档,添加安装脚本和Web UI使用说明
优化配置文件管理界面,增加API客户端封装
新增登录页面和认证中间件
This commit is contained in:
hex2077 2025-11-12 17:37:39 +08:00
parent 80d4e16840
commit ee050c77f2
25 changed files with 2036 additions and 506 deletions

View file

@ -30,10 +30,11 @@
>
> **📅 バージョン更新ログ**
>
> - **2025.11.11** - Web UI管理コントロールコンソールの追加、リアルタイム設定管理与健康状態モニタリングをサポート
> - **2025.11.06** - Gemini 3 プレビュー版のサポートを追加、モデル互換性とパフォーマンス最適化を向上
> - **2025.10.18** - Kiroオープン登録、新規アカウントに500クレジット付与、Claude Sonnet 4.5を完全サポート
> - **2025.09.01** - Qwen Code CLIを統合、`qwen3-coder-plus`モデルサポートを追加
> - **2025.08.29** - アカウントプール管理機能をリリース、マルチアカウントポーリング、インテリジェントフェイルオーバー、自動ダウングレード戦略をサポート
> - **2025.08.29** - アカウントプール管理機能をリリース、マルチアカウントポーリング、自動フェイルオーバー、自動ダウングレード戦略をサポート
> - 設定方法config.jsonに`PROVIDER_POOLS_FILE_PATH`パラメータを追加
> - 参考設定:[provider_pools.json](./provider_pools.json.example)
@ -85,8 +86,8 @@
本プロジェクトは異なるプロトコルを通じて複数のモデルプロバイダーをサポートします。以下はそれらの関係の概要です:
* **OpenAIプロトコル (P_OPENAI)**`openai-custom`、`gemini-cli-oauth`、`claude-custom`、`claude-kiro-oauth`、`openai-qwen-oauth`モデルプロバイダーによって実装。
* **Claudeプロトコル (P_CLAUDE)**`claude-custom`、`claude-kiro-oauth`、`gemini-cli-oauth`、`openai-custom`、`openai-qwen-oauth`モデルプロバイダーによって実装。
* **OpenAIプロトコル (P_OPENAI)**`openai-custom`、`gemini-cli-oauth`、`claude-custom`、`claude-kiro-oauth`、`openai-qwen-oauth`、`openaiResponses-custom`モデルプロバイダーによって実装。
* **Claudeプロトコル (P_CLAUDE)**`claude-custom`、`claude-kiro-oauth`、`gemini-cli-oauth`、`openai-custom`、`openai-qwen-oauth`、`openaiResponses-custom`モデルプロバイダーによって実装。
* **Geminiプロトコル (P_GEMINI)**`gemini-cli-oauth`モデルプロバイダーによって実装。
詳細な関係図:
@ -106,6 +107,7 @@
MP_CLAUDE_C[claude-custom]
MP_CLAUDE_K[claude-kiro-oauth]
MP_QWEN[openai-qwen-oauth]
MP_OPENAI_RESP[openaiResponses-custom]
end
P_OPENAI ---|サポート| MP_OPENAI
@ -113,14 +115,16 @@
P_OPENAI ---|サポート| MP_GEMINI
P_OPENAI ---|サポート| MP_CLAUDE_C
P_OPENAI ---|サポート| MP_CLAUDE_K
P_OPENAI ---|サポート| MP_OPENAI_RESP
P_GEMINI ---|サポート| MP_GEMINI
P_CLAUDE ---|サポート| MP_CLAUDE_C
P_CLAUDE ---|サポート| MP_CLAUDE_K
P_CLAUDE ---|サポート| MP_GEMINI
P_CLAUDE ---|サポート| MP_OPENAI
P_CLAUDE ---|サポート| MP_QWEN
P_CLAUDE ---|サポート| MP_OPENAI_RESP
style P_OPENAI fill:#f9f,stroke:#333,stroke-width:2px
style P_GEMINI fill:#ccf,stroke:#333,stroke-width:2px
@ -132,8 +136,80 @@
## 🔧 使用方法
### 🚀 install-and-run スクリプトでクイックスタート
AIClient-2-APIを使い始める最も簡単な方法は、自動的にインストールと起動を実行するスクリプトを使用することです。Linux/macOS版とWindows版の2つのバージョンを提供
#### Linux/macOS ユーザー向け
```bash
# スクリプトに実行権限を付与して実行
chmod +x install-and-run.sh
./install-and-run.sh
```
#### Windows ユーザー向け
```cmd
# バッチファイルを実行
install-and-run.bat
```
#### スクリプトの機能
`install-and-run` スクリプトは自動的に以下の操作を実行します:
1. **Node.js インストール確認**Node.js がインストールされているかを確認し、不足の場合はダウンロードリンクを提供
2. **依存関係管理**`node_modules` が存在しない場合、npm 依存関係を自動インストール
3. **ファイル検証**:すべての必要なプロジェクトファイルが存在することを確認
4. **サーバー起動**`http://localhost:3000` で API サーバーを起動
5. **Web UI アクセス**:管理コンソールへの直接アクセスを提供
#### スクリプト実行例
```
========================================
AI Client 2 API 快速インストール起動スクリプト
========================================
[確認] Node.js がインストールされているかを確認中...
✅ Node.js がインストールされています、バージョン: v20.10.0
✅ package.json ファイルが見つかりました
✅ node_modules ディレクトリが既に存在しています
✅ プロジェクトファイルの確認が完了しました
========================================
AI Client 2 API サーバーを起動中...
========================================
🌐 サーバーは http://localhost:3000 で起動します
📖 管理インターフェースを表示するには http://localhost:3000 にアクセス
⏹️ サーバーを停止するには Ctrl+C を押してください
```
> **💡 ヒント**:スクリプトは自動的に依存関係をインストールし、サーバーを起動します。問題が発生した場合、スクリプトは明確なエラーメッセージと解決案を提供します。
---
### 📋 コア機能
#### Web UI管理コントロールコンソール
![Web UI](src/img/web.png)
以下の機能モジュールを備えたWeb管理インターフェース
**📊 ダッシュボード**:システム概要、インタラクティブなルーティング例、クライアント設定ガイド
**⚙️ 設定管理**全プロバイダーGemini、OpenAI、Claude、Kiro、Qwenのリアルタイムパラメータ修正、高度設定、ファイルアップロード対応
**🔗 プロバイダープール**:アクティブ接続監視、プロバイダー健全性統計、有効化/無効化管理
**📁 設定ファイル**OAuth資格情報の集中管理、検索フィルタリング、ファイル操作機能
**📜 リアルタイムログ**:システムログとリクエストログのライブ表示、管理コントロール付き
**🔐 ログイン**:認証が必要(デフォルト:`admin123`、`pwd`ファイルで変更可能)
アクセス:`http://localhost:3000` → ログイン → サイドバーナビゲーション → 即座有効
#### MCPプロトコルサポート
本プロジェクトは**Model Context Protocol (MCP)**と完全互換で、MCPをサポートするクライアントとシームレスに統合し、強力な機能拡張を実現します。
@ -145,6 +221,8 @@
* **Kimi K2** - 月之暗面の最新フラッグシップモデル
* **GLM-4.5** - 智譜AIの最新バージョン
* **Qwen Code** - アリババ通義千問のコード専用モデル
* **Gemini 3** - Googleの最新プレビューモデル
* **Claude Sonnet 4.5** - Anthropicの最新フラッグシップモデル
---
@ -187,31 +265,6 @@
本プロジェクトは2つの柔軟なモデル切り替え方法を提供し、異なる使用シナリオのニーズに対応します。
#### 方法1起動パラメータ切り替え
コマンドラインパラメータでデフォルトのモデルプロバイダーを指定:
```bash
# Geminiプロバイダーを使用
node src/api-server.js --model-provider gemini-cli-oauth --project-id your-project-id
# Claude Kiroプロバイダーを使用
node src/api-server.js --model-provider claude-kiro-oauth
# Qwenプロバイダーを使用
node src/api-server.js --model-provider openai-qwen-oauth
```
**利用可能なモデルプロバイダー識別子**
- `openai-custom` - 標準OpenAI API
- `claude-custom` - 公式Claude API
- `gemini-cli-oauth` - Gemini CLI OAuth
- `claude-kiro-oauth` - Kiro Claude OAuth
- `openai-qwen-oauth` - Qwen Code OAuth
- `openaiResponses-custom` - OpenAI Responses API
#### 方法2Pathルーティング切り替え推奨
APIリクエストパスでプロバイダー識別子を指定して即座に切り替え
| ルートパス | 説明 | 使用ケース |
@ -268,7 +321,7 @@ curl http://localhost:3000/gemini-cli-oauth/v1/chat/completions \
| パラメータ | タイプ | デフォルト値 | 説明 |
|------|------|--------|------|
| `--model-provider` | string | gemini-cli-oauth | AIモデルプロバイダー、選択可能値openai-custom, claude-custom, gemini-cli-oauth, claude-kiro-oauth, openai-qwen-oauth |
| `--model-provider` | string | gemini-cli-oauth | AIモデルプロバイダー、選択可能値openai-custom, claude-custom, gemini-cli-oauth, claude-kiro-oauth, openai-qwen-oauth, openaiResponses-custom |
### 🧠 OpenAI互換プロバイダーパラメータ

View file

@ -30,6 +30,7 @@
>
> **📅 版本更新日志**
>
> - **2025.11.11** - 新增 Web UI 管理控制台,支持实时配置管理和健康状态监控
> - **2025.11.06** - 新增对 Gemini 3 预览版的支持,增强模型兼容性和性能优化
> - **2025.10.18** - Kiro 开放注册,新用户赠送 500 额度,已完整支持 Claude Sonnet 4.5
> - **2025.09.01** - 集成 Qwen Code CLI新增 `qwen3-coder-plus` 模型支持
@ -62,6 +63,7 @@
* **系统提示词管理**:支持覆盖和追加两种模式,实现统一基础指令与个性化扩展的完美结合
### 🔧 开发友好,易于扩展
* **Web UI 管理控制台**实时配置管理、健康状态监控、API 测试和日志查看
* **模块化架构**:基于策略模式和适配器模式,新增模型提供商仅需 3 步
* **完整测试保障**:集成测试和单元测试覆盖率 90%+,确保代码质量
* **容器化部署**:提供 Docker 支持,一键部署,跨平台运行
@ -83,7 +85,6 @@
## 🎨 模型协议与提供商关系图
本项目通过不同的协议Protocol支持多种模型提供商Model Provider。以下是它们之间的关系概述
* **OpenAI 协议 (P_OPENAI)**:由 `openai-custom`, `gemini-cli-oauth`, `claude-custom`, `claude-kiro-oauth``openai-qwen-oauth` 等模型提供商实现。
@ -94,7 +95,7 @@
```mermaid
graph TD
subgraph Core_Protocols["核心协议"]
P_OPENAI[OpenAI Protocol]
@ -108,6 +109,7 @@
MP_CLAUDE_C[claude-custom]
MP_CLAUDE_K[claude-kiro-oauth]
MP_QWEN[openai-qwen-oauth]
MP_OPENAI_RESP[openaiResponses-custom]
end
P_OPENAI ---|支持| MP_OPENAI
@ -115,6 +117,7 @@
P_OPENAI ---|支持| MP_GEMINI
P_OPENAI ---|支持| MP_CLAUDE_C
P_OPENAI ---|支持| MP_CLAUDE_K
P_OPENAI ---|支持| MP_OPENAI_RESP
P_GEMINI ---|支持| MP_GEMINI
@ -123,6 +126,7 @@
P_CLAUDE ---|支持| MP_GEMINI
P_CLAUDE ---|支持| MP_OPENAI
P_CLAUDE ---|支持| MP_QWEN
P_CLAUDE ---|支持| MP_OPENAI_RESP
style P_OPENAI fill:#f9f,stroke:#333,stroke-width:2px
style P_GEMINI fill:#ccf,stroke:#333,stroke-width:2px
@ -134,8 +138,80 @@
## 🔧 使用说明
### 🚀 install-and-run 脚本快速启动
使用 AIClient-2-API 最简单的方式是使用我们的自动化安装启动脚本。我们提供了 Linux/macOS 和 Windows 两个版本:
#### Linux/macOS 用户
```bash
# 给脚本添加执行权限并运行
chmod +x install-and-run.sh
./install-and-run.sh
```
#### Windows 用户
```cmd
# 运行批处理文件
install-and-run.bat
```
#### 脚本功能说明
`install-and-run` 脚本会自动执行以下操作:
1. **检查 Node.js 安装**:验证 Node.js 是否已安装,如缺失则提供下载链接
2. **依赖管理**:如果 `node_modules` 不存在,自动安装 npm 依赖包
3. **文件验证**:确保所有必需的项目文件都存在
4. **服务器启动**:在 `http://localhost:3000` 启动 API 服务器
5. **Web UI 访问**:直接提供管理控制台的访问地址
#### 脚本执行示例
```
========================================
AI Client 2 API 快速安装启动脚本
========================================
[检查] 正在检查Node.js是否已安装...
✅ Node.js已安装版本: v20.10.0
✅ 找到package.json文件
✅ node_modules目录已存在
✅ 项目文件检查完成
========================================
启动AI Client 2 API服务器...
========================================
🌐 服务器将在 http://localhost:3000 启动
📖 访问 http://localhost:3000 查看管理界面
⏹️ 按 Ctrl+C 停止服务器
```
> **💡 提示**:脚本会自动安装依赖并启动服务器。如果遇到任何问题,脚本会提供清晰的错误信息和解决建议。
---
### 📋 核心功能
#### Web UI 管理控制台
![Web UI](src/img/web.png)
功能完善的 Web 管理界面,包含:
**📊 仪表盘**:系统概览、交互式路由示例、客户端配置指南
**⚙️ 配置管理**实时参数修改支持所有提供商Gemini、OpenAI、Claude、Kiro、Qwen包含高级设置和文件上传
**🔗 供应商池**:监控活动连接、提供商健康统计、启用/禁用管理
**📁 配置文件**OAuth 凭据集中管理,支持搜索过滤和文件操作
**📜 实时日志**:系统日志和请求日志实时显示,带管理控制
**🔐 登录验证**:默认密码 `admin123`,可通过 `pwd` 文件修改
访问:`http://localhost:3000` → 登录 → 侧边栏导航 → 立即生效
#### MCP 协议支持
本项目完全兼容 **Model Context Protocol (MCP)**,可与支持 MCP 的客户端无缝集成,实现强大的功能扩展。
@ -147,6 +223,8 @@
* **Kimi K2** - 月之暗面最新旗舰模型
* **GLM-4.5** - 智谱 AI 最新版本
* **Qwen Code** - 阿里通义千问代码专用模型
* **Gemini 3** - Google 最新预览版模型
* **Claude Sonnet 4.5** - Anthropic 最新旗舰模型
---
@ -175,13 +253,11 @@
3. **最佳实践**:推荐配合 **Claude Code** 使用,可获得最优体验
4. **重要提示**Kiro 服务使用政策已更新,请访问官方网站查看最新使用限制和条款
#### OpenAI Responses API
* **应用场景**:适用于需要使用 OpenAI Responses API 进行结构化对话的场景如Codex
* **配置方法**
* 方式一:在 [`config.json`](./config.json) 中设置 `MODEL_PROVIDER``openaiResponses-custom`
* 方式二:使用启动参数 `--model-provider openaiResponses-custom`
* 方式三:使用路径路由 `/openaiResponses-custom`
* **必需参数**:提供有效的 API 密钥和基础 URL
#### 账号池管理配置
1. **创建号池配置文件**:参考 [provider_pools.json.example](./provider_pools.json.example) 创建配置文件
2. **配置号池参数**:在 config.json 中设置 `PROVIDER_POOLS_FILE_PATH` 指向号池配置文件
3. **启动参数配置**:使用 `--provider-pools-file <path>` 参数指定号池配置文件路径
4. **健康检查**:系统会定期自动执行健康检查,移除不健康的提供商
---
@ -189,31 +265,6 @@
本项目提供两种灵活的模型切换方式,满足不同使用场景的需求。
#### 方式一:启动参数切换
通过命令行参数指定默认使用的模型提供商:
```bash
# 使用Gemini提供商
node src/api-server.js --model-provider gemini-cli-oauth --project-id your-project-id
# 使用Claude Kiro提供商
node src/api-server.js --model-provider claude-kiro-oauth
# 使用Qwen提供商
node src/api-server.js --model-provider openai-qwen-oauth
```
**可用的模型提供商标识**
- `openai-custom` - 标准OpenAI API
- `claude-custom` - 官方Claude API
- `gemini-cli-oauth` - Gemini CLI OAuth
- `claude-kiro-oauth` - Kiro Claude OAuth
- `openai-qwen-oauth` - Qwen Code OAuth
- `openaiResponses-custom` - OpenAI Responses API
#### 方式二Path 路由切换(推荐)
通过在 API 请求路径中指定供应商标识,实现即时切换:
| 路由路径 | 说明 | 适用场景 |
@ -270,7 +321,7 @@ curl http://localhost:3000/gemini-cli-oauth/v1/chat/completions \
| 参数 | 类型 | 默认值 | 说明 |
|------|------|--------|------|
| `--model-provider` | string | gemini-cli-oauth | AI 模型提供商可选值openai-custom, claude-custom, gemini-cli-oauth, claude-kiro-oauth, openai-qwen-oauth |
| `--model-provider` | string | gemini-cli-oauth | AI 模型提供商可选值openai-custom, claude-custom, gemini-cli-oauth, claude-kiro-oauth, openai-qwen-oauth, openaiResponses-custom |
### 🧠 OpenAI 兼容提供商参数
@ -380,6 +431,9 @@ node src/api-server.js --system-prompt-file custom-prompt.txt --system-prompt-mo
node src/api-server.js --log-prompts console
node src/api-server.js --log-prompts file --prompt-log-base-name my-logs
# 配置号池
node src/api-server.js --provider-pools-file ./provider_pools.json
# 完整示例
node src/api-server.js \
--host 0.0.0.0 \
@ -391,8 +445,10 @@ node src/api-server.js \
--system-prompt-file ./custom-system-prompt.txt \
--system-prompt-mode overwrite \
--log-prompts file \
--prompt-log-base-name api-logs
--prompt-log-base-name api-logs \
--provider-pools-file ./provider_pools.json
```
---
## 📄 开源许可

108
README.md
View file

@ -30,6 +30,7 @@
>
> **📅 Version Update Log**
>
> - **2025.11.11** - Added Web UI management console, supporting real-time configuration management and health status monitoring
> - **2025.11.06** - Added support for Gemini 3 Preview, enhanced model compatibility and performance optimization
> - **2025.10.18** - Kiro open registration, new accounts get 500 credits, full support for Claude Sonnet 4.5
> - **2025.09.01** - Integrated Qwen Code CLI, added `qwen3-coder-plus` model support
@ -85,8 +86,8 @@
This project supports multiple model providers through different protocols. The following is an overview of their relationships:
* **OpenAI Protocol (P_OPENAI)**: Implemented by `openai-custom`, `gemini-cli-oauth`, `claude-custom`, `claude-kiro-oauth`, and `openai-qwen-oauth` model providers.
* **Claude Protocol (P_CLAUDE)**: Implemented by `claude-custom`, `claude-kiro-oauth`, `gemini-cli-oauth`, `openai-custom`, and `openai-qwen-oauth` model providers.
* **OpenAI Protocol (P_OPENAI)**: Implemented by `openai-custom`, `gemini-cli-oauth`, `claude-custom`, `claude-kiro-oauth`, `openai-qwen-oauth`, and `openaiResponses-custom` model providers.
* **Claude Protocol (P_CLAUDE)**: Implemented by `claude-custom`, `claude-kiro-oauth`, `gemini-cli-oauth`, `openai-custom`, `openai-qwen-oauth`, and `openaiResponses-custom` model providers.
* **Gemini Protocol (P_GEMINI)**: Implemented by `gemini-cli-oauth` model provider.
Detailed relationship diagram:
@ -106,6 +107,7 @@ Detailed relationship diagram:
MP_CLAUDE_C[claude-custom]
MP_CLAUDE_K[claude-kiro-oauth]
MP_QWEN[openai-qwen-oauth]
MP_OPENAI_RESP[openaiResponses-custom]
end
P_OPENAI ---|Support| MP_OPENAI
@ -113,6 +115,7 @@ Detailed relationship diagram:
P_OPENAI ---|Support| MP_GEMINI
P_OPENAI ---|Support| MP_CLAUDE_C
P_OPENAI ---|Support| MP_CLAUDE_K
P_OPENAI ---|Support| MP_OPENAI_RESP
P_GEMINI ---|Support| MP_GEMINI
@ -121,6 +124,7 @@ Detailed relationship diagram:
P_CLAUDE ---|Support| MP_GEMINI
P_CLAUDE ---|Support| MP_OPENAI
P_CLAUDE ---|Support| MP_QWEN
P_CLAUDE ---|Support| MP_OPENAI_RESP
style P_OPENAI fill:#f9f,stroke:#333,stroke-width:2px
style P_GEMINI fill:#ccf,stroke:#333,stroke-width:2px
@ -132,8 +136,79 @@ Detailed relationship diagram:
## 🔧 Usage Instructions
### 🚀 Quick Start with install-and-run Script
The easiest way to get started with AIClient-2-API is to use our automated installation and startup scripts. We provide both Linux/macOS and Windows versions:
#### For Linux/macOS Users
```bash
# Make the script executable and run it
chmod +x install-and-run.sh
./install-and-run.sh
```
#### For Windows Users
```cmd
# Run the batch file
install-and-run.bat
```
#### What the Script Does
The `install-and-run` script automatically:
1. **Checks Node.js Installation**: Verifies Node.js is installed and provides download link if missing
2. **Dependency Management**: Automatically installs npm dependencies if `node_modules` doesn't exist
3. **File Validation**: Ensures all required project files are present
4. **Server Startup**: Launches the API server on `http://localhost:3000`
5. **Web UI Access**: Provides direct access to the management console
#### Script Output Example
```
========================================
AI Client 2 API Quick Install Script
========================================
[Check] Checking if Node.js is installed...
✅ Node.js is installed, version: v20.10.0
✅ Found package.json file
✅ node_modules directory already exists
✅ Project file check completed
========================================
Starting AI Client 2 API Server...
========================================
🌐 Server will start on http://localhost:3000
📖 Visit http://localhost:3000 to view management interface
⏹️ Press Ctrl+C to stop server
```
> **💡 Tip**: The script will automatically install dependencies and start the server. If you encounter any issues, the script provides clear error messages and suggested solutions.
---
### 📋 Core Features
#### Web UI Management Console
![Web UI](src/img/web.png)
A comprehensive web-based management interface offering:
**📊 Dashboard**: System overview, interactive routing examples, and client configuration guides
**⚙️ Configuration**: Real-time parameter modification for all providers (Gemini, OpenAI, Claude, Kiro, Qwen) with advanced settings and file upload support
**🔗 Provider Pools**: Monitor active connections, provider health statistics, and enable/disable providers
**📁 Config Files**: Centralized OAuth credential management with search, filtering, and file operations
**📜 Logs**: Real-time system and request logs with management controls
**🔐 Login**: Authentication required (default: `admin123`, modify via `pwd` file)
Access: `http://localhost:3000` → Login → Sidebar navigation → Immediate effect changes
#### MCP Protocol Support
This project is fully compatible with **Model Context Protocol (MCP)**, enabling seamless integration with MCP-supporting clients for powerful functional extensions.
@ -145,6 +220,8 @@ Seamlessly supports the following latest large models, simply configure the corr
* **Kimi K2** - Moonshot AI's latest flagship model
* **GLM-4.5** - Zhipu AI's latest version
* **Qwen Code** - Alibaba Tongyi Qianwen code-specific model
* **Gemini 3** - Google's latest preview model
* **Claude Sonnet 4.5** - Anthropic's latest flagship model
---
@ -187,31 +264,6 @@ Seamlessly supports the following latest large models, simply configure the corr
This project provides two flexible model switching methods to meet different usage scenario requirements.
#### Method 1: Startup Parameter Switching
Specify the default model provider via command line parameters:
```bash
# Use Gemini provider
node src/api-server.js --model-provider gemini-cli-oauth --project-id your-project-id
# Use Claude Kiro provider
node src/api-server.js --model-provider claude-kiro-oauth
# Use Qwen provider
node src/api-server.js --model-provider openai-qwen-oauth
```
**Available Model Provider Identifiers**:
- `openai-custom` - Standard OpenAI API
- `claude-custom` - Official Claude API
- `gemini-cli-oauth` - Gemini CLI OAuth
- `claude-kiro-oauth` - Kiro Claude OAuth
- `openai-qwen-oauth` - Qwen Code OAuth
- `openaiResponses-custom` - OpenAI Responses API
#### Method 2: Path Routing Switching (Recommended)
Achieve instant switching by specifying provider identifier in API request path:
| Route Path | Description | Use Case |
@ -268,7 +320,7 @@ This project supports rich command-line parameter configuration, allowing flexib
| Parameter | Type | Default Value | Description |
|------|------|--------|------|
| `--model-provider` | string | gemini-cli-oauth | AI model provider, optional values: openai-custom, claude-custom, gemini-cli-oauth, claude-kiro-oauth, openai-qwen-oauth |
| `--model-provider` | string | gemini-cli-oauth | AI model provider, optional values: openai-custom, claude-custom, gemini-cli-oauth, claude-kiro-oauth, openai-qwen-oauth, openaiResponses-custom |
### 🧠 OpenAI Compatible Provider Parameters

101
install-and-run.bat Normal file
View file

@ -0,0 +1,101 @@
@echo off
chcp 65001 >nul
setlocal enabledelayedexpansion
echo ========================================
echo AI Client 2 API 快速安装启动脚本
echo ========================================
echo.
:: 检查Node.js是否已安装
echo [检查] 正在检查Node.js是否已安装...
node --version >nul 2>&1
if %errorlevel% neq 0 (
echo ❌ 错误未检测到Node.js请先安装Node.js
echo 📥 下载地址https://nodejs.org/
echo 💡 推荐安装LTS版本
pause
exit /b 1
)
:: 获取Node.js版本
for /f "tokens=*" %%i in ('node --version') do set NODE_VERSION=%%i
echo ✅ Node.js已安装版本: !NODE_VERSION!
:: 检查package.json是否存在
if not exist "package.json" (
echo ❌ 错误未找到package.json文件
echo 请确保在项目根目录下运行此脚本
pause
exit /b 1
)
echo ✅ 找到package.json文件
:: 检查node_modules目录是否存在
if not exist "node_modules" (
echo [安装] node_modules目录不存在正在安装依赖...
echo 这可能需要几分钟时间,请耐心等待...
echo 正在执行: npm install...
:: 使用npm install并设置超时机制
npm install --timeout=300000
if !errorlevel! neq 0 (
echo ❌ 依赖安装失败
echo 请检查网络连接或手动运行 'npm install'
pause
exit /b 1
)
echo ✅ 依赖安装完成
) else (
echo ✅ node_modules目录已存在
)
:: 检查package-lock.json是否存在
if not exist "package-lock.json" (
echo [更新] package-lock.json不存在正在更新依赖...
echo 正在执行: npm install...
:: 使用npm install并设置超时机制
npm install --timeout=300000
if !errorlevel! neq 0 (
echo ❌ 依赖更新失败
echo 请检查网络连接或手动运行 'npm install'
pause
exit /b 1
)
echo ✅ 依赖更新完成
) else (
echo ✅ package-lock.json文件存在
)
:: 检查src目录和api-server.js是否存在
if not exist "src\api-server.js" (
echo ❌ 错误未找到src\api-server.js文件
pause
exit /b 1
)
echo ✅ 项目文件检查完成
:: 启动应用程序
echo.
echo ========================================
echo 启动AI Client 2 API服务器...
echo ========================================
echo.
echo 🌐 服务器将在 http://localhost:3000 启动
echo 📖 访问 http://localhost:3000 查看管理界面
echo ⏹️ 按 Ctrl+C 停止服务器
echo.
:: 启动服务器
node src\api-server.js
:: 如果启动失败
if !errorlevel! neq 0 (
echo.
echo ❌ 服务器异常
pause
exit /b 1
)
pause

102
install-and-run.sh Normal file
View file

@ -0,0 +1,102 @@
#!/bin/bash
# 设置中文环境
export LC_ALL=zh_CN.UTF-8
export LANG=zh_CN.UTF-8
echo "========================================"
echo " AI Client 2 API 快速安装启动脚本"
echo "========================================"
echo
# 检查Node.js是否已安装
echo "[检查] 正在检查Node.js是否已安装..."
node --version > /dev/null 2>&1
if [ $? -ne 0 ]; then
echo "❌ 错误未检测到Node.js请先安装Node.js"
echo "📥 下载地址https://nodejs.org/"
echo "💡 推荐安装LTS版本"
exit 1
fi
# 获取Node.js版本
NODE_VERSION=$(node --version 2>/dev/null)
echo "✅ Node.js已安装版本: $NODE_VERSION"
# 检查npm是否可用
echo "[检查] 正在检查npm是否可用..."
npm --version > /dev/null 2>&1
if [ $? -ne 0 ]; then
echo "❌ 错误npm不可用请重新安装Node.js"
exit 1
fi
# 检查package.json是否存在
if [ ! -f "package.json" ]; then
echo "❌ 错误未找到package.json文件"
echo "请确保在项目根目录下运行此脚本"
exit 1
fi
echo "✅ 找到package.json文件"
# 检查node_modules目录是否存在
if [ ! -d "node_modules" ]; then
echo "[安装] node_modules目录不存在正在安装依赖..."
echo "这可能需要几分钟时间,请耐心等待..."
echo "正在执行: npm install..."
npm install
if [ $? -ne 0 ]; then
echo "❌ 依赖安装失败"
echo "请检查网络连接或运行 'npm install' 手动安装"
exit 1
fi
echo "✅ 依赖安装完成"
else
echo "✅ node_modules目录已存在"
fi
# 检查package-lock.json是否存在
if [ ! -f "package-lock.json" ]; then
echo "[更新] package-lock.json不存在正在更新依赖..."
echo "正在执行: npm install..."
npm install
if [ $? -ne 0 ]; then
echo "❌ 依赖更新失败"
echo "请检查网络连接或运行 'npm install' 手动安装"
exit 1
fi
echo "✅ 依赖更新完成"
else
echo "✅ package-lock.json文件存在"
fi
# 检查src目录和api-server.js是否存在
if [ ! -f "src/api-server.js" ]; then
echo "❌ 错误未找到src/api-server.js文件"
exit 1
fi
echo "✅ 项目文件检查完成"
# 启动应用程序
echo
echo "========================================"
echo " 启动AI Client 2 API服务器..."
echo "========================================"
echo
echo "🌐 服务器将在 http://localhost:3000 启动"
echo "📖 访问 http://localhost:3000 查看管理界面"
echo "⏹️ 按 Ctrl+C 停止服务器"
echo
# 启动服务器
node src/api-server.js
# 如果启动失败
if [ $? -ne 0 ]; then
echo
echo "❌ 服务器异常"
echo "请检查错误信息并重试"
exit 1
fi

View file

@ -7,6 +7,7 @@
"checkHealth": true,
"uuid": "2f579c65-d3c5-41b1-9985-9f6e3d7bf39c",
"isHealthy": true,
"isDisabled": false,
"lastUsed": null,
"usageCount": 0,
"errorCount": 0,
@ -19,6 +20,7 @@
"checkHealth": true,
"uuid": "e284628d-302f-456d-91f3-6095386fb3b8",
"isHealthy": true,
"isDisabled": true,
"lastUsed": null,
"usageCount": 0,
"errorCount": 0,
@ -33,6 +35,7 @@
"checkHealth": true,
"uuid": "e284628d-302f-456d-91f3-609538678968",
"isHealthy": true,
"isDisabled": false,
"lastUsed": null,
"usageCount": 0,
"errorCount": 0,
@ -47,6 +50,7 @@
"checkHealth": true,
"uuid": "ac200154-26b8-4f5f-8650-e8cc738b06e3",
"isHealthy": true,
"isDisabled": false,
"lastUsed": null,
"usageCount": 0,
"errorCount": 0,
@ -59,6 +63,7 @@
"checkHealth": true,
"uuid": "4f8afcc2-a9bb-4b96-bb50-3b9667a71f54",
"isHealthy": true,
"isDisabled": false,
"lastUsed": null,
"usageCount": 0,
"errorCount": 0,
@ -73,6 +78,7 @@
"checkHealth": true,
"uuid": "bb87047a-3b1d-4249-adbb-1087ecd58128",
"isHealthy": true,
"isDisabled": false,
"lastUsed": null,
"usageCount": 0,
"errorCount": 0,
@ -85,6 +91,7 @@
"checkHealth": true,
"uuid": "7c2002c6-122a-4db0-af06-8a0ff433801a",
"isHealthy": true,
"isDisabled": false,
"lastUsed": null,
"usageCount": 0,
"errorCount": 0,
@ -98,6 +105,7 @@
"checkModelName": null,
"checkHealth": true,
"isHealthy": true,
"isDisabled": false,
"lastUsed": null,
"usageCount": 0,
"errorCount": 0,
@ -109,6 +117,7 @@
"checkModelName": null,
"checkHealth": true,
"isHealthy": true,
"isDisabled": false,
"lastUsed": null,
"usageCount": 0,
"errorCount": 0,
@ -122,6 +131,7 @@
"checkModelName": null,
"checkHealth": true,
"isHealthy": true,
"isDisabled": false,
"lastUsed": null,
"usageCount": 0,
"errorCount": 0,

1
pwd Normal file
View file

@ -0,0 +1 @@
admin123

View file

@ -166,16 +166,16 @@ async function startServer() {
try {
const open = (await import('open')).default;
setTimeout(() => {
open(`http://${CONFIG.HOST}:${CONFIG.SERVER_PORT}/`)
open(`http://${CONFIG.HOST}:${CONFIG.SERVER_PORT}/login.html`)
.then(() => {
console.log('[UI] Opened management console in default browser');
console.log('[UI] Opened login page in default browser');
})
.catch(err => {
console.log('[UI] Please open manually: http://' + CONFIG.HOST + ':' + CONFIG.SERVER_PORT + '/');
console.log('[UI] Please open manually: http://' + CONFIG.HOST + ':' + CONFIG.SERVER_PORT + '/login.html');
});
}, 1000);
} catch (err) {
console.log(`[UI] Management console available at: http://${CONFIG.HOST}:${CONFIG.SERVER_PORT}/`);
console.log(`[UI] Login page available at: http://${CONFIG.HOST}:${CONFIG.SERVER_PORT}/login.html`);
}
// }

View file

@ -78,14 +78,14 @@ export async function initializeConfig(args = process.argv.slice(2), configFileP
QWEN_OAUTH_CREDS_FILE_PATH: null,
PROJECT_ID: null,
SYSTEM_PROMPT_FILE_PATH: INPUT_SYSTEM_PROMPT_FILE, // Default value
SYSTEM_PROMPT_MODE: 'overwrite',
SYSTEM_PROMPT_MODE: 'append',
PROMPT_LOG_BASE_NAME: "prompt_log",
PROMPT_LOG_MODE: "none",
REQUEST_MAX_RETRIES: 3,
REQUEST_BASE_DELAY: 1000,
CRON_NEAR_MINUTES: 15,
CRON_REFRESH_TOKEN: true,
PROVIDER_POOLS_FILE_PATH: null // 新增号池配置文件路径
CRON_REFRESH_TOKEN: false,
PROVIDER_POOLS_FILE_PATH: '' // 新增号池配置文件路径
};
console.log('[Config] Using default configuration.');
}

BIN
src/img/web.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 117 KiB

View file

@ -33,6 +33,7 @@ export class ProviderPoolManager {
this.providerPools[providerType].forEach((providerConfig) => {
// Ensure initial health and usage stats are present in the config
providerConfig.isHealthy = providerConfig.isHealthy !== undefined ? providerConfig.isHealthy : true;
providerConfig.isDisabled = providerConfig.isDisabled !== undefined ? providerConfig.isDisabled : false;
providerConfig.lastUsed = providerConfig.lastUsed !== undefined ? providerConfig.lastUsed : null;
providerConfig.usageCount = providerConfig.usageCount !== undefined ? providerConfig.usageCount : 0;
providerConfig.errorCount = providerConfig.errorCount !== undefined ? providerConfig.errorCount : 0;
@ -59,20 +60,22 @@ export class ProviderPoolManager {
*/
selectProvider(providerType) {
const availableProviders = this.providerStatus[providerType] || [];
const healthyProviders = availableProviders.filter(p => p.config.isHealthy);
const availableAndHealthyProviders = availableProviders.filter(p =>
p.config.isHealthy && !p.config.isDisabled
);
if (healthyProviders.length === 0) {
console.warn(`[ProviderPoolManager] No healthy providers available for type: ${providerType}`);
if (availableAndHealthyProviders.length === 0) {
console.warn(`[ProviderPoolManager] No available and healthy providers for type: ${providerType}`);
return null;
}
// 优化3: 简化轮询逻辑,移除不必要的循环
const currentIndex = this.roundRobinIndex[providerType] || 0;
const providerIndex = currentIndex % healthyProviders.length;
const selected = healthyProviders[providerIndex];
const providerIndex = currentIndex % availableAndHealthyProviders.length;
const selected = availableAndHealthyProviders[providerIndex];
// 更新下次轮询的索引
this.roundRobinIndex[providerType] = (providerIndex + 1) % healthyProviders.length;
this.roundRobinIndex[providerType] = (providerIndex + 1) % availableAndHealthyProviders.length;
// 更新使用信息
selected.config.lastUsed = new Date().toISOString();
@ -136,6 +139,44 @@ export class ProviderPoolManager {
}
}
/**
* 禁用指定供应商
* @param {string} providerType - 供应商类型
* @param {object} providerConfig - 供应商配置
*/
disableProvider(providerType, providerConfig) {
const pool = this.providerStatus[providerType];
if (pool) {
const provider = pool.find(p => p.uuid === providerConfig.uuid);
if (provider) {
provider.config.isDisabled = true;
console.log(`[ProviderPoolManager] Disabled provider: ${providerConfig.uuid} for type ${providerType}`);
// 使用防抖保存
this._debouncedSave(providerType);
}
}
}
/**
* 启用指定供应商
* @param {string} providerType - 供应商类型
* @param {object} providerConfig - 供应商配置
*/
enableProvider(providerType, providerConfig) {
const pool = this.providerStatus[providerType];
if (pool) {
const provider = pool.find(p => p.uuid === providerConfig.uuid);
if (provider) {
provider.config.isDisabled = false;
console.log(`[ProviderPoolManager] Enabled provider: ${providerConfig.uuid} for type ${providerType}`);
// 使用防抖保存
this._debouncedSave(providerType);
}
}
}
/**
* Performs health checks on all providers in the pool.
* This method would typically be called periodically (e.g., via cron job).

View file

@ -6,7 +6,6 @@ import { getApiService } from './service-manager.js';
import { getProviderPoolManager } from './service-manager.js';
import { MODEL_PROVIDER } from './common.js';
import { PROMPT_LOG_FILENAME } from './config-manager.js';
/**
* Main request handler. It authenticates the request, determines the endpoint type,
* and delegates to the appropriate specialized handler function.
@ -32,13 +31,12 @@ export function createRequestHandler(config, providerPoolManager) {
return;
}
// Serve static files for UI
if (path.startsWith('/static/') || path === '/' || path === '/index.html' || path.startsWith('/app/')) {
// Serve static files for UI (除了登录页面需要认证)
if (path.startsWith('/static/') || path === '/' || path === '/favicon.ico' || path === '/index.html' || path.startsWith('/app/') || path === '/login.html') {
const served = await serveStaticFiles(path, res);
if (served) return;
}
// Handle UI management API requests
const uiHandled = await handleUIApiRequests(method, path, req, res, currentConfig, providerPoolManager);
if (uiHandled) return;
@ -51,7 +49,7 @@ export function createRequestHandler(config, providerPoolManager) {
currentConfig.MODEL_PROVIDER = modelProviderHeader;
console.log(`[Config] MODEL_PROVIDER overridden by header to: ${currentConfig.MODEL_PROVIDER}`);
}
// Check if the first path segment matches a MODEL_PROVIDER and switch if it does
const pathSegments = path.split('/').filter(segment => segment.length > 0);
if (pathSegments.length > 0) {

View file

@ -2,9 +2,182 @@ import { existsSync, readFileSync, writeFileSync } from 'fs';
import { promises as fs } from 'fs';
import path from 'path';
import multer from 'multer';
import crypto from 'crypto';
import { getRequestBody } from './common.js';
import { CONFIG } from './config-manager.js';
// Token存储在内存中生产环境建议使用Redis
const tokenStore = new Map();
/**
* 生成简单的token
*/
function generateToken() {
return crypto.randomBytes(32).toString('hex');
}
/**
* 生成token过期时间
*/
function getExpiryTime() {
const now = Date.now();
const expiry = 60 * 60 * 1000; // 1小时
return now + expiry;
}
/**
* 验证简单token
*/
function verifyToken(token) {
const tokenInfo = tokenStore.get(token);
if (!tokenInfo) {
return null;
}
// 检查是否过期
if (Date.now() > tokenInfo.expiryTime) {
tokenStore.delete(token);
return null;
}
return tokenInfo;
}
/**
* 清理过期的token
*/
function cleanupExpiredTokens() {
const now = Date.now();
for (const [token, info] of tokenStore.entries()) {
if (now > info.expiryTime) {
tokenStore.delete(token);
}
}
}
/**
* 读取密码文件内容
*/
async function readPasswordFile() {
try {
const password = await fs.readFile('./pwd', 'utf8');
return password.trim();
} catch (error) {
console.error('读取密码文件失败:', error);
return null;
}
}
/**
* 验证登录凭据
*/
async function validateCredentials(password) {
const storedPassword = await readPasswordFile();
return storedPassword && password === storedPassword;
}
/**
* 解析请求体JSON
*/
function parseRequestBody(req) {
return new Promise((resolve, reject) => {
let body = '';
req.on('data', chunk => {
body += chunk.toString();
});
req.on('end', () => {
try {
if (!body.trim()) {
resolve({});
} else {
resolve(JSON.parse(body));
}
} catch (error) {
reject(new Error('无效的JSON格式'));
}
});
req.on('error', reject);
});
}
/**
* 检查token验证
*/
function checkAuth(req) {
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return false;
}
const token = authHeader.substring(7);
const tokenInfo = verifyToken(token);
return tokenInfo !== null;
}
/**
* 处理登录请求
*/
async function handleLoginRequest(req, res) {
if (req.method !== 'POST') {
res.writeHead(405, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ success: false, message: '仅支持POST请求' }));
return true;
}
try {
const requestData = await parseRequestBody(req);
const { password } = requestData;
if (!password) {
res.writeHead(400, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ success: false, message: '密码不能为空' }));
return true;
}
const isValid = await validateCredentials(password);
if (isValid) {
// 生成简单token
const token = generateToken();
const expiryTime = getExpiryTime();
// 存储token信息
tokenStore.set(token, {
username: 'admin',
loginTime: Date.now(),
expiryTime
});
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({
success: true,
message: '登录成功',
token,
expiresIn: '1小时'
}));
} else {
res.writeHead(401, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({
success: false,
message: '密码错误,请重试'
}));
}
} catch (error) {
console.error('登录处理错误:', error);
res.writeHead(500, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({
success: false,
message: error.message || '服务器错误'
}));
}
return true;
}
// 定时清理过期token
setInterval(cleanupExpiredTokens, 5 * 60 * 1000); // 每5分钟清理一次
// 配置multer中间件
const storage = multer.diskStorage({
destination: async (req, file, cb) => {
@ -80,6 +253,38 @@ export async function serveStaticFiles(pathParam, res) {
* @returns {Promise<boolean>} - True if the request was handled by UI API
*/
export async function handleUIApiRequests(method, pathParam, req, res, currentConfig, providerPoolManager) {
// 处理登录接口
if (method === 'POST' && pathParam === '/api/login') {
const handled = await handleLoginRequest(req, res);
if (handled) return true;
}
// 健康检查接口用于前端token验证
if (method === 'GET' && pathParam === '/api/health') {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ status: 'ok', timestamp: Date.now() }));
return true;
}
// Handle UI management API requests (需要token验证除了登录接口、健康检查和Events接口)
if (pathParam.startsWith('/api/') && pathParam !== '/api/login' && pathParam !== '/api/health' && pathParam !== '/api/events') {
// 检查token验证
if (!checkAuth(req)) {
res.writeHead(401, {
'Content-Type': 'application/json',
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Headers': 'Content-Type, Authorization'
});
res.end(JSON.stringify({
error: {
message: '未授权访问,请先登录',
code: 'UNAUTHORIZED'
}
}));
return true;
}
}
// 文件上传API
if (method === 'POST' && pathParam === '/api/upload-oauth-credentials') {
const uploadMiddleware = upload.single('file');
@ -612,6 +817,85 @@ export async function handleUIApiRequests(method, pathParam, req, res, currentCo
}
}
// Disable/Enable specific provider configuration
const disableEnableProviderMatch = pathParam.match(/^\/api\/providers\/([^\/]+)\/([^\/]+)\/(disable|enable)$/);
if (disableEnableProviderMatch) {
const providerType = decodeURIComponent(disableEnableProviderMatch[1]);
const providerUuid = disableEnableProviderMatch[2];
const action = disableEnableProviderMatch[3];
try {
const filePath = currentConfig.PROVIDER_POOLS_FILE_PATH || 'provider_pools.json';
let providerPools = {};
// Load existing pools
if (existsSync(filePath)) {
try {
const fileContent = readFileSync(filePath, 'utf8');
providerPools = JSON.parse(fileContent);
} catch (readError) {
res.writeHead(404, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: { message: 'Provider pools file not found' } }));
return true;
}
}
// Find and update the provider
const providers = providerPools[providerType] || [];
const providerIndex = providers.findIndex(p => p.uuid === providerUuid);
if (providerIndex === -1) {
res.writeHead(404, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: { message: 'Provider not found' } }));
return true;
}
// Update isDisabled field
const provider = providers[providerIndex];
provider.isDisabled = action === 'disable';
// Save to file
writeFileSync(filePath, JSON.stringify(providerPools, null, 2), 'utf8');
console.log(`[UI API] ${action === 'disable' ? 'Disabled' : 'Enabled'} provider ${providerUuid} in ${providerType}`);
// Update provider pool manager if available
if (providerPoolManager) {
providerPoolManager.providerPools = providerPools;
// Call the appropriate method
if (action === 'disable') {
providerPoolManager.disableProvider(providerType, provider);
} else {
providerPoolManager.enableProvider(providerType, provider);
}
}
// Update CONFIG cache to maintain consistency
CONFIG.providerPools = providerPools;
// 广播更新事件
broadcastEvent('config_update', {
action: action,
filePath: filePath,
providerType,
providerConfig: provider,
timestamp: new Date().toISOString()
});
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({
success: true,
message: `Provider ${action}d successfully`,
provider: provider
}));
return true;
} catch (error) {
res.writeHead(500, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: { message: error.message } }));
return true;
}
}
// Server-Sent Events for real-time updates
if (method === 'GET' && pathParam === '/api/events') {
res.writeHead(200, {
@ -900,7 +1184,7 @@ async function scanConfigFiles(currentConfig, providerPoolManager) {
const configsPath = path.join(process.cwd(), 'configs');
if (!existsSync(configsPath)) {
console.log('[Config Scanner] configs directory not found, creating empty result');
// console.log('[Config Scanner] configs directory not found, creating empty result');
return configFiles;
}

305
static/app/auth.js Normal file
View file

@ -0,0 +1,305 @@
// 认证模块 - 处理token管理和API调用封装
/**
* 认证管理类
*/
class AuthManager {
constructor() {
this.tokenKey = 'authToken';
this.expiryKey = 'authTokenExpiry';
this.baseURL = window.location.origin;
}
/**
* 获取存储的token
*/
getToken() {
return localStorage.getItem(this.tokenKey);
}
/**
* 获取token过期时间
*/
getTokenExpiry() {
const expiry = localStorage.getItem(this.expiryKey);
return expiry ? parseInt(expiry) : null;
}
/**
* 检查token是否有效
*/
isTokenValid() {
const token = this.getToken();
const expiry = this.getTokenExpiry();
if (!token) return false;
// 如果设置了过期时间,检查是否过期
if (expiry && Date.now() > expiry) {
this.clearToken();
return false;
}
return true;
}
/**
* 保存token到本地存储
*/
saveToken(token, rememberMe = false) {
localStorage.setItem(this.tokenKey, token);
if (rememberMe) {
const expiryTime = Date.now() + (7 * 24 * 60 * 60 * 1000); // 7天
localStorage.setItem(this.expiryKey, expiryTime.toString());
}
}
/**
* 清除token
*/
clearToken() {
localStorage.removeItem(this.tokenKey);
localStorage.removeItem(this.expiryKey);
}
/**
* 登出
*/
async logout() {
this.clearToken();
window.location.href = '/login.html';
}
}
/**
* API调用封装类
*/
class ApiClient {
constructor() {
this.authManager = new AuthManager();
this.baseURL = window.location.origin;
}
/**
* 获取带认证的请求头
*/
getAuthHeaders() {
const token = this.authManager.getToken();
return token ? {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
} : {
'Content-Type': 'application/json'
};
}
/**
* 处理401错误重定向到登录页
*/
handleUnauthorized() {
this.authManager.clearToken();
window.location.href = '/login.html';
}
/**
* 通用API请求方法
*/
async request(endpoint, options = {}) {
const url = `${this.baseURL}/api${endpoint}`;
const headers = {
...this.getAuthHeaders(),
...options.headers
};
const config = {
...options,
headers
};
try {
const response = await fetch(url, config);
// 如果是401错误重定向到登录页
if (response.status === 401) {
this.handleUnauthorized();
throw new Error('未授权访问');
}
const contentType = response.headers.get('content-type');
if (contentType && contentType.includes('application/json')) {
return await response.json();
} else {
return await response.text();
}
} catch (error) {
if (error.message === '未授权访问') {
// 已经在handleUnauthorized中处理了重定向
throw error;
}
console.error('API请求错误:', error);
throw error;
}
}
/**
* GET请求
*/
async get(endpoint, params = {}) {
const queryString = new URLSearchParams(params).toString();
const url = queryString ? `${endpoint}?${queryString}` : endpoint;
return this.request(url, { method: 'GET' });
}
/**
* POST请求
*/
async post(endpoint, data = {}) {
return this.request(endpoint, {
method: 'POST',
body: JSON.stringify(data)
});
}
/**
* PUT请求
*/
async put(endpoint, data = {}) {
return this.request(endpoint, {
method: 'PUT',
body: JSON.stringify(data)
});
}
/**
* DELETE请求
*/
async delete(endpoint) {
return this.request(endpoint, { method: 'DELETE' });
}
/**
* POST请求支持FormData上传
*/
async upload(endpoint, formData) {
const url = `${this.baseURL}/api${endpoint}`;
// 获取认证token
const token = this.authManager.getToken();
const headers = {};
// 如果有token添加Authorization头部
if (token) {
headers['Authorization'] = `Bearer ${token}`;
}
// 对于FormData请求不添加Content-Type头部让浏览器自动设置
const config = {
method: 'POST',
headers,
body: formData
};
try {
const response = await fetch(url, config);
// 如果是401错误重定向到登录页
if (response.status === 401) {
this.handleUnauthorized();
throw new Error('未授权访问');
}
const contentType = response.headers.get('content-type');
if (contentType && contentType.includes('application/json')) {
return await response.json();
} else {
return await response.text();
}
} catch (error) {
if (error.message === '未授权访问') {
// 已经在handleUnauthorized中处理了重定向
throw error;
}
console.error('API请求错误:', error);
throw error;
}
}
}
/**
* 初始化认证检查
*/
async function initAuth() {
const authManager = new AuthManager();
// 检查是否已经有有效的token
if (authManager.isTokenValid()) {
// 验证token是否仍然有效发送一个测试请求
try {
const apiClient = new ApiClient();
await apiClient.get('/health');
return true;
} catch (error) {
// Token无效清除并重定向到登录页
authManager.clearToken();
window.location.href = '/login.html';
return false;
}
} else {
// 没有有效token重定向到登录页
window.location.href = '/login.html';
return false;
}
}
/**
* 登出函数
*/
async function logout() {
const authManager = new AuthManager();
await authManager.logout();
}
/**
* 登录函数供登录页面使用
*/
async function login(password, rememberMe = false) {
try {
const response = await fetch('/api/login', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
password,
rememberMe
})
});
const data = await response.json();
if (data.success) {
// 保存token
const authManager = new AuthManager();
authManager.saveToken(data.token, rememberMe);
return { success: true };
} else {
return { success: false, message: data.message };
}
} catch (error) {
console.error('登录错误:', error);
return { success: false, message: '登录失败,请检查网络连接' };
}
}
// 导出实例
window.authManager = new AuthManager();
window.apiClient = new ApiClient();
window.initAuth = initAuth;
window.logout = logout;
window.login = login;
// 导出认证管理器类和API客户端类供其他模块使用
window.AuthManager = AuthManager;
window.ApiClient = ApiClient;
console.log('认证模块已加载');

View file

@ -2,14 +2,14 @@
import { showToast, formatUptime } from './utils.js';
import { handleProviderChange, handleGeminiCredsTypeChange, handleKiroCredsTypeChange } from './event-handlers.js';
import { loadProviders } from './provider-manager.js';
/**
* 加载配置
*/
async function loadConfiguration() {
try {
const response = await fetch('/api/config');
const data = await response.json();
const data = await window.apiClient.get('/config');
// 基础配置
const apiKeyEl = document.getElementById('apiKey');
@ -77,14 +77,14 @@ async function loadConfiguration() {
const providerPoolsFilePathEl = document.getElementById('providerPoolsFilePath');
if (systemPromptFilePathEl) systemPromptFilePathEl.value = data.SYSTEM_PROMPT_FILE_PATH || 'input_system_prompt.txt';
if (systemPromptModeEl) systemPromptModeEl.value = data.SYSTEM_PROMPT_MODE || 'overwrite';
if (systemPromptModeEl) systemPromptModeEl.value = data.SYSTEM_PROMPT_MODE || 'append';
if (promptLogBaseNameEl) promptLogBaseNameEl.value = data.PROMPT_LOG_BASE_NAME || 'prompt_log';
if (promptLogModeEl) promptLogModeEl.value = data.PROMPT_LOG_MODE || 'none';
if (requestMaxRetriesEl) requestMaxRetriesEl.value = data.REQUEST_MAX_RETRIES || 3;
if (requestBaseDelayEl) requestBaseDelayEl.value = data.REQUEST_BASE_DELAY || 1000;
if (cronNearMinutesEl) cronNearMinutesEl.value = data.CRON_NEAR_MINUTES || 1;
if (cronRefreshTokenEl) cronRefreshTokenEl.checked = data.CRON_REFRESH_TOKEN || false;
if (providerPoolsFilePathEl) providerPoolsFilePathEl.value = data.PROVIDER_POOLS_FILE_PATH || 'provider_pools.json';
if (providerPoolsFilePathEl) providerPoolsFilePathEl.value = data.PROVIDER_POOLS_FILE_PATH || '';
// 触发供应商配置显示
handleProviderChange();
@ -106,7 +106,7 @@ async function loadConfiguration() {
}
// 检查并设置供应商池菜单显示状态
const providerPoolsFilePath = data.PROVIDER_POOLS_FILE_PATH || 'provider_pools.json';
const providerPoolsFilePath = data.PROVIDER_POOLS_FILE_PATH;
const providersMenuItem = document.querySelector('.nav-item[data-section="providers"]');
if (providerPoolsFilePath && providerPoolsFilePath.trim() !== '') {
if (providersMenuItem) providersMenuItem.style.display = 'flex';
@ -179,8 +179,8 @@ async function saveConfiguration() {
}
// 保存高级配置参数
config.SYSTEM_PROMPT_FILE_PATH = document.getElementById('systemPromptFilePath')?.value || '';
config.SYSTEM_PROMPT_MODE = document.getElementById('systemPromptMode')?.value || '';
config.SYSTEM_PROMPT_FILE_PATH = document.getElementById('systemPromptFilePath')?.value || 'input_system_prompt.txt';
config.SYSTEM_PROMPT_MODE = document.getElementById('systemPromptMode')?.value || 'append';
config.PROMPT_LOG_BASE_NAME = document.getElementById('promptLogBaseName')?.value || '';
config.PROMPT_LOG_MODE = document.getElementById('promptLogMode')?.value || '';
config.REQUEST_MAX_RETRIES = parseInt(document.getElementById('requestMaxRetries')?.value || 3);
@ -190,18 +190,16 @@ async function saveConfiguration() {
config.PROVIDER_POOLS_FILE_PATH = document.getElementById('providerPoolsFilePath')?.value || '';
try {
const response = await fetch('/api/config', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(config),
});
await window.apiClient.post('/config', config);
if (response.ok) {
showToast('配置已保存', 'success');
} else {
showToast('保存配置失败', 'error');
showToast('配置已保存', 'success');
// 检查当前是否在供应商池管理页面,如果是则刷新数据
const providersSection = document.getElementById('providers');
if (providersSection && providersSection.classList.contains('active')) {
// 当前在供应商池页面,刷新数据
await loadProviders();
showToast('供应商池数据已刷新', 'success');
}
} catch (error) {
console.error('Failed to save configuration:', error);

View file

@ -125,18 +125,8 @@ class FileUploadHandler {
formData.append('provider', this.currentProvider);
formData.append('targetInputId', targetInputId);
// 发送上传请求
const response = await fetch('/api/upload-oauth-credentials', {
method: 'POST',
body: formData
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.error?.message || '上传失败');
}
const result = await response.json();
// 使用封装接口发送上传请求
const result = await window.apiClient.upload('/upload-oauth-credentials', formData);
// 成功上传,设置文件路径到输入框
this.setFilePathToInput(targetInputId, result.filePath);

View file

@ -80,6 +80,10 @@
.config-form {
max-width: 100%;
}
.logout-btn {
line-height: 0;
}
}
/* ========================================
@ -592,6 +596,10 @@
.system-prompt-section textarea {
min-height: 120px;
}
.logout-btn {
line-height: 0;
}
}
/* ========================================
@ -704,6 +712,10 @@
font-size: 0.8125rem;
padding: 0.625rem 0.875rem;
}
.logout-btn {
line-height: 0;
}
}
/* ========================================
@ -735,6 +747,10 @@
.provider-modal-body {
max-height: calc(85vh - 120px);
}
.logout-btn {
line-height: 0;
}
}
/* ========================================
@ -779,6 +795,10 @@
.provider-modal-body {
-webkit-overflow-scrolling: touch;
}
.logout-btn {
line-height: 0;
}
}
/* ========================================

View file

@ -156,13 +156,20 @@ function closeProviderModal(button) {
function renderProviderList(providers) {
return providers.map(provider => {
const isHealthy = provider.isHealthy;
const isDisabled = provider.isDisabled || false;
const lastUsed = provider.lastUsed ? new Date(provider.lastUsed).toLocaleString() : '从未使用';
const healthClass = isHealthy ? 'healthy' : 'unhealthy';
const disabledClass = isDisabled ? 'disabled' : '';
const healthIcon = isHealthy ? 'fas fa-check-circle text-success' : 'fas fa-exclamation-triangle text-warning';
const healthText = isHealthy ? '正常' : '异常';
const disabledText = isDisabled ? '已禁用' : '已启用';
const disabledIcon = isDisabled ? 'fas fa-ban text-muted' : 'fas fa-play text-success';
const toggleButtonText = isDisabled ? '启用' : '禁用';
const toggleButtonIcon = isDisabled ? 'fas fa-play' : 'fas fa-ban';
const toggleButtonClass = isDisabled ? 'btn-success' : 'btn-warning';
return `
<div class="provider-item-detail ${healthClass}" data-uuid="${provider.uuid}">
<div class="provider-item-detail ${healthClass} ${disabledClass}" data-uuid="${provider.uuid}">
<div class="provider-item-header" onclick="window.toggleProviderDetails('${provider.uuid}')">
<div class="provider-info">
<div class="provider-name">${provider.uuid}</div>
@ -171,11 +178,19 @@ function renderProviderList(providers) {
<i class="${healthIcon}"></i>
健康状态: ${healthText}
</span> |
<span class="disabled-status">
<i class="${disabledIcon}"></i>
状态: ${disabledText}
</span> |
使用次数: ${provider.usageCount || 0} |
失败次数: ${provider.errorCount || 0} |
最后使用: ${lastUsed}
</div>
</div>
<div class="provider-actions-group">
<button class="btn-small ${toggleButtonClass}" onclick="window.toggleProviderStatus('${provider.uuid}', event)" title="${toggleButtonText}此供应商">
<i class="${toggleButtonIcon}"></i> ${toggleButtonText}
</button>
<button class="btn-small btn-edit" onclick="window.editProvider('${provider.uuid}', event)">
<i class="fas fa-edit"></i>
</button>
@ -380,7 +395,7 @@ function getFieldOrder(provider) {
const otherFields = Object.keys(provider).filter(key =>
key !== 'isHealthy' && key !== 'lastUsed' && key !== 'usageCount' &&
key !== 'errorCount' && key !== 'lastErrorTime' && key !== 'uuid' &&
!orderedFields.includes(key)
key !== 'isDisabled' && !orderedFields.includes(key)
);
// 按字母顺序排序其他字段
@ -440,9 +455,19 @@ function editProvider(uuid, event) {
select.disabled = false;
});
// 替换编辑按钮为保存和取消按钮
// 替换编辑按钮为保存和取消按钮,但保留禁用/启用按钮
const actionsGroup = providerDetail.querySelector('.provider-actions-group');
const toggleButton = actionsGroup.querySelector('[onclick*="toggleProviderStatus"]');
const currentProvider = providerDetail.closest('.provider-modal').querySelector(`[data-uuid="${uuid}"]`);
const isCurrentlyDisabled = currentProvider.classList.contains('disabled');
const toggleButtonText = isCurrentlyDisabled ? '启用' : '禁用';
const toggleButtonIcon = isCurrentlyDisabled ? 'fas fa-play' : 'fas fa-ban';
const toggleButtonClass = isCurrentlyDisabled ? 'btn-success' : 'btn-warning';
actionsGroup.innerHTML = `
<button class="btn-small ${toggleButtonClass}" onclick="window.toggleProviderStatus('${uuid}', event)" title="${toggleButtonText}此供应商">
<i class="${toggleButtonIcon}"></i> ${toggleButtonText}
</button>
<button class="btn-small btn-save" onclick="window.saveProvider('${uuid}', event)">
<i class="fas fa-save"></i>
</button>
@ -489,9 +514,18 @@ function cancelEdit(uuid, event) {
select.value = originalValue || '';
});
// 恢复原来的编辑和删除按钮
// 恢复原来的编辑和删除按钮,但保留禁用/启用按钮
const actionsGroup = providerDetail.querySelector('.provider-actions-group');
const currentProvider = providerDetail.closest('.provider-modal').querySelector(`[data-uuid="${uuid}"]`);
const isCurrentlyDisabled = currentProvider.classList.contains('disabled');
const toggleButtonText = isCurrentlyDisabled ? '启用' : '禁用';
const toggleButtonIcon = isCurrentlyDisabled ? 'fas fa-play' : 'fas fa-ban';
const toggleButtonClass = isCurrentlyDisabled ? 'btn-success' : 'btn-warning';
actionsGroup.innerHTML = `
<button class="btn-small ${toggleButtonClass}" onclick="window.toggleProviderStatus('${uuid}', event)" title="${toggleButtonText}此供应商">
<i class="${toggleButtonIcon}"></i> ${toggleButtonText}
</button>
<button class="btn-small btn-edit" onclick="window.editProvider('${uuid}', event)">
<i class="fas fa-edit"></i>
</button>
@ -529,22 +563,10 @@ async function saveProvider(uuid, event) {
});
try {
const response = await fetch(`/api/providers/${encodeURIComponent(providerType)}/${uuid}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ providerConfig }),
});
if (response.ok) {
showToast('供应商配置更新成功', 'success');
// 重新获取该供应商类型的最新配置
await refreshProviderConfig(providerType);
} else {
const error = await response.json();
showToast('更新失败: ' + error.error.message, 'error');
}
await window.apiClient.put(`/providers/${encodeURIComponent(providerType)}/${uuid}`, { providerConfig });
showToast('供应商配置更新成功', 'success');
// 重新获取该供应商类型的最新配置
await refreshProviderConfig(providerType);
} catch (error) {
console.error('Failed to update provider:', error);
showToast('更新失败: ' + error.message, 'error');
@ -567,18 +589,10 @@ async function deleteProvider(uuid, event) {
const providerType = providerDetail.closest('.provider-modal').getAttribute('data-provider-type');
try {
const response = await fetch(`/api/providers/${encodeURIComponent(providerType)}/${uuid}`, {
method: 'DELETE',
});
if (response.ok) {
showToast('供应商配置删除成功', 'success');
// 重新获取最新配置
await refreshProviderConfig(providerType);
} else {
const error = await response.json();
showToast('删除失败: ' + error.error.message, 'error');
}
await window.apiClient.delete(`/providers/${encodeURIComponent(providerType)}/${uuid}`);
showToast('供应商配置删除成功', 'success');
// 重新获取最新配置
await refreshProviderConfig(providerType);
} catch (error) {
console.error('Failed to delete provider:', error);
showToast('删除失败: ' + error.message, 'error');
@ -592,29 +606,26 @@ async function deleteProvider(uuid, event) {
async function refreshProviderConfig(providerType) {
try {
// 重新获取该供应商类型的最新数据
const response = await fetch(`/api/providers/${encodeURIComponent(providerType)}`);
if (response.ok) {
const data = await response.json();
const data = await window.apiClient.get(`/providers/${encodeURIComponent(providerType)}`);
// 如果当前显示的是该供应商类型的模态框,则更新模态框
const modal = document.querySelector('.provider-modal');
if (modal && modal.getAttribute('data-provider-type') === providerType) {
// 更新统计信息
const totalCountElement = modal.querySelector('.provider-summary-item .value');
if (totalCountElement) {
totalCountElement.textContent = data.totalCount;
}
// 如果当前显示的是该供应商类型的模态框,则更新模态框
const modal = document.querySelector('.provider-modal');
if (modal && modal.getAttribute('data-provider-type') === providerType) {
// 更新统计信息
const totalCountElement = modal.querySelector('.provider-summary-item .value');
if (totalCountElement) {
totalCountElement.textContent = data.totalCount;
}
const healthyCountElement = modal.querySelectorAll('.provider-summary-item .value')[1];
if (healthyCountElement) {
healthyCountElement.textContent = data.healthyCount;
}
// 重新渲染供应商列表
const providerList = modal.querySelector('.provider-list');
if (providerList) {
providerList.innerHTML = renderProviderList(data.providers);
}
const healthyCountElement = modal.querySelectorAll('.provider-summary-item .value')[1];
if (healthyCountElement) {
healthyCountElement.textContent = data.healthyCount;
}
// 重新渲染供应商列表
const providerList = modal.querySelector('.provider-list');
if (providerList) {
providerList.innerHTML = renderProviderList(data.providers);
}
}
@ -647,7 +658,7 @@ function showAddProviderForm(providerType) {
<h4><i class="fas fa-plus"></i> </h4>
<div class="form-grid">
<div class="form-group">
<label>检查模型名称</label>
<label>检查模型名称 <span class="optional-mark">(选填)</span></label>
<input type="text" id="newCheckModelName" placeholder="例如: gpt-3.5-turbo">
</div>
<div class="form-group">
@ -823,13 +834,8 @@ async function addProvider(providerType) {
const checkModelName = document.getElementById('newCheckModelName')?.value;
const checkHealth = document.getElementById('newCheckHealth')?.value === 'true';
if (!checkModelName) {
showToast('请输入检查模型名称', 'error');
return;
}
const providerConfig = {
checkModelName,
checkModelName: checkModelName || '', // 允许为空
checkHealth
};
@ -860,36 +866,58 @@ async function addProvider(providerType) {
}
try {
const response = await fetch('/api/providers', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
providerType,
providerConfig
}),
await window.apiClient.post('/providers', {
providerType,
providerConfig
});
if (response.ok) {
showToast('供应商配置添加成功', 'success');
// 移除添加表单
const form = document.querySelector('.add-provider-form');
if (form) {
form.remove();
}
// 重新获取最新配置数据
await refreshProviderConfig(providerType);
} else {
const error = await response.json();
showToast('添加失败: ' + error.error.message, 'error');
showToast('供应商配置添加成功', 'success');
// 移除添加表单
const form = document.querySelector('.add-provider-form');
if (form) {
form.remove();
}
// 重新获取最新配置数据
await refreshProviderConfig(providerType);
} catch (error) {
console.error('Failed to add provider:', error);
showToast('添加失败: ' + error.message, 'error');
}
}
/**
* 切换供应商禁用/启用状态
* @param {string} uuid - 供应商UUID
* @param {Event} event - 事件对象
*/
async function toggleProviderStatus(uuid, event) {
event.stopPropagation();
const providerDetail = event.target.closest('.provider-item-detail');
const providerType = providerDetail.closest('.provider-modal').getAttribute('data-provider-type');
const currentProvider = providerDetail.closest('.provider-modal').querySelector(`[data-uuid="${uuid}"]`);
// 获取当前供应商信息
const isCurrentlyDisabled = currentProvider.classList.contains('disabled');
const action = isCurrentlyDisabled ? 'enable' : 'disable';
const confirmMessage = isCurrentlyDisabled ?
`确定要启用这个供应商配置吗?` :
`确定要禁用这个供应商配置吗?禁用后该供应商将不会被选中使用。`;
if (!confirm(confirmMessage)) {
return;
}
try {
await window.apiClient.post(`/providers/${encodeURIComponent(providerType)}/${uuid}/${action}`, { action });
showToast(`供应商${isCurrentlyDisabled ? '启用' : '禁用'}成功`, 'success');
// 重新获取该供应商类型的最新配置
await refreshProviderConfig(providerType);
} catch (error) {
console.error('Failed to toggle provider status:', error);
showToast(`操作失败: ${error.message}`, 'error');
}
}
// 导出所有函数并挂载到window对象供HTML调用
export {
showProviderManagerModal,
@ -901,7 +929,8 @@ export {
deleteProvider,
refreshProviderConfig,
showAddProviderForm,
addProvider
addProvider,
toggleProviderStatus
};
// 将函数挂载到window对象
@ -912,4 +941,5 @@ window.cancelEdit = cancelEdit;
window.saveProvider = saveProvider;
window.deleteProvider = deleteProvider;
window.showAddProviderForm = showAddProviderForm;
window.addProvider = addProvider;
window.addProvider = addProvider;
window.toggleProviderStatus = toggleProviderStatus;

View file

@ -13,8 +13,7 @@ let initialLoadTime = null;
*/
async function loadSystemInfo() {
try {
const response = await fetch('/api/system');
const data = await response.json();
const data = await window.apiClient.get('/system');
const nodeVersionEl = document.getElementById('nodeVersion');
const serverTimeEl = document.getElementById('serverTime');
@ -80,8 +79,7 @@ function updateTimeDisplay() {
*/
async function loadProviders() {
try {
const response = await fetch('/api/providers');
const data = await response.json();
const data = await window.apiClient.get('/providers');
renderProviders(data);
} catch (error) {
console.error('Failed to load providers:', error);
@ -102,112 +100,123 @@ function renderProviders(providers) {
const hasProviders = Object.keys(providers).length > 0;
const statsGrid = document.querySelector('#providers .stats-grid');
// 始终显示统计卡片
if (statsGrid) statsGrid.style.display = 'grid';
// 定义所有支持的提供商显示顺序
const providerDisplayOrder = [
'gemini-cli-oauth',
'openai-custom',
'claude-custom',
'claude-kiro-oauth',
'openai-qwen-oauth',
'openaiResponses-custom'
];
// 获取所有提供商类型并按指定顺序排序
// 优先显示预定义的所有供应商类型,即使某些供应商没有数据也要显示
let allProviderTypes;
if (hasProviders) {
// 显示统计卡片
if (statsGrid) statsGrid.style.display = 'grid';
// 计算总统计
let totalAccounts = 0;
let totalHealthy = 0;
// 定义提供商显示顺序
const providerDisplayOrder = [
'gemini-cli-oauth',
'openai-custom',
'claude-custom',
'claude-kiro-oauth',
'openai-qwen-oauth',
'openaiResponses-custom'
];
// 获取所有提供商类型并按指定顺序排序
const allProviderTypes = Object.keys(providers);
const sortedProviderTypes = providerDisplayOrder.filter(type => allProviderTypes.includes(type))
.concat(allProviderTypes.filter(type => !providerDisplayOrder.includes(type)));
// 按照排序后的提供商类型渲染
sortedProviderTypes.forEach((providerType) => {
const accounts = providers[providerType];
const providerDiv = document.createElement('div');
providerDiv.className = 'provider-item';
providerDiv.dataset.providerType = providerType;
providerDiv.style.cursor = 'pointer';
const healthyCount = accounts.filter(acc => acc.isHealthy).length;
const totalCount = accounts.length;
const usageCount = accounts.reduce((sum, acc) => sum + (acc.usageCount || 0), 0);
const errorCount = accounts.reduce((sum, acc) => sum + (acc.errorCount || 0), 0);
totalAccounts += totalCount;
totalHealthy += healthyCount;
// 更新全局统计变量
if (!providerStats.providerTypeStats[providerType]) {
providerStats.providerTypeStats[providerType] = {
totalAccounts: 0,
healthyAccounts: 0,
totalUsage: 0,
totalErrors: 0,
lastUpdate: null
};
}
const typeStats = providerStats.providerTypeStats[providerType];
typeStats.totalAccounts = totalCount;
typeStats.healthyAccounts = healthyCount;
typeStats.totalUsage = usageCount;
typeStats.totalErrors = errorCount;
typeStats.lastUpdate = new Date().toISOString();
providerDiv.innerHTML = `
<div class="provider-header">
<div class="provider-name">
<span class="provider-type-text">${providerType}</span>
</div>
<div class="provider-status status-${healthyCount === totalCount ? 'healthy' : 'unhealthy'}">
<i class="fas fa-${healthyCount === totalCount ? 'check-circle' : 'exclamation-triangle'}"></i>
<span>${healthyCount}/${totalCount} 健康</span>
</div>
</div>
<div class="provider-stats">
<div class="provider-stat">
<span class="provider-stat-label">总账户</span>
<span class="provider-stat-value">${totalCount}</span>
</div>
<div class="provider-stat">
<span class="provider-stat-label">健康账户</span>
<span class="provider-stat-value">${healthyCount}</span>
</div>
<div class="provider-stat">
<span class="provider-stat-label">使用次数</span>
<span class="provider-stat-value">${usageCount}</span>
</div>
<div class="provider-stat">
<span class="provider-stat-label">错误次数</span>
<span class="provider-stat-value">${errorCount}</span>
</div>
</div>
`;
// 添加点击事件 - 整个供应商组都可以点击
providerDiv.addEventListener('click', (e) => {
e.preventDefault();
openProviderManager(providerType);
});
container.appendChild(providerDiv);
});
// 更新统计卡片数据
const activeProviders = Object.keys(providers).length;
updateProviderStatsDisplay(activeProviders, totalHealthy, totalAccounts);
// 合并预定义类型和实际存在的类型,确保显示所有预定义供应商
const actualProviderTypes = Object.keys(providers);
allProviderTypes = [...new Set([...providerDisplayOrder, ...actualProviderTypes])];
} else {
// 隐藏统计卡片
if (statsGrid) statsGrid.style.display = 'none';
// 显示无数据提示
container.innerHTML = '<div class="no-providers"><p>暂无供应商池配置</p></div>';
allProviderTypes = providerDisplayOrder;
}
const sortedProviderTypes = providerDisplayOrder.filter(type => allProviderTypes.includes(type))
.concat(allProviderTypes.filter(type => !providerDisplayOrder.includes(type)));
// 计算总统计
let totalAccounts = 0;
let totalHealthy = 0;
// 按照排序后的提供商类型渲染
sortedProviderTypes.forEach((providerType) => {
const accounts = hasProviders ? providers[providerType] || [] : [];
const providerDiv = document.createElement('div');
providerDiv.className = 'provider-item';
providerDiv.dataset.providerType = providerType;
providerDiv.style.cursor = 'pointer';
const healthyCount = accounts.filter(acc => acc.isHealthy).length;
const totalCount = accounts.length;
const usageCount = accounts.reduce((sum, acc) => sum + (acc.usageCount || 0), 0);
const errorCount = accounts.reduce((sum, acc) => sum + (acc.errorCount || 0), 0);
totalAccounts += totalCount;
totalHealthy += healthyCount;
// 更新全局统计变量
if (!providerStats.providerTypeStats[providerType]) {
providerStats.providerTypeStats[providerType] = {
totalAccounts: 0,
healthyAccounts: 0,
totalUsage: 0,
totalErrors: 0,
lastUpdate: null
};
}
const typeStats = providerStats.providerTypeStats[providerType];
typeStats.totalAccounts = totalCount;
typeStats.healthyAccounts = healthyCount;
typeStats.totalUsage = usageCount;
typeStats.totalErrors = errorCount;
typeStats.lastUpdate = new Date().toISOString();
// 为无数据状态设置特殊样式
const isEmptyState = !hasProviders || totalCount === 0;
const statusClass = isEmptyState ? 'status-empty' : (healthyCount === totalCount ? 'status-healthy' : 'status-unhealthy');
const statusIcon = isEmptyState ? 'fa-info-circle' : (healthyCount === totalCount ? 'fa-check-circle' : 'fa-exclamation-triangle');
const statusText = isEmptyState ? '0/0 节点' : `${healthyCount}/${totalCount} 健康`;
providerDiv.innerHTML = `
<div class="provider-header">
<div class="provider-name">
<span class="provider-type-text">${providerType}</span>
</div>
<div class="provider-status ${statusClass}">
<i class="fas fa-${statusIcon}"></i>
<span>${statusText}</span>
</div>
</div>
<div class="provider-stats">
<div class="provider-stat">
<span class="provider-stat-label">总账户</span>
<span class="provider-stat-value">${totalCount}</span>
</div>
<div class="provider-stat">
<span class="provider-stat-label">健康账户</span>
<span class="provider-stat-value">${healthyCount}</span>
</div>
<div class="provider-stat">
<span class="provider-stat-label">使用次数</span>
<span class="provider-stat-value">${usageCount}</span>
</div>
<div class="provider-stat">
<span class="provider-stat-label">错误次数</span>
<span class="provider-stat-value">${errorCount}</span>
</div>
</div>
`;
// 如果是空状态,添加特殊样式
if (isEmptyState) {
providerDiv.classList.add('empty-provider');
}
// 添加点击事件 - 整个供应商组都可以点击
providerDiv.addEventListener('click', (e) => {
e.preventDefault();
openProviderManager(providerType);
});
container.appendChild(providerDiv);
});
// 更新统计卡片数据
const activeProviders = hasProviders ? Object.keys(providers).length : 0;
updateProviderStatsDisplay(activeProviders, totalHealthy, totalAccounts);
}
/**
@ -282,8 +291,7 @@ function updateProviderStatsDisplay(activeProviders, healthyProviders, totalAcco
*/
async function openProviderManager(providerType) {
try {
const response = await fetch(`/api/providers/${encodeURIComponent(providerType)}`);
const data = await response.json();
const data = await window.apiClient.get(`/providers/${encodeURIComponent(providerType)}`);
showProviderManagerModal(data);
} catch (error) {

View file

@ -1,7 +1,7 @@
/* CSS变量 */
:root {
--primary-color: #4f46e5;
--secondary-color: #818cf8;
--primary-color: #059669;
--secondary-color: #10b981;
--success-color: #10b981;
--danger-color: #ef4444;
--warning-color: #f59e0b;
@ -92,6 +92,33 @@ body {
.status-badge.error i {
color: var(--danger-color);
}
.logout-btn {
padding: 8px 16px;
background: transparent;
color: var(--text-secondary);
border: 1px solid var(--border-color);
border-radius: 6px;
cursor: pointer;
font-size: 14px;
font-weight: 500;
transition: all 0.3s ease;
display: inline-flex;
align-items: center;
gap: 6px;
}
.logout-btn:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(5, 150, 105, 0.4);
}
.logout-btn:active {
transform: translateY(0);
}
.logout-btn i {
font-size: 14px;
}
@keyframes pulse {
0%, 100% {
@ -255,6 +282,13 @@ body {
color: var(--text-primary);
}
.form-group label .optional-mark {
font-size: 0.75rem;
color: var(--text-secondary);
font-weight: 400;
margin-left: 0.25rem;
}
.form-control {
width: 100%;
padding: 0.75rem;
@ -268,7 +302,7 @@ body {
.form-control:focus {
outline: none;
border-color: var(--primary-color);
box-shadow: 0 0 0 3px rgba(79, 70, 229, 0.1);
box-shadow: 0 0 0 3px rgba(5, 150, 105, 0.1);
}
textarea.form-control {
@ -504,13 +538,33 @@ textarea.form-control {
}
.btn-primary {
background: var(--primary-color);
color: white;
line-height: 0;
padding: 8px 16px;
background: transparent;
color: var(--text-secondary);
border: 1px solid var(--border-color);
border-radius: 6px;
cursor: pointer;
font-size: 14px;
font-weight: 500;
transition: all 0.3s ease;
display: inline-flex;
align-items: center;
gap: 6px;
}
.btn-primary:hover {
background: #4338ca;
transform: translateY(-2px);
background: var(--bg-tertiary);
color: var(--primary-color);
border-color: var(--primary-color);
}
.btn-primary:active {
transform: translateY(0);
}
.btn-primary i {
font-size: 14px;
}
.btn-success {
@ -537,7 +591,7 @@ textarea.form-control {
}
.btn-danger:hover {
background: #dc2626;
background: #ef4444;
}
/* 提供商列表 */
@ -1341,14 +1395,14 @@ input:checked + .toggle-slider:before {
}
.btn-edit {
background: linear-gradient(135deg, #007bff 0%, #6f42c1 100%);
background: linear-gradient(135deg, #059669 0%, #10b981 100%);
color: white;
box-shadow: 0 2px 8px rgba(0, 123, 255, 0.3);
box-shadow: 0 2px 8px rgba(5, 150, 105, 0.3);
}
.btn-edit:hover {
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(0, 123, 255, 0.4);
box-shadow: 0 4px 12px rgba(5, 150, 105, 0.4);
}
.btn-delete {
@ -1417,8 +1471,8 @@ input:checked + .toggle-slider:before {
.config-item input:focus, .config-item textarea:focus, .config-item select:focus {
outline: none;
border-color: #007bff;
box-shadow: 0 0 0 3px rgba(0, 123, 255, 0.1);
border-color: var(--primary-color);
box-shadow: 0 0 0 3px rgba(5, 150, 105, 0.1);
}
.config-item input[readonly], .config-item select[disabled] {
@ -1486,8 +1540,8 @@ input:checked + .toggle-slider:before {
.form-group input:focus, .form-group select:focus {
outline: none;
border-color: #007bff;
box-shadow: 0 0 0 3px rgba(0, 123, 255, 0.1);
border-color: var(--primary-color);
box-shadow: 0 0 0 3px rgba(5, 150, 105, 0.1);
}
/* 无提供商提示 */
@ -1704,7 +1758,7 @@ input:checked + .toggle-slider:before {
}
.search-input-group .btn:hover {
background: #4338ca;
background: #047857;
transform: translateY(-1px);
}
@ -1763,18 +1817,18 @@ input:checked + .toggle-slider:before {
overflow-y: auto;
}
.config-item {
.config-item-manager {
padding: 1.5rem;
border-bottom: 1px solid var(--border-color);
transition: var(--transition);
cursor: pointer;
}
.config-item:hover {
.config-item-manager:hover {
background: var(--bg-secondary);
}
.config-item:last-child {
.config-item-manager:last-child {
border-bottom: none;
}
@ -1843,17 +1897,17 @@ input:checked + .toggle-slider:before {
font-weight: 500;
}
.config-item.used .config-item-status {
.config-item-manager.used .config-item-status {
background: #d1fae5;
color: #065f46;
}
.config-item.unused .config-item-status {
.config-item-manager.unused .config-item-status {
background: #fef3c7;
color: #92400e;
}
.config-item.invalid .config-item-status {
.config-item-manager.invalid .config-item-status {
background: #fee2e2;
color: #991b1b;
}
@ -1865,7 +1919,7 @@ input:checked + .toggle-slider:before {
display: none;
}
.config-item.expanded .config-item-details {
.config-item-manager.expanded .config-item-details {
display: block;
}
@ -1923,7 +1977,7 @@ input:checked + .toggle-slider:before {
}
.btn-view:hover {
background: #4338ca;
background: #047857;
}
.btn-delete-small {
@ -1932,14 +1986,14 @@ input:checked + .toggle-slider:before {
}
.btn-delete-small:hover {
background: #dc2626;
background: #ef4444;
}
.config-item.expanded {
.config-item-manager.expanded {
background: var(--bg-secondary);
}
.config-item.expanded:hover {
.config-item-manager.expanded:hover {
background: #e5e7eb;
}
@ -2218,19 +2272,19 @@ input:checked + .toggle-slider:before {
}
/* 针对不同使用类型的特殊样式 */
.config-item.used .config-usage-info {
.config-item-manager.used .config-usage-info {
border-left-color: var(--success-color);
}
.config-item.used .config-usage-info .usage-info-header i {
.config-item-manager.used .config-usage-info .usage-info-header i {
color: var(--success-color);
}
.config-item.used .usage-detail-item i {
.config-item-manager.used .usage-detail-item i {
color: var(--success-color);
}
.config-item.used .usage-detail-type {
.config-item-manager.used .usage-detail-type {
background: var(--success-color);
}
@ -2363,7 +2417,7 @@ input:checked + .toggle-slider:before {
}
.delete-confirm-modal.used .delete-modal-header h3 {
color: #dc2626;
color: #ef4444;
}
.delete-confirm-modal.unused .delete-modal-header h3 {
@ -2390,7 +2444,7 @@ input:checked + .toggle-slider:before {
.delete-warning.warning-used {
background: linear-gradient(135deg, #fef2f2 0%, #ffffff 100%);
border-color: #fca5a5;
color: #dc2626;
color: #ef4444;
}
.delete-warning.warning-unused {
@ -2486,7 +2540,7 @@ input:checked + .toggle-slider:before {
.alert-icon {
font-size: 1.25rem;
color: #dc2626;
color: #ef4444;
margin-top: 0.125rem;
flex-shrink: 0;
}
@ -2495,7 +2549,7 @@ input:checked + .toggle-slider:before {
margin: 0 0 0.75rem 0;
font-size: 0.875rem;
font-weight: 600;
color: #dc2626;
color: #ef4444;
}
.alert-content p {
@ -2517,7 +2571,7 @@ input:checked + .toggle-slider:before {
}
.alert-content strong {
color: #dc2626;
color: #ef4444;
font-weight: 600;
}
@ -2557,9 +2611,9 @@ input:checked + .toggle-slider:before {
}
.delete-confirm-modal.used .btn-confirm-delete {
background: linear-gradient(135deg, #dc2626 0%, #b91c1c 100%);
background: linear-gradient(135deg, #f59e0b 0%, #d97706 100%);
color: white;
box-shadow: 0 4px 15px rgba(220, 38, 38, 0.4);
box-shadow: 0 4px 15px rgba(245, 158, 11, 0.4);
animation: pulseDanger 2s infinite;
}
@ -2570,9 +2624,9 @@ input:checked + .toggle-slider:before {
}
.delete-confirm-modal.used .btn-confirm-delete:hover {
background: linear-gradient(135deg, #b91c1c 0%, #991b1b 100%);
background: linear-gradient(135deg, #d97706 0%, #b45309 100%);
transform: translateY(-2px);
box-shadow: 0 6px 20px rgba(220, 38, 38, 0.5);
box-shadow: 0 6px 20px rgba(245, 158, 11, 0.5);
}
.delete-confirm-modal.unused .btn-confirm-delete:hover {
@ -2583,10 +2637,10 @@ input:checked + .toggle-slider:before {
@keyframes pulseDanger {
0%, 100% {
box-shadow: 0 4px 15px rgba(220, 38, 38, 0.4);
box-shadow: 0 4px 15px rgba(239, 68, 68, 0.4);
}
50% {
box-shadow: 0 4px 15px rgba(220, 38, 38, 0.7);
box-shadow: 0 4px 15px rgba(239, 68, 68, 0.7);
}
}
@ -2645,3 +2699,144 @@ input:checked + .toggle-slider:before {
gap: 0.75rem;
}
}
/* 禁用供应商状态样式 */
.provider-item-detail.disabled {
opacity: 0.6;
background: linear-gradient(135deg, #f8f9fa 0%, #ffffff 100%);
border: 1px solid #dee2e6;
position: relative;
}
.provider-item-detail.disabled::before {
content: '';
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: 4px;
background: linear-gradient(135deg, #6c757d 0%, #495057 100%);
border-radius: 0 2px 2px 0;
z-index: 1;
}
.provider-item-detail.disabled:hover {
opacity: 0.8;
border-color: #adb5bd;
box-shadow: 0 2px 8px rgba(108, 117, 125, 0.15);
transform: none;
}
.provider-item-detail.disabled .provider-item-header {
background: linear-gradient(135deg, #f1f3f4 0%, #ffffff 100%);
}
.provider-item-detail.disabled .provider-name {
color: #6c757d;
text-decoration: line-through;
}
.provider-item-detail.disabled .provider-meta {
color: #9ca3af;
}
.disabled-status {
display: inline-flex;
align-items: center;
gap: 0.5rem;
font-weight: 500;
padding: 0.25rem 0.5rem;
border-radius: 6px;
transition: var(--transition);
}
.provider-item-detail.disabled .disabled-status {
color: #6c757d;
background: rgba(108, 117, 125, 0.1);
}
.provider-item-detail:not(.disabled) .disabled-status {
color: #059669;
background: rgba(16, 185, 129, 0.1);
}
/* 禁用/启用按钮特殊样式 */
.btn-warning {
background: linear-gradient(135deg, #f59e0b 0%, #d97706 100%);
color: white;
box-shadow: 0 2px 8px rgba(245, 158, 11, 0.3);
}
.btn-warning:hover {
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(245, 158, 11, 0.4);
}
.btn-warning:active {
transform: translateY(0);
}
/* 供应商状态指示器 */
.provider-status .disabled-indicator {
position: relative;
}
.provider-status .disabled-indicator::after {
content: '';
position: absolute;
top: -2px;
right: -2px;
width: 8px;
height: 8px;
background: #6c757d;
border: 2px solid white;
border-radius: 50%;
}
.provider-status .enabled-indicator::after {
background: var(--success-color);
}
/* 禁用状态下的交互效果 */
.provider-item-detail.disabled .provider-actions-group .btn-edit,
.provider-item-detail.disabled .provider-actions-group .btn-delete {
opacity: 0.5;
pointer-events: none;
}
.provider-item-detail.disabled .provider-actions-group .btn-toggle {
opacity: 1;
}
/* 统计信息中的禁用状态显示 */
.provider-stat .disabled-count {
color: #6c757d;
font-style: italic;
}
.provider-stat .enabled-count {
color: var(--success-color);
font-weight: 600;
}
/* 响应式调整 */
@media (max-width: 768px) {
.provider-item-detail.disabled {
opacity: 0.5;
}
.disabled-status {
font-size: 0.75rem;
padding: 0.2rem 0.4rem;
}
.provider-actions-group {
flex-wrap: wrap;
gap: 0.5rem;
}
.btn-small {
font-size: 0.7rem;
padding: 0.4rem 0.6rem;
}
}

View file

@ -4,6 +4,7 @@ import { showToast } from './utils.js';
let allConfigs = []; // 存储所有配置数据
let filteredConfigs = []; // 存储过滤后的配置数据
let isLoadingConfigs = false; // 防止重复加载配置
/**
* 搜索配置
@ -64,7 +65,7 @@ function createConfigItemElement(config, index) {
// 从布尔值 isUsed 转换为状态字符串用于显示
const configStatus = config.isUsed ? 'used' : 'unused';
const item = document.createElement('div');
item.className = `config-item ${configStatus}`;
item.className = `config-item-manager ${configStatus}`;
item.dataset.index = index;
const statusIcon = config.isUsed ? 'fa-check-circle' : 'fa-circle';
@ -250,17 +251,23 @@ function updateStats() {
* 加载配置文件列表
*/
async function loadConfigList() {
// 防止重复加载
if (isLoadingConfigs) {
console.log('正在加载配置列表,跳过重复调用');
return;
}
isLoadingConfigs = true;
console.log('开始加载配置列表...');
try {
const response = await fetch('/api/upload-configs');
if (response.ok) {
allConfigs = await response.json();
filteredConfigs = [...allConfigs];
renderConfigList();
updateStats();
// showToast('配置文件列表已刷新', 'success');
} else {
throw new Error('获取配置文件列表失败');
}
const result = await window.apiClient.get('/upload-configs');
allConfigs = result;
filteredConfigs = [...allConfigs];
renderConfigList();
updateStats();
console.log('配置列表加载成功,共', allConfigs.length, '个项目');
// showToast('配置文件列表已刷新', 'success');
} catch (error) {
console.error('加载配置列表失败:', error);
showToast('加载配置列表失败: ' + error.message, 'error');
@ -270,6 +277,9 @@ async function loadConfigList() {
filteredConfigs = [...allConfigs];
renderConfigList();
updateStats();
} finally {
isLoadingConfigs = false;
console.log('配置列表加载完成');
}
}
@ -343,14 +353,8 @@ function generateMockConfigData() {
*/
async function viewConfig(path) {
try {
const response = await fetch(`/api/upload-configs/view/${encodeURIComponent(path)}`);
if (response.ok) {
const fileData = await response.json();
showConfigModal(fileData);
} else {
const error = await response.json();
showToast('查看配置失败: ' + error.error.message, 'error');
}
const fileData = await window.apiClient.get(`/upload-configs/view/${encodeURIComponent(path)}`);
showConfigModal(fileData);
} catch (error) {
console.error('查看配置失败:', error);
showToast('查看配置失败: ' + error.message, 'error');
@ -450,39 +454,34 @@ function closeConfigModal() {
*/
async function copyConfigContent(path) {
try {
const response = await fetch(`/api/upload-configs/view/${encodeURIComponent(path)}`);
if (response.ok) {
const fileData = await response.json();
// 尝试使用现代 Clipboard API
if (navigator.clipboard && navigator.clipboard.writeText) {
await navigator.clipboard.writeText(fileData.content);
showToast('内容已复制到剪贴板', 'success');
} else {
// 降级方案:使用传统的 document.execCommand
const textarea = document.createElement('textarea');
textarea.value = fileData.content;
textarea.style.position = 'fixed';
textarea.style.opacity = '0';
document.body.appendChild(textarea);
textarea.select();
try {
const successful = document.execCommand('copy');
if (successful) {
showToast('内容已复制到剪贴板', 'success');
} else {
showToast('复制失败,请手动复制', 'error');
}
} catch (err) {
console.error('复制失败:', err);
showToast('复制失败,请手动复制', 'error');
} finally {
document.body.removeChild(textarea);
}
}
const fileData = await window.apiClient.get(`/upload-configs/view/${encodeURIComponent(path)}`);
// 尝试使用现代 Clipboard API
if (navigator.clipboard && navigator.clipboard.writeText) {
await navigator.clipboard.writeText(fileData.content);
showToast('内容已复制到剪贴板', 'success');
} else {
showToast('获取配置内容失败', 'error');
// 降级方案:使用传统的 document.execCommand
const textarea = document.createElement('textarea');
textarea.value = fileData.content;
textarea.style.position = 'fixed';
textarea.style.opacity = '0';
document.body.appendChild(textarea);
textarea.select();
try {
const successful = document.execCommand('copy');
if (successful) {
showToast('内容已复制到剪贴板', 'success');
} else {
showToast('复制失败,请手动复制', 'error');
}
} catch (err) {
console.error('复制失败:', err);
showToast('复制失败,请手动复制', 'error');
} finally {
document.body.removeChild(textarea);
}
}
} catch (error) {
console.error('复制失败:', error);
@ -640,23 +639,14 @@ function showDeleteConfirmModal(config) {
*/
async function performDelete(path) {
try {
const response = await fetch(`/api/upload-configs/delete/${encodeURIComponent(path)}`, {
method: 'DELETE'
});
const result = await window.apiClient.delete(`/upload-configs/delete/${encodeURIComponent(path)}`);
showToast(result.message, 'success');
if (response.ok) {
const result = await response.json();
showToast(result.message, 'success');
// 从本地列表中移除
allConfigs = allConfigs.filter(c => c.path !== path);
filteredConfigs = filteredConfigs.filter(c => c.path !== path);
renderConfigList();
updateStats();
} else {
const error = await response.json();
showToast('删除失败: ' + error.error.message, 'error');
}
// 从本地列表中移除
allConfigs = allConfigs.filter(c => c.path !== path);
filteredConfigs = filteredConfigs.filter(c => c.path !== path);
renderConfigList();
updateStats();
} catch (error) {
console.error('删除配置失败:', error);
showToast('删除配置失败: ' + error.message, 'error');
@ -724,30 +714,24 @@ function initUploadConfigManager() {
* 重新加载配置文件
*/
async function reloadConfig() {
try {
const response = await fetch('/api/reload-config', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
}
});
// 防止重复重载
if (isLoadingConfigs) {
console.log('正在重载配置,跳过重复调用');
return;
}
if (response.ok) {
const result = await response.json();
showToast(result.message, 'success');
// 重新加载配置列表以反映最新的关联状态
await loadConfigList();
// 发送自定义事件通知其他组件配置已重新加载
window.dispatchEvent(new CustomEvent('configReloaded', {
detail: result.details
}));
} else {
const error = await response.json();
throw new Error(error.error?.message || '重载配置失败');
}
try {
const result = await window.apiClient.post('/reload-config');
showToast(result.message, 'success');
// 重新加载配置列表以反映最新的关联状态
await loadConfigList();
// 注意:不再发送 configReloaded 事件,避免重复调用
// window.dispatchEvent(new CustomEvent('configReloaded', {
// detail: result.details
// }));
} catch (error) {
console.error('重载配置失败:', error);
showToast('重载配置失败: ' + error.message, 'error');

View file

@ -54,7 +54,7 @@ function showToast(message, type = 'info') {
*/
function getFieldLabel(key) {
const labelMap = {
'checkModelName': '检查模型名称',
'checkModelName': '检查模型名称 (选填)',
'checkHealth': '健康检查',
'OPENAI_API_KEY': 'OpenAI API Key',
'OPENAI_BASE_URL': 'OpenAI Base URL',

BIN
static/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

View file

@ -3,7 +3,7 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=5.0, user-scalable=yes">
<meta name="theme-color" content="#4f46e5">
<meta name="theme-color" content="#059669">
<meta name="description" content="AIClient2API 管理控制台 - 统一管理AI服务提供商">
<title>AIClient2API - 管理控制台</title>
<link rel="stylesheet" href="app/styles.css">
@ -20,7 +20,11 @@
<span class="status-badge" id="serverStatus">
<i class="fas fa-circle"></i> <span class="status-text">连接中...</span>
</span>
<button class="btn btn-primary" id="refreshBtn" aria-label="刷新数据">
<button id="logoutBtn" class="logout-btn" title="登出">
<i class="fas fa-sign-out-alt"></i> 登出
</button>
</span>
<button id="refreshBtn" class="logout-btn" aria-label="刷新数据">
<i class="fas fa-sync-alt"></i> <span class="btn-text">重载</span>
</button>
</div>
@ -115,9 +119,6 @@
<div class="endpoint-info">
<label>端点路径:</label>
<code class="endpoint-path">/gemini-cli-oauth/v1/chat/completions</code>
<button class="copy-btn" data-path="/gemini-cli-oauth/v1/chat/completions">
<i class="fas fa-copy"></i>
</button>
</div>
<div class="usage-example">
<label>使用示例 (OpenAI格式):</label>
@ -137,9 +138,6 @@
<div class="endpoint-info">
<label>端点路径:</label>
<code class="endpoint-path">/gemini-cli-oauth/v1/messages</code>
<button class="copy-btn" data-path="/gemini-cli-oauth/v1/messages">
<i class="fas fa-copy"></i>
</button>
</div>
<div class="usage-example">
<label>使用示例 (Claude格式):</label>
@ -174,9 +172,6 @@
<div class="endpoint-info">
<label>端点路径:</label>
<code class="endpoint-path">/openai-qwen-oauth/v1/chat/completions</code>
<button class="copy-btn" data-path="/openai-qwen-oauth/v1/chat/completions">
<i class="fas fa-copy"></i>
</button>
</div>
<div class="usage-example">
<label>使用示例 (OpenAI格式):</label>
@ -196,9 +191,6 @@
<div class="endpoint-info">
<label>端点路径:</label>
<code class="endpoint-path">/openai-qwen-oauth/v1/messages</code>
<button class="copy-btn" data-path="/openai-qwen-oauth/v1/messages">
<i class="fas fa-copy"></i>
</button>
</div>
<div class="usage-example">
<label>使用示例 (Claude格式):</label>
@ -233,9 +225,6 @@
<div class="endpoint-info">
<label>端点路径:</label>
<code class="endpoint-path">/claude-custom/v1/chat/completions</code>
<button class="copy-btn" data-path="/claude-custom/v1/chat/completions">
<i class="fas fa-copy"></i>
</button>
</div>
<div class="usage-example">
<label>使用示例 (OpenAI格式):</label>
@ -255,9 +244,6 @@
<div class="endpoint-info">
<label>端点路径:</label>
<code class="endpoint-path">/claude-custom/v1/messages</code>
<button class="copy-btn" data-path="/claude-custom/v1/messages">
<i class="fas fa-copy"></i>
</button>
</div>
<div class="usage-example">
<label>使用示例 (Claude格式):</label>
@ -292,9 +278,6 @@
<div class="endpoint-info">
<label>端点路径:</label>
<code class="endpoint-path">/claude-kiro-oauth/v1/chat/completions</code>
<button class="copy-btn" data-path="/claude-kiro-oauth/v1/chat/completions">
<i class="fas fa-copy"></i>
</button>
</div>
<div class="usage-example">
<label>使用示例 (OpenAI格式):</label>
@ -314,9 +297,6 @@
<div class="endpoint-info">
<label>端点路径:</label>
<code class="endpoint-path">/claude-kiro-oauth/v1/messages</code>
<button class="copy-btn" data-path="/claude-kiro-oauth/v1/messages">
<i class="fas fa-copy"></i>
</button>
</div>
<div class="usage-example">
<label>使用示例 (Claude格式):</label>
@ -351,9 +331,6 @@
<div class="endpoint-info">
<label>端点路径:</label>
<code class="endpoint-path">/openai-custom/v1/chat/completions</code>
<button class="copy-btn" data-path="/openai-custom/v1/chat/completions">
<i class="fas fa-copy"></i>
</button>
</div>
<div class="usage-example">
<label>使用示例 (OpenAI格式):</label>
@ -373,9 +350,6 @@
<div class="endpoint-info">
<label>端点路径:</label>
<code class="endpoint-path">/openai-custom/v1/messages</code>
<button class="copy-btn" data-path="/openai-custom/v1/messages">
<i class="fas fa-copy"></i>
</button>
</div>
<div class="usage-example">
<label>使用示例 (Claude格式):</label>
@ -576,13 +550,13 @@
<div class="config-row">
<div class="form-group">
<label for="systemPromptFilePath">系统提示文件路径</label>
<input type="text" id="systemPromptFilePath" class="form-control" placeholder="例如: input_system_prompt.txt">
<input type="text" id="systemPromptFilePath" class="form-control" value="input_system_prompt.txt" placeholder="例如: input_system_prompt.txt">
</div>
<div class="form-group">
<label for="systemPromptMode">系统提示模式</label>
<select id="systemPromptMode" class="form-control">
<option value="append" selected>追加 (append)</option>
<option value="overwrite">覆盖 (overwrite)</option>
<option value="append">追加 (append)</option>
</select>
</div>
</div>
@ -629,7 +603,7 @@
<div class="form-group pool-section">
<label for="providerPoolsFilePath">供应商池配置文件路径</label>
<input type="text" id="providerPoolsFilePath" class="form-control" placeholder="例如: provider_pools.json">
<input type="text" id="providerPoolsFilePath" class="form-control" value="" placeholder="例如: provider_pools.json">
<small class="form-text">配置了供应商池后,可在供应商池管理中查看详细信息</small>
</div>
@ -768,6 +742,30 @@
<div id="toastContainer" class="toast-container"></div>
<!-- Scripts -->
<script src="app/auth.js"></script>
<script>
// 页面加载时检查登录状态
(async function() {
const isAuthenticated = await initAuth();
if (!isAuthenticated) {
// 如果未认证initAuth会自动重定向到登录页面
return;
}
// 认证成功,继续加载页面
console.log('用户已认证');
// 显示登出按钮(如果配置了密码保护)
const logoutBtn = document.getElementById('logoutBtn');
if (logoutBtn && localStorage.getItem('authToken')) {
logoutBtn.style.display = 'inline-block';
logoutBtn.addEventListener('click', async () => {
if (confirm('确定要登出吗?')) {
await logout();
}
});
}
})();
</script>
<script type="module" src="app/app.js"></script>
</body>
</html>

304
static/login.html Normal file
View file

@ -0,0 +1,304 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>登录 - AIClient2API</title>
<link rel="icon" type="image/x-icon" href="/favicon.ico">
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
}
.login-container {
background: white;
border-radius: 12px;
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.1);
width: 100%;
max-width: 400px;
padding: 40px;
animation: fadeIn 0.5s ease-in;
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(-20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.logo {
text-align: center;
margin-bottom: 30px;
}
.logo img {
width: 80px;
height: 80px;
border-radius: 50%;
margin-bottom: 15px;
}
.logo h1 {
font-size: 24px;
color: #333;
margin-bottom: 5px;
}
.logo p {
font-size: 14px;
color: #666;
}
.form-group {
margin-bottom: 20px;
}
.form-group label {
display: block;
margin-bottom: 8px;
color: #333;
font-size: 14px;
font-weight: 500;
}
.form-group input {
width: 100%;
padding: 12px 15px;
border: 2px solid #e1e8ed;
border-radius: 8px;
font-size: 14px;
transition: all 0.3s ease;
outline: none;
}
.form-group input:focus {
border-color: #059669;
box-shadow: 0 0 0 3px rgba(5, 150, 105, 0.1);
}
.form-group input::placeholder {
color: #aaa;
}
.error-message {
color: #e74c3c;
font-size: 13px;
margin-top: 8px;
display: none;
animation: shake 0.3s ease-in-out;
}
@keyframes shake {
0%, 100% { transform: translateX(0); }
25% { transform: translateX(-10px); }
75% { transform: translateX(10px); }
}
.error-message.show {
display: block;
}
.login-button {
width: 100%;
padding: 12px;
background: linear-gradient(135deg, #059669 0%, #10b981 100%);
color: white;
border: none;
border-radius: 8px;
font-size: 16px;
font-weight: 600;
cursor: pointer;
transition: all 0.3s ease;
margin-top: 10px;
}
.login-button:hover {
transform: translateY(-2px);
box-shadow: 0 5px 20px rgba(5, 150, 105, 0.4);
}
.login-button:active {
transform: translateY(0);
}
.login-button:disabled {
background: #ccc;
cursor: not-allowed;
transform: none;
}
.footer {
text-align: center;
margin-top: 30px;
padding-top: 20px;
border-top: 1px solid #e1e8ed;
}
.footer p {
font-size: 13px;
color: #999;
}
.loading {
display: inline-block;
width: 16px;
height: 16px;
border: 2px solid #ffffff;
border-radius: 50%;
border-top-color: transparent;
animation: spin 0.8s linear infinite;
margin-right: 8px;
vertical-align: middle;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
@media (max-width: 480px) {
.login-container {
padding: 30px 20px;
}
.logo h1 {
font-size: 20px;
}
}
</style>
</head>
<body>
<div class="login-container">
<div class="logo">
<img src="/favicon.ico" alt="Logo" onerror="this.style.display='none'">
<h1>AIClient2API</h1>
<p>请登录以继续</p>
</div>
<form id="loginForm">
<div class="form-group">
<label for="password">密码</label>
<input
type="password"
id="password"
name="password"
placeholder="请输入密码"
autocomplete="current-password"
required
>
<div class="error-message" id="errorMessage">密码错误,请重试</div>
</div>
<button type="submit" class="login-button" id="loginButton">
登录
</button>
</form>
<div class="footer">
<p>&copy; 2025 AIClient2API. All rights reserved.</p>
</div>
</div>
<script>
const loginForm = document.getElementById('loginForm');
const passwordInput = document.getElementById('password');
const errorMessage = document.getElementById('errorMessage');
const loginButton = document.getElementById('loginButton');
// 检查是否已经登录
checkLoginStatus();
loginForm.addEventListener('submit', async (e) => {
e.preventDefault();
const password = passwordInput.value.trim();
if (!password) {
showError('请输入密码');
return;
}
// 禁用按钮并显示加载状态
loginButton.disabled = true;
loginButton.innerHTML = '<span class="loading"></span>登录中...';
errorMessage.classList.remove('show');
try {
// 直接使用fetch进行登录请求登录页面不需要token
const response = await fetch('/api/login', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
password
})
});
const data = await response.json();
if (response.ok && data.success) {
// 登录成功保存token
localStorage.setItem('authToken', data.token);
// 跳转到主页
window.location.href = '/';
} else {
showError(data.message || '密码错误,请重试');
loginButton.disabled = false;
loginButton.innerHTML = '登录';
passwordInput.value = '';
passwordInput.focus();
}
} catch (error) {
console.error('登录错误:', error);
showError('登录失败,请检查网络连接');
loginButton.disabled = false;
loginButton.innerHTML = '登录';
}
});
function showError(message) {
errorMessage.textContent = message;
errorMessage.classList.add('show');
passwordInput.classList.add('error');
setTimeout(() => {
passwordInput.classList.remove('error');
}, 300);
}
function checkLoginStatus() {
const token = localStorage.getItem('authToken');
if (token) {
// Token存在跳转到主页
window.location.href = '/';
}
}
// 监听输入,清除错误提示
passwordInput.addEventListener('input', () => {
errorMessage.classList.remove('show');
});
// 页面加载时聚焦到密码输入框
passwordInput.focus();
</script>
</body>
</html>