Compare commits

...

24 Commits

Author SHA1 Message Date
hkfires
0aee78c072 fix(logs): improve request id and status code parsing 2025-12-24 22:46:08 +08:00
hkfires
8780ea7ec5 fix(logs): wrap long log messages 2025-12-24 15:38:11 +08:00
hkfires
40fe33aeae fix(config): optimize layout for full height 2025-12-24 12:45:30 +08:00
hkfires
2a94be08fa fix(logs): optimize layout for full height 2025-12-24 12:38:57 +08:00
hkfires
0758cfe08a feat(logs): implement tabbed view for logs and error files 2025-12-24 11:45:14 +08:00
hkfires
02a01e5afc feat(logs): add request id parsing and refactor row layout 2025-12-24 10:46:33 +08:00
Supra4E8C
561e06503c feat: update README.md README_CN.md 2025-12-22 23:20:31 +08:00
Supra4E8C
94962158ef feat(settings): move request logging toggle behind hidden entry 2025-12-22 22:41:50 +08:00
Supra4E8C
68974ffc68 feat(ai-providers): add prefix editing for provider configs 2025-12-21 23:46:39 +08:00
Supra4E8C
f8ed787f92 fix(splash): prevent login flicker on startup 2025-12-21 20:22:22 +08:00
Supra4E8C
dea106cf47 fix(splash): preserve logo aspect ratio 2025-12-21 16:58:14 +08:00
Supra4E8C
76ef1b68af fix(dashboard): improve stats loading and i18n date formatting 2025-12-21 16:54:17 +08:00
Supra4E8C
39a003bdd4 refactor(dashboard): simplify stats and add available models card 2025-12-21 16:27:28 +08:00
Supra4E8C
b1426ccefc feat(dashboard): enhance dashboard with provider breakdown and usage stats 2025-12-21 16:06:33 +08:00
Supra4E8C
a9df58cba7 feat(dashboard): add dashboard page with stats and splash screen 2025-12-21 16:05:09 +08:00
Supra4E8C
f6563490a6 fix(webui): normalize gemini endpoint and oauth callback status 2025-12-21 10:40:04 +08:00
Supra4E8C
18c1ba6c3c feat(ampcode): remove localhost-only management toggle 2025-12-20 18:32:32 +08:00
Supra4E8C
c2627cac3e fix: release auto write 2025-12-20 18:17:40 +08:00
Supra4E8C
df472119e7 feat: add commit-based release notes, usage loading spinner, and 60s logs timeout 2025-12-20 12:34:45 +08:00
Supra4E8C
10f2262753 fix(ai-providers): gate Claude models input and refine excluded tag styles 2025-12-19 23:54:26 +08:00
Supra4E8C
39d86d133a feat(oauth): add callback URL submission and require Gemini CLI project ID 2025-12-19 18:04:14 +08:00
Supra4E8C
ddbd7d00bd fix 2025-12-18 17:49:59 +08:00
Supra4E8C
e44beb541f feat 2025-12-18 12:36:17 +08:00
Supra4E8C
aecd5875d6 feat(usage): add loading overlay and 60s API timeout 2025-12-18 01:03:12 +08:00
40 changed files with 2290 additions and 695 deletions

View File

@@ -15,6 +15,9 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
fetch-depth: 0
fetch-tags: true
- name: Setup Node.js
uses: actions/setup-node@v4
@@ -36,27 +39,25 @@ jobs:
mv index.html management.html
ls -lh management.html
- name: Generate release notes
run: |
set -euo pipefail
current_tag="${GITHUB_REF_NAME}"
previous_tag="$(git tag --list 'v*' --sort=-v:refname | grep -v "^${current_tag}$" | head -n 1 || true)"
if [ -n "${previous_tag}" ]; then
range="${previous_tag}..${current_tag}"
else
range="${current_tag}"
fi
: > release-notes.md
git log --pretty=format:"- %h %s" "${range}" >> release-notes.md
- name: Create Release
uses: softprops/action-gh-release@v1
with:
files: dist/management.html
body: |
## CLI Proxy API Management Center - ${{ github.ref_name }}
### Download and Usage
1. Download the `management.html` file
2. Open it directly in your browser
3. All assets (CSS, JavaScript, images) are bundled into this single file
### Features
- Single file, no external dependencies required
- Complete management interface for CLI Proxy API
- Support for local and remote connections
- Multi-language support (Chinese/English)
- Dark/Light theme support
---
🤖 Generated with GitHub Actions
body_path: release-notes.md
draft: false
prerelease: false
env:

3
.gitignore vendored
View File

@@ -8,6 +8,8 @@ pnpm-debug.log*
lerna-debug.log*
api.md
usage.json
CLAUDE.md
AGENTS.md
node_modules
dist
@@ -15,6 +17,7 @@ dist-ssr
*.local
# Editor directories and files
settings.local.json
.vscode/*
!.vscode/extensions.json
.idea

216
README.md
View File

@@ -1,190 +1,130 @@
# CLI Proxy API Management Center
A modern React-based WebUI for managing the CLI Proxy API, completely refactored with a modern tech stack for enhanced maintainability, type safety, and user experience.
A single-file WebUI (React + TypeScript) for operating and troubleshooting the **CLI Proxy API** via its **Management API** (config, credentials, logs, and usage).
[中文文档](README_CN.md)
**Main Project**: https://github.com/router-for-me/CLIProxyAPI
**Example URL**: https://remote.router-for.me/
**Main Project**: https://github.com/router-for-me/CLIProxyAPI
**Example URL**: https://remote.router-for.me/
**Minimum Required Version**: ≥ 6.3.0 (recommended ≥ 6.5.0)
Since version 6.0.19, the WebUI ships with the main program; access it via `/management.html` on the API port once the service is running.
## Features
## What this is (and isnt)
### Core Capabilities
- This repository is the WebUI only. It talks to the CLI Proxy API **Management API** (`/v0/management`) to read/update config, upload credentials, view logs, and inspect usage.
- It is **not** a proxy and does not forward traffic.
- **Login & Authentication**: Auto-detects current address (manual override supported), encrypted auto-login with secure localStorage, session persistence
- **Basic Settings**: Debug mode, proxy URL, request retries with custom config, quota fallback (auto-switch project/preview models), usage statistics toggle, request logging & file logging, WebSocket `/ws/*` authentication
- **API Keys Management**: Manage proxy auth keys with add/edit/delete operations
- **AI Providers**: Configure Gemini/Codex/Claude settings, OpenAI-compatible providers with custom base URLs/headers/proxy/model aliases, Vertex AI credential import from service-account JSON
- **Auth Files & OAuth**: Upload/download/search/paginate JSON credentials; type filters (Qwen/Gemini/GeminiCLI/AIStudio/Claude/Codex/Antigravity/iFlow/Vertex/Empty); bulk delete; OAuth/Device flows for multiple providers
- **Logs Viewer**: Real-time log viewer with auto-refresh, download and clear capabilities (appears when logging-to-file is enabled)
- **Usage Analytics**: Overview cards, hourly/daily toggles, interactive charts with multiple model lines, per-API statistics table
- **Config Management**: In-browser YAML editor for `/config.yaml` with syntax highlighting, reload/save functionality
- **System Information**: Connection status, config cache, server version/build date, UI version in footer
## Quick start
### User Experience
### Option A: Use the WebUI bundled in CLIProxyAPI (recommended)
- **Responsive Design**: Full mobile support with collapsible sidebar
- **Theme System**: Light/dark mode with persistent preference
- **Internationalization**: English and Simplified Chinese (zh-CN) with seamless switching
- **Real-time Feedback**: Toast notifications for all operations
- **Security**: Masked secrets, encrypted local storage
1. Start your CLI Proxy API service.
2. Open: `http://<host>:<api_port>/management.html`
3. Enter your **management key** and connect.
## Tech Stack
The address is auto-detected from the current page URL; manual override is supported.
- **Frontend Framework**: React 19 with TypeScript
- **Build Tool**: Vite 7 with single-file output ([vite-plugin-singlefile](https://github.com/nicknisi/vite-plugin-singlefile))
- **State Management**: [Zustand](https://github.com/pmndrs/zustand) for global stores
- **Routing**: React Router 7 with HashRouter
- **HTTP Client**: Axios with interceptors for auth & error handling
- **Internationalization**: i18next + react-i18next
- **Styling**: SCSS with CSS Modules, CSS Variables for theming
- **Charts**: Chart.js + react-chartjs-2
- **Code Editor**: @uiw/react-codemirror with YAML support
## Getting Started
### Prerequisites
- Node.js 18+ (LTS recommended)
- npm 9+
### Installation
### Option B: Run the dev server
```bash
# Clone the repository
git clone https://github.com/router-for-me/Cli-Proxy-API-Management-Center.git
cd Cli-Proxy-API-Management-Center
# Install dependencies
npm install
npm run dev
```
### Development
Open `http://localhost:5173`, then connect to your CLI Proxy API instance.
### Option C: Build a single HTML file
```bash
npm run dev # Start Vite dev server (default: http://localhost:5173)
npm install
npm run build
```
### Build
- Output: `dist/index.html` (all assets are inlined).
- For CLIProxyAPI bundling, the release workflow renames it to `management.html`.
- To preview locally: `npm run preview`
```bash
npm run build # TypeScript check + Vite production build
```
Tip: opening `dist/index.html` via `file://` may be blocked by browser CORS; serving it (preview/static server) is more reliable.
The build outputs a single `dist/index.html` file with all assets inlined.
## Connecting to the server
### Other Commands
### API address
```bash
npm run preview # Preview production build locally
npm run lint # ESLint with strict mode (--max-warnings 0)
npm run format # Prettier formatting for src/**/*.{ts,tsx,css,scss}
npm run type-check # TypeScript type checking only (tsc --noEmit)
```
You can enter any of the following; the UI will normalize it:
## Usage
- `localhost:8317`
- `http://192.168.1.10:8317`
- `https://example.com:8317`
- `http://example.com:8317/v0/management` (also accepted; the suffix is removed internally)
### Access Methods
### Management key (not the same as API keys)
1. **Integrated with CLI Proxy API (Recommended)**
After starting the CLI Proxy API service, visit `http://your-server:8317/management.html`
The management key is sent with every request as:
2. **Standalone (Built file)**
Open the built `dist/index.html` directly in a browser, or host it on any static file server
- `Authorization: Bearer <MANAGEMENT_KEY>` (default)
3. **Development Server**
Run `npm run dev` and open `http://localhost:5173`
This is different from the proxy `api-keys` you manage inside the UI (those are for client requests to the proxy endpoints).
### Initial Configuration
### Remote management
1. The login page auto-detects the current address; you can modify it if needed
2. Enter your management key
3. Click Connect to authenticate
4. Credentials are encrypted and saved locally for auto-login
If you connect from a non-localhost browser, the server must allow remote management (e.g. `allow-remote-management: true`).
See `api.md` for the full authentication rules, server-side limits, and edge cases.
> **Tip**: The Logs navigation item appears only after enabling "Logging to file" in Basic Settings.
## What you can manage (mapped to the UI pages)
## Project Structure
- **Dashboard**: connection status, server version/build date, quick counts, model availability snapshot.
- **Basic Settings**: debug, proxy URL, request retry, quota fallback (switch project/preview models), usage statistics, request logging, file logging, WebSocket auth.
- **API Keys**: manage proxy `api-keys` (add/edit/delete).
- **AI Providers**:
- Gemini/Codex/Claude key entries (base URL, headers, proxy, model aliases, excluded models, prefix).
- OpenAI-compatible providers (multiple API keys, custom headers, model alias import via `/v1/models`, optional browser-side “chat/completions” test).
- Ampcode integration (upstream URL/key, force mappings, model mapping table).
- **Auth Files**: upload/download/delete JSON credentials, filter/search/pagination, runtime-only indicators, view supported models per credential (when the server supports it), manage OAuth excluded models (supports `*` wildcards).
- **OAuth**: start OAuth/device flows for supported providers, poll status, optionally submit callback `redirect_url`; includes iFlow cookie import.
- **Usage**: requests/tokens charts (hour/day), per-API & per-model breakdown, cached/reasoning token breakdown, RPM/TPM window, optional cost estimation with locally-saved model pricing.
- **Config**: edit `/config.yaml` in-browser with YAML highlighting + search, then save/reload.
- **Logs**: tail logs with incremental polling, auto-refresh, search, hide management traffic, clear logs; download request error log files.
- **System**: quick links + fetch `/v1/models` (grouped view). Requires at least one proxy API key to query models.
```
├── src/
│ ├── components/
│ │ ├── common/ # Shared components (NotificationContainer)
│ │ ├── layout/ # App shell (MainLayout with sidebar)
│ │ └── ui/ # Reusable UI primitives (Button, Input, Modal, etc.)
│ ├── hooks/ # Custom hooks (useApi, useDebounce, usePagination, etc.)
│ ├── i18n/
│ │ ├── locales/ # Translation files (zh-CN.json, en.json)
│ │ └── index.ts # i18next configuration
│ ├── pages/ # Route page components with co-located .module.scss
│ ├── router/ # ProtectedRoute wrapper
│ ├── services/
│ │ ├── api/ # API layer (client.ts singleton, feature modules)
│ │ └── storage/ # Secure storage utilities
│ ├── stores/ # Zustand stores (auth, config, theme, language, notification)
│ ├── styles/ # Global SCSS (variables, mixins, themes, components)
│ ├── types/ # TypeScript type definitions
│ ├── utils/ # Utility functions (constants, format, validation, etc.)
│ ├── App.tsx # Root component with routing
│ └── main.tsx # Entry point
├── dist/ # Build output (single-file bundle)
├── vite.config.ts # Vite configuration
├── tsconfig.json # TypeScript configuration
└── package.json
```
## Build & release notes
### Key Architecture Patterns
- Vite produces a **single HTML** output (`dist/index.html`) with all assets inlined (via `vite-plugin-singlefile`).
- Tagging `vX.Y.Z` triggers `.github/workflows/release.yml` to publish `dist/management.html`.
- The UI version shown in the footer is injected at build time (env `VERSION`, git tag, or `package.json` fallback).
- **Path Alias**: Use `@/` to import from `src/` (configured in vite.config.ts and tsconfig.json)
- **API Client**: Singleton `apiClient` in `src/services/api/client.ts` with auth interceptors
- **State Management**: Zustand stores with localStorage persistence for auth/theme/language
- **Styling**: SCSS variables auto-injected; CSS Modules for component-scoped styles
- **Build Output**: Single-file bundle for easy distribution (all assets inlined)
## Security notes
- The management key is stored in browser `localStorage` using a lightweight obfuscation format (`enc::v1::...`) to avoid plaintext storage; treat it as sensitive.
- Use a dedicated browser profile/device for management. Be cautious when enabling remote management and evaluate its exposure surface.
## Troubleshooting
### Connection Issues
- **Cant connect / 401**: confirm the API address and management key; remote access may require enabling remote management in the server config.
- **Repeated auth failures**: the server may temporarily block remote IPs.
- **Logs page missing**: enable “Logging to file” in Basic Settings; the navigation item is shown only when file logging is enabled.
- **Some features show “unsupported”**: the backend may be too old or the endpoint is disabled/absent (common for model lists per auth file, excluded models, logs).
- **OpenAI provider test fails**: the test runs in the browser and depends on network/CORS of the provider endpoint; a failure here does not always mean the server cannot reach it.
1. Confirm the CLI Proxy API service is running
2. Check if the API address is correct
3. Verify that the management key is valid
4. Ensure your firewall allows the connection
## Development
### Data Not Updating
1. Click the "Refresh All" button in the header
2. Check your network connection
3. Open browser DevTools console for error details
### Logs & Config Editor
- **Logs**: Requires server-side logging-to-file enabled; 404 indicates old server version or logging disabled
- **Config Editor**: Requires `/config.yaml` endpoint; ensure valid YAML syntax before saving
### Usage Statistics
- If charts are empty, enable "Usage statistics" in settings; data resets on server restart
```bash
npm run dev # Vite dev server
npm run build # tsc + Vite build
npm run preview # serve dist locally
npm run lint # ESLint (fails on warnings)
npm run format # Prettier
npm run type-check # tsc --noEmit
```
## Contributing
We welcome Issues and Pull Requests! Please follow these guidelines:
Issues and PRs are welcome. Please include:
1. Fork the repository
2. Create a feature branch (`git checkout -b feature/amazing-feature`)
3. Commit your changes with clear messages
4. Push to your branch
5. Open a Pull Request
### Development Guidelines
- Run `npm run lint` and `npm run type-check` before committing
- Follow existing code patterns and naming conventions
- Use TypeScript strict mode
- Write meaningful commit messages
- Reproduction steps (server version + UI version)
- Screenshots for UI changes
- Verification notes (`npm run lint`, `npm run type-check`)
## License
This project is licensed under the MIT License.
MIT

View File

@@ -1,190 +1,130 @@
# CLI Proxy API 管理中心
用于管理 CLI Proxy API 的现代化 React Web 界面,采用全新技术栈重构,提供更好的可维护性、类型安全性和用户体验
用于管理与排障 **CLI Proxy API** 的单文件 WebUIReact + TypeScript通过 **Management API** 完成配置、凭据、日志与统计等运维工作
[English](README.md)
**主项目**: https://github.com/router-for-me/CLIProxyAPI
**示例地址**: https://remote.router-for.me/
**主项目**: https://github.com/router-for-me/CLIProxyAPI
**示例地址**: https://remote.router-for.me/
**最低版本要求**: ≥ 6.3.0(推荐 ≥ 6.5.0
自 6.0.19 版本起WebUI 已集成到主程序中,启动服务后可通过 `/management.html` 访问。
Since version 6.0.19, the WebUI ships with the main program; access it via `/management.html` on the API port once the service is running.
## 功能特点
## 这是什么(以及不是什么)
### 核心功能
- **登录与认证**: 自动检测当前地址(支持手动修改),加密自动登录,会话持久化
- **基础设置**: 调试模式、代理 URL、请求重试配置、配额溢出自动切换项目/预览模型、使用统计开关、请求日志与文件日志、WebSocket `/ws/*` 鉴权
- **API 密钥管理**: 管理代理认证密钥,支持添加/编辑/删除操作
- **AI 提供商**: 配置 Gemini/Codex/ClaudeOpenAI 兼容提供商(自定义 Base URL/Headers/代理/模型别名Vertex AI 服务账号 JSON 导入
- **认证文件与 OAuth**: 上传/下载/搜索/分页 JSON 凭据类型筛选Qwen/Gemini/GeminiCLI/AIStudio/Claude/Codex/Antigravity/iFlow/Vertex/Empty批量删除多提供商 OAuth/设备码流程
- **日志查看**: 实时日志查看,支持自动刷新、下载和清空(启用"写入日志文件"后显示)
- **使用统计**: 概览卡片、小时/天切换、多模型交互式图表、按 API 统计表格
- **配置管理**: 内置 YAML 编辑器,支持 `/config.yaml` 语法高亮、重载/保存
- **系统信息**: 连接状态、配置缓存、服务器版本/构建时间、底栏显示 UI 版本
### 用户体验
- **响应式设计**: 完整移动端支持,可折叠侧边栏
- **主题系统**: 明/暗模式切换,偏好持久化
- **国际化**: 简体中文和英文,无缝切换
- **实时反馈**: 所有操作的消息通知
- **安全性**: 密钥遮蔽、加密本地存储
## 技术栈
- **前端框架**: React 19 + TypeScript
- **构建工具**: Vite 7单文件输出[vite-plugin-singlefile](https://github.com/nicknisi/vite-plugin-singlefile)
- **状态管理**: [Zustand](https://github.com/pmndrs/zustand)
- **路由**: React Router 7 (HashRouter)
- **HTTP 客户端**: Axios带认证和错误处理拦截器
- **国际化**: i18next + react-i18next
- **样式**: SCSS + CSS ModulesCSS 变量主题
- **图表**: Chart.js + react-chartjs-2
- **代码编辑器**: @uiw/react-codemirrorYAML 支持)
- 本仓库只包含 Web 管理界面本身,通过 CLI Proxy API 的 **Management API**`/v0/management`)读取/修改配置、上传凭据、查看日志与使用统计。
-**不是** 代理本体,不参与流量转发。
## 快速开始
### 环境要求
### 方式 A使用 CLIProxyAPI 自带的 WebUI推荐
- Node.js 18+(推荐 LTS 版本)
- npm 9+
1. 启动 CLI Proxy API 服务。
2. 打开:`http://<host>:<api_port>/management.html`
3. 输入 **管理密钥** 并连接。
### 安装
页面会根据当前地址自动推断 API 地址,也支持手动修改。
### 方式 B开发调试
```bash
# 克隆仓库
git clone https://github.com/router-for-me/Cli-Proxy-API-Management-Center.git
cd Cli-Proxy-API-Management-Center
# 安装依赖
npm install
npm run dev
```
### 开发
打开 `http://localhost:5173`,然后连接到你的 CLI Proxy API 实例。
### 方式 C构建单文件 HTML
```bash
npm run dev # 启动 Vite 开发服务器(默认: http://localhost:5173
npm install
npm run build
```
### 构建
- 构建产物:`dist/index.html`(资源已全部内联)。
- 在 CLIProxyAPI 的发布流程里会重命名为 `management.html`
- 本地预览:`npm run preview`
提示:直接用 `file://` 打开 `dist/index.html` 可能遇到浏览器 CORS 限制;更稳妥的方式是用预览/静态服务器打开。
## 连接说明
### API 地址怎么填
以下格式均可WebUI 会自动归一化:
- `localhost:8317`
- `http://192.168.1.10:8317`
- `https://example.com:8317`
- `http://example.com:8317/v0/management`(也可填写,后缀会被自动去除)
### 管理密钥(注意:不是 API Keys
管理密钥会以如下方式随请求发送:
- `Authorization: Bearer <MANAGEMENT_KEY>`(默认)
这与 WebUI 中“API Keys”页面管理的 `api-keys` 不同:后者是代理对外接口(如 OpenAI 兼容接口)给客户端使用的鉴权 key。
### 远程管理
当你从非 localhost 的浏览器访问时,服务端通常需要开启远程管理(例如 `allow-remote-management: true`)。
完整鉴权规则、限制与边界情况请查看 `api.md`
## 功能一览(按页面对应)
- **仪表盘**:连接状态、服务版本/构建时间、关键数量概览、可用模型概览。
- **基础设置**:调试开关、代理 URL、请求重试、配额回退切项目/切预览模型、使用统计、请求日志、文件日志、WebSocket 鉴权。
- **API Keys**:管理代理 `api-keys`(增/改/删)。
- **AI 提供商**
- Gemini/Codex/Claude 配置Base URL、Headers、代理、模型别名、排除模型、Prefix
- OpenAI 兼容提供商(多 Key、Header、自助从 `/v1/models` 拉取并导入模型别名、可选浏览器侧 `chat/completions` 测试)。
- Ampcode 集成(上游地址/密钥、强制映射、模型映射表)。
- **认证文件**:上传/下载/删除 JSON 凭据,筛选/搜索/分页,标记 runtime-only查看单个凭据可用模型依赖后端支持管理 OAuth 排除模型(支持 `*` 通配符)。
- **OAuth**:对支持的提供商发起 OAuth/设备码流程,轮询状态;可选提交回调 `redirect_url`;包含 iFlow Cookie 导入。
- **使用统计**:按小时/天图表、按 API 与按模型统计、缓存/推理 Token 拆分、RPM/TPM 时间窗、可选本地保存的模型价格用于费用估算。
- **配置文件**:浏览器内编辑 `/config.yaml`YAML 高亮 + 搜索),保存/重载。
- **日志**:增量拉取日志、自动刷新、搜索、隐藏管理端流量、清空日志;下载请求错误日志文件。
- **系统信息**:快捷链接 + 拉取 `/v1/models` 并分组展示(需要至少一个代理 API Key 才能查询模型)。
## 构建与发布说明
- 使用 Vite 输出 **单文件 HTML**`dist/index.html`),资源全部内联(`vite-plugin-singlefile`)。
-`vX.Y.Z` 标签会触发 `.github/workflows/release.yml`,发布 `dist/management.html`
- 页脚显示的 UI 版本在构建期注入(优先使用环境变量 `VERSION`,否则使用 git tag / `package.json`)。
## 安全提示
- 管理密钥会存入浏览器 `localStorage`,并使用轻量混淆格式(`enc::v1::...`)避免明文;仍应视为敏感信息。
- 建议使用独立浏览器配置/设备进行管理;开启远程管理时请谨慎评估暴露面。
## 常见问题
- **无法连接 / 401**:确认 API 地址与管理密钥;远程访问可能需要服务端开启远程管理。
- **反复输错密钥**:服务端可能对远程 IP 进行临时封禁。
- **日志页面不显示**:需要在“基础设置”里开启“写入日志文件”,导航项才会出现。
- **功能提示不支持**:多为后端版本较旧或接口未启用/不存在(如:认证文件模型列表、排除模型、日志相关接口)。
- **OpenAI 提供商测试失败**:测试在浏览器侧执行,会受网络与 CORS 影响;这里失败不一定代表服务端不可用。
## 开发命令
```bash
npm run build # TypeScript 检查 + Vite 生产构建
npm run dev # 启动开发服务器
npm run build # tsc + Vite 构建
npm run preview # 本地预览 dist
npm run lint # ESLintwarnings 视为失败)
npm run format # Prettier
npm run type-check # tsc --noEmit
```
构建输出单个 `dist/index.html` 文件,所有资源已内联。
### 其他命令
```bash
npm run preview # 本地预览生产构建
npm run lint # ESLint 严格模式(--max-warnings 0
npm run format # Prettier 格式化 src/**/*.{ts,tsx,css,scss}
npm run type-check # 仅 TypeScript 类型检查tsc --noEmit
```
## 使用方法
### 访问方式
1. **与 CLI Proxy API 集成使用(推荐)**
启动 CLI Proxy API 服务后,访问 `http://您的服务器:8317/management.html`
2. **独立使用(构建后文件)**
直接在浏览器打开构建的 `dist/index.html`,或部署到任意静态文件服务器
3. **开发服务器**
运行 `npm run dev` 后打开 `http://localhost:5173`
### 初始配置
1. 登录页会自动检测当前地址,可根据需要修改
2. 输入管理密钥
3. 点击连接进行认证
4. 凭据会加密保存到本地,下次自动登录
> **提示**: 只有在"基础设置"中启用"写入日志文件"后,才会显示"日志查看"导航项。
## 项目结构
```
├── src/
│ ├── components/
│ │ ├── common/ # 公共组件NotificationContainer
│ │ ├── layout/ # 应用外壳MainLayout 侧边栏布局)
│ │ └── ui/ # 可复用 UI 组件Button、Input、Modal 等)
│ ├── hooks/ # 自定义 HooksuseApi、useDebounce、usePagination 等)
│ ├── i18n/
│ │ ├── locales/ # 翻译文件zh-CN.json、en.json
│ │ └── index.ts # i18next 配置
│ ├── pages/ # 路由页面组件,配套 .module.scss 样式
│ ├── router/ # ProtectedRoute 路由守卫
│ ├── services/
│ │ ├── api/ # API 层client.ts 单例,功能模块)
│ │ └── storage/ # 安全存储工具
│ ├── stores/ # Zustand 状态管理auth、config、theme、language、notification
│ ├── styles/ # 全局 SCSSvariables、mixins、themes、components
│ ├── types/ # TypeScript 类型定义
│ ├── utils/ # 工具函数constants、format、validation 等)
│ ├── App.tsx # 根组件与路由
│ └── main.tsx # 入口文件
├── dist/ # 构建输出(单文件打包)
├── vite.config.ts # Vite 配置
├── tsconfig.json # TypeScript 配置
└── package.json
```
### 核心架构模式
- **路径别名**: 使用 `@/` 导入 `src/` 目录(在 vite.config.ts 和 tsconfig.json 中配置)
- **API 客户端**: `src/services/api/client.ts` 单例,带认证拦截器
- **状态管理**: Zustand storesauth/theme/language 持久化到 localStorage
- **样式**: SCSS 变量自动注入CSS Modules 实现组件作用域样式
- **构建输出**: 单文件打包,便于分发(所有资源内联)
## 故障排除
### 连接问题
1. 确认 CLI Proxy API 服务正在运行
2. 检查 API 地址是否正确
3. 验证管理密钥是否有效
4. 确认防火墙设置允许连接
### 数据不更新
1. 点击顶栏的"刷新全部"按钮
2. 检查网络连接
3. 打开浏览器开发者工具控制台查看错误信息
### 日志与配置编辑
- **日志**: 需要服务端启用写文件日志;返回 404 说明服务器版本过旧或未启用日志
- **配置编辑**: 依赖 `/config.yaml` 接口;保存前请确保 YAML 语法正确
### 使用统计
- 若图表为空,请在设置中启用"使用统计";数据在服务重启后会清空
## 贡献
欢迎提 Issue Pull Request请遵循以下指南
欢迎提 Issue PR。建议附上
1. Fork 本仓库
2. 创建功能分支(`git checkout -b feature/amazing-feature`
3. 提交更改,使用清晰的提交信息
4. 推送到分支
5. 开启 Pull Request
### 开发规范
- 提交前运行 `npm run lint``npm run type-check`
- 遵循现有代码模式和命名规范
- 使用 TypeScript 严格模式
- 编写有意义的提交信息
- 复现步骤(服务端版本 + UI 版本)
- UI 改动截图
- 验证记录(`npm run lint``npm run type-check`
## 许可证
本项目采用 MIT 许可证。
MIT

View File

@@ -1,6 +1,7 @@
import { useEffect } from 'react';
import { useCallback, useEffect, useState } from 'react';
import { HashRouter, Navigate, Route, Routes } from 'react-router-dom';
import { LoginPage } from '@/pages/LoginPage';
import { DashboardPage } from '@/pages/DashboardPage';
import { SettingsPage } from '@/pages/SettingsPage';
import { ApiKeysPage } from '@/pages/ApiKeysPage';
import { AiProvidersPage } from '@/pages/AiProvidersPage';
@@ -11,19 +12,29 @@ import { ConfigPage } from '@/pages/ConfigPage';
import { LogsPage } from '@/pages/LogsPage';
import { SystemPage } from '@/pages/SystemPage';
import { NotificationContainer } from '@/components/common/NotificationContainer';
import { SplashScreen } from '@/components/common/SplashScreen';
import { MainLayout } from '@/components/layout/MainLayout';
import { ProtectedRoute } from '@/router/ProtectedRoute';
import { useAuthStore, useLanguageStore, useThemeStore } from '@/stores';
const SPLASH_DURATION = 1500;
const SPLASH_FADE_DURATION = 400;
function App() {
const initializeTheme = useThemeStore((state) => state.initializeTheme);
const language = useLanguageStore((state) => state.language);
const setLanguage = useLanguageStore((state) => state.setLanguage);
const restoreSession = useAuthStore((state) => state.restoreSession);
const [splashReadyToFade, setSplashReadyToFade] = useState(false);
const [showSplash, setShowSplash] = useState(true);
const [authReady, setAuthReady] = useState(false);
useEffect(() => {
initializeTheme();
restoreSession();
void restoreSession().finally(() => {
setAuthReady(true);
});
}, [initializeTheme, restoreSession]);
useEffect(() => {
@@ -31,6 +42,27 @@ function App() {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []); // 仅用于首屏同步 i18n 语言
useEffect(() => {
const timer = setTimeout(() => {
setSplashReadyToFade(true);
}, SPLASH_DURATION - SPLASH_FADE_DURATION);
return () => clearTimeout(timer);
}, []);
const handleSplashFinish = useCallback(() => {
setShowSplash(false);
}, []);
if (showSplash) {
return (
<SplashScreen
fadeOut={splashReadyToFade && authReady}
onFinish={handleSplashFinish}
/>
);
}
return (
<HashRouter>
<NotificationContainer />
@@ -44,7 +76,8 @@ function App() {
</ProtectedRoute>
}
>
<Route index element={<Navigate to="/settings" replace />} />
<Route index element={<DashboardPage />} />
<Route path="dashboard" element={<DashboardPage />} />
<Route path="settings" element={<SettingsPage />} />
<Route path="api-keys" element={<ApiKeysPage />} />
<Route path="ai-providers" element={<AiProvidersPage />} />
@@ -54,7 +87,7 @@ function App() {
<Route path="config" element={<ConfigPage />} />
<Route path="logs" element={<LogsPage />} />
<Route path="system" element={<SystemPage />} />
<Route path="*" element={<Navigate to="/settings" replace />} />
<Route path="*" element={<Navigate to="/" replace />} />
</Route>
</Routes>
</HashRouter>

View File

@@ -0,0 +1,106 @@
@use 'sass:color';
@use '../../styles/variables.scss' as *;
.splash-screen {
position: fixed;
inset: 0;
z-index: 9999;
display: flex;
align-items: center;
justify-content: center;
background: var(--bg-primary);
opacity: 1;
transition: opacity 0.4s ease-out;
&.fade-out {
opacity: 0;
pointer-events: none;
}
}
.splash-content {
display: flex;
flex-direction: column;
align-items: center;
gap: $spacing-md;
animation: splash-enter 0.6s ease-out;
}
@keyframes splash-enter {
from {
opacity: 0;
transform: scale(0.9) translateY(20px);
}
to {
opacity: 1;
transform: scale(1) translateY(0);
}
}
.splash-logo {
height: 80px;
width: auto;
border-radius: $radius-lg;
box-shadow: $shadow-lg;
animation: splash-logo-pulse 1.5s ease-in-out infinite;
}
@keyframes splash-logo-pulse {
0%, 100% {
transform: scale(1);
}
50% {
transform: scale(1.05);
}
}
.splash-title {
font-size: 28px;
font-weight: 800;
color: var(--text-primary);
margin: 0;
letter-spacing: -0.5px;
}
.splash-subtitle {
font-size: 16px;
font-weight: 500;
color: var(--text-secondary);
margin: 0;
margin-top: -8px;
}
.splash-loader {
width: 120px;
height: 3px;
background: var(--border-color);
border-radius: $radius-full;
overflow: hidden;
margin-top: $spacing-md;
}
.splash-loader-bar {
width: 100%;
height: 100%;
background: var(--primary-color);
border-radius: $radius-full;
animation: splash-loading 1.2s ease-in-out infinite;
transform-origin: left;
}
@keyframes splash-loading {
0% {
transform: scaleX(0);
}
50% {
transform: scaleX(1);
transform-origin: left;
}
50.01% {
transform-origin: right;
}
100% {
transform: scaleX(0);
transform-origin: right;
}
}

View File

@@ -0,0 +1,36 @@
import { useEffect } from 'react';
import { INLINE_LOGO_JPEG } from '@/assets/logoInline';
import './SplashScreen.scss';
interface SplashScreenProps {
onFinish: () => void;
fadeOut?: boolean;
}
const FADE_OUT_DURATION = 400;
export function SplashScreen({ onFinish, fadeOut = false }: SplashScreenProps) {
useEffect(() => {
if (!fadeOut) return;
const finishTimer = setTimeout(() => {
onFinish();
}, FADE_OUT_DURATION);
return () => {
clearTimeout(finishTimer);
};
}, [fadeOut, onFinish]);
return (
<div className={`splash-screen ${fadeOut ? 'fade-out' : ''}`}>
<div className="splash-content">
<img src={INLINE_LOGO_JPEG} alt="CPAMC" className="splash-logo" />
<h1 className="splash-title">CLI Proxy API</h1>
<p className="splash-subtitle">Management Center</p>
<div className="splash-loader">
<div className="splash-loader-bar" />
</div>
</div>
</div>
);
}

View File

@@ -2,12 +2,15 @@ import { ReactNode, SVGProps, useCallback, useEffect, useLayoutEffect, useRef, u
import { NavLink, Outlet } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { Button } from '@/components/ui/Button';
import { Modal } from '@/components/ui/Modal';
import { ToggleSwitch } from '@/components/ui/ToggleSwitch';
import {
IconBot,
IconChartLine,
IconFileText,
IconInfo,
IconKey,
IconLayoutDashboard,
IconScrollText,
IconSettings,
IconShield,
@@ -15,9 +18,10 @@ import {
} from '@/components/ui/icons';
import { INLINE_LOGO_JPEG } from '@/assets/logoInline';
import { useAuthStore, useConfigStore, useLanguageStore, useNotificationStore, useThemeStore } from '@/stores';
import { versionApi } from '@/services/api';
import { configApi, versionApi } from '@/services/api';
const sidebarIcons: Record<string, ReactNode> = {
dashboard: <IconLayoutDashboard size={18} />,
settings: <IconSlidersHorizontal size={18} />,
apiKeys: <IconKey size={18} />,
aiProviders: <IconBot size={18} />,
@@ -146,6 +150,7 @@ export function MainLayout() {
const config = useConfigStore((state) => state.config);
const fetchConfig = useConfigStore((state) => state.fetchConfig);
const clearCache = useConfigStore((state) => state.clearCache);
const updateConfigValue = useConfigStore((state) => state.updateConfigValue);
const theme = useThemeStore((state) => state.theme);
const toggleTheme = useThemeStore((state) => state.toggleTheme);
@@ -155,11 +160,20 @@ export function MainLayout() {
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
const [checkingVersion, setCheckingVersion] = useState(false);
const [brandExpanded, setBrandExpanded] = useState(true);
const [requestLogModalOpen, setRequestLogModalOpen] = useState(false);
const [requestLogDraft, setRequestLogDraft] = useState(false);
const [requestLogTouched, setRequestLogTouched] = useState(false);
const [requestLogSaving, setRequestLogSaving] = useState(false);
const brandCollapseTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const headerRef = useRef<HTMLElement | null>(null);
const versionTapCount = useRef(0);
const versionTapTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const fullBrandName = 'CLI Proxy API Management Center';
const abbrBrandName = t('title.abbr');
const requestLogEnabled = config?.requestLog ?? false;
const requestLogDirty = requestLogDraft !== requestLogEnabled;
const canEditRequestLog = connectionStatus === 'connected' && Boolean(config);
// 将顶栏高度写入 CSS 变量,确保侧栏/内容区计算一致,防止滚动时抖动
useLayoutEffect(() => {
@@ -201,6 +215,20 @@ export function MainLayout() {
};
}, []);
useEffect(() => {
if (requestLogModalOpen && !requestLogTouched) {
setRequestLogDraft(requestLogEnabled);
}
}, [requestLogModalOpen, requestLogTouched, requestLogEnabled]);
useEffect(() => {
return () => {
if (versionTapTimer.current) {
clearTimeout(versionTapTimer.current);
}
};
}, []);
const handleBrandClick = useCallback(() => {
if (!brandExpanded) {
setBrandExpanded(true);
@@ -214,6 +242,60 @@ export function MainLayout() {
}
}, [brandExpanded]);
const openRequestLogModal = useCallback(() => {
setRequestLogTouched(false);
setRequestLogDraft(requestLogEnabled);
setRequestLogModalOpen(true);
}, [requestLogEnabled]);
const handleRequestLogClose = useCallback(() => {
setRequestLogModalOpen(false);
setRequestLogTouched(false);
}, []);
const handleVersionTap = useCallback(() => {
versionTapCount.current += 1;
if (versionTapTimer.current) {
clearTimeout(versionTapTimer.current);
}
versionTapTimer.current = setTimeout(() => {
versionTapCount.current = 0;
}, 1500);
if (versionTapCount.current >= 7) {
versionTapCount.current = 0;
if (versionTapTimer.current) {
clearTimeout(versionTapTimer.current);
versionTapTimer.current = null;
}
openRequestLogModal();
}
}, [openRequestLogModal]);
const handleRequestLogSave = async () => {
if (!canEditRequestLog) return;
if (!requestLogDirty) {
setRequestLogModalOpen(false);
return;
}
const previous = requestLogEnabled;
setRequestLogSaving(true);
updateConfigValue('request-log', requestLogDraft);
try {
await configApi.updateRequestLog(requestLogDraft);
clearCache('request-log');
showNotification(t('notification.request_log_updated'), 'success');
setRequestLogModalOpen(false);
} catch (error: any) {
updateConfigValue('request-log', previous);
showNotification(`${t('notification.update_failed')}: ${error?.message || ''}`, 'error');
} finally {
setRequestLogSaving(false);
}
};
useEffect(() => {
fetchConfig().catch(() => {
// ignore initial failure; login flow会提示
@@ -230,6 +312,7 @@ export function MainLayout() {
: 'muted';
const navItems = [
{ path: '/', label: t('nav.dashboard'), icon: sidebarIcons.dashboard },
{ path: '/settings', label: t('nav.basic_settings'), icon: sidebarIcons.settings },
{ path: '/api-keys', label: t('nav.api_keys'), icon: sidebarIcons.apiKeys },
{ path: '/ai-providers', label: t('nav.ai_providers'), icon: sidebarIcons.aiProviders },
@@ -366,7 +449,7 @@ export function MainLayout() {
<span>
{t('footer.api_version')}: {serverVersion || t('system_info.version_unknown')}
</span>
<span>
<span onClick={handleVersionTap}>
{t('footer.version')}: {__APP_VERSION__ || t('system_info.version_unknown')}
</span>
<span>
@@ -376,6 +459,40 @@ export function MainLayout() {
</footer>
</div>
</div>
<Modal
open={requestLogModalOpen}
onClose={handleRequestLogClose}
title={t('basic_settings.request_log_title')}
footer={
<>
<Button variant="secondary" onClick={handleRequestLogClose} disabled={requestLogSaving}>
{t('common.cancel')}
</Button>
<Button
onClick={handleRequestLogSave}
loading={requestLogSaving}
disabled={!canEditRequestLog || !requestLogDirty}
>
{t('common.save')}
</Button>
</>
}
>
<div className="request-log-modal">
<div className="status-badge warning">{t('basic_settings.request_log_warning')}</div>
<ToggleSwitch
label={t('basic_settings.request_log_enable')}
labelPosition="left"
checked={requestLogDraft}
disabled={!canEditRequestLog || requestLogSaving}
onChange={(value) => {
setRequestLogDraft(value);
setRequestLogTouched(true);
}}
/>
</div>
</Modal>
</div>
);
}

View File

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

View File

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

View File

@@ -5,15 +5,26 @@ interface ToggleSwitchProps {
onChange: (value: boolean) => void;
label?: ReactNode;
disabled?: boolean;
labelPosition?: 'left' | 'right';
}
export function ToggleSwitch({ checked, onChange, label, disabled = false }: ToggleSwitchProps) {
export function ToggleSwitch({
checked,
onChange,
label,
disabled = false,
labelPosition = 'right'
}: ToggleSwitchProps) {
const handleChange = (event: ChangeEvent<HTMLInputElement>) => {
onChange(event.target.checked);
};
const className = ['switch', labelPosition === 'left' ? 'switch-label-left' : '']
.filter(Boolean)
.join(' ');
return (
<label className="switch">
<label className={className}>
<input type="checkbox" checked={checked} onChange={handleChange} disabled={disabled} />
<span className="track">
<span className="thumb" />

View File

@@ -303,3 +303,14 @@ export function IconCode({ size = 20, ...props }: IconProps) {
</svg>
);
}
export function IconLayoutDashboard({ size = 20, ...props }: IconProps) {
return (
<svg {...baseSvgProps} width={size} height={size} {...props}>
<rect width="7" height="9" x="3" y="3" rx="1" />
<rect width="7" height="5" x="14" y="3" rx="1" />
<rect width="7" height="9" x="14" y="12" rx="1" />
<rect width="7" height="5" x="3" y="16" rx="1" />
</svg>
);
}

View File

@@ -29,6 +29,7 @@
"required": "Required",
"api_key": "Key",
"base_url": "Address",
"prefix": "Prefix",
"proxy_url": "Proxy",
"alias": "Alias",
"failure": "Failure",
@@ -81,6 +82,7 @@
"status": "Connection Status:"
},
"nav": {
"dashboard": "Dashboard",
"basic_settings": "Basic Settings",
"api_keys": "API Keys",
"ai_providers": "AI Providers",
@@ -91,6 +93,27 @@
"logs": "Logs Viewer",
"system_info": "Management Center Info"
},
"dashboard": {
"title": "Dashboard",
"subtitle": "Welcome to CLI Proxy API Management Center",
"openai_providers": "OpenAI Providers",
"quick_actions": "Quick Actions",
"current_config": "Current Configuration",
"management_keys": "Management Keys",
"provider_keys_detail": "G:{{gemini}} C:{{codex}} Cl:{{claude}} O:{{openai}}",
"oauth_credentials": "OAuth Credentials",
"usage_overview": "Usage Overview",
"total_requests": "Total Requests",
"total_tokens": "Total Tokens",
"rpm_30min": "RPM (30min)",
"tpm_30min": "TPM (30min)",
"models_used": "Models Used",
"no_usage_data": "No usage data available",
"view_detailed_usage": "View Detailed Stats",
"edit_settings": "Edit Settings",
"available_models": "Available Models",
"available_models_desc": "Total models from all providers"
},
"basic_settings": {
"title": "Basic Settings",
"debug_title": "Debug Mode",
@@ -110,7 +133,9 @@
"usage_statistics_enable": "Enable usage statistics",
"logging_title": "Logging",
"logging_to_file_enable": "Enable logging to file",
"request_log_title": "Request Logging",
"request_log_enable": "Enable request logging",
"request_log_warning": "Keep this off unless you need detailed troubleshooting.",
"ws_auth_title": "WebSocket Authentication",
"ws_auth_enable": "Require auth for /ws/*"
},
@@ -149,6 +174,9 @@
"excluded_models_placeholder": "Comma or newline separated, e.g. gemini-1.5-pro, gemini-1.5-flash",
"excluded_models_hint": "Leave empty to allow all models; values are trimmed and deduplicated automatically.",
"excluded_models_count": "Excluding {{count}} models",
"prefix_label": "Prefix (Optional):",
"prefix_placeholder": "e.g.: team-a",
"prefix_hint": "When set, call models as prefix/<model> to target this entry.",
"config_toggle_label": "Enabled",
"config_disabled_badge": "Disabled",
"codex_title": "Codex API Configuration",
@@ -200,8 +228,6 @@
"ampcode_upstream_api_key_current": "Current Amp official key: {{key}}",
"ampcode_clear_upstream_api_key": "Clear official key",
"ampcode_clear_upstream_api_key_confirm": "Are you sure you want to clear the Ampcode upstream API key (Amp official)?",
"ampcode_restrict_management_label": "Restrict Amp management routes to localhost",
"ampcode_restrict_management_hint": "When enabled, Amp management routes (/api/auth, /api/user, /api/threads, etc.) only accept 127.0.0.1/::1 (recommended).",
"ampcode_force_model_mappings_label": "Force model mappings",
"ampcode_force_model_mappings_hint": "When enabled, mappings override local API-key availability checks.",
"ampcode_model_mappings_label": "Model mappings (from → to)",
@@ -424,9 +450,10 @@
"gemini_cli_oauth_title": "Gemini CLI OAuth",
"gemini_cli_oauth_button": "Start Gemini CLI Login",
"gemini_cli_oauth_hint": "Login to Google Gemini CLI service through OAuth flow, automatically obtain and save authentication files.",
"gemini_cli_project_id_label": "Google Cloud Project ID (Optional):",
"gemini_cli_project_id_placeholder": "Enter Google Cloud Project ID (optional)",
"gemini_cli_project_id_hint": "If a project ID is specified, authentication information for that project will be used.",
"gemini_cli_project_id_label": "Google Cloud Project ID:",
"gemini_cli_project_id_placeholder": "Enter Google Cloud Project ID",
"gemini_cli_project_id_hint": "Project ID is required for Gemini CLI OAuth.",
"gemini_cli_project_id_required": "Please enter a Google Cloud project ID.",
"gemini_cli_oauth_url_label": "Authorization URL:",
"gemini_cli_open_link": "Open Link",
"gemini_cli_copy_link": "Copy Link",
@@ -446,6 +473,16 @@
"qwen_oauth_status_error": "Authentication failed:",
"qwen_oauth_start_error": "Failed to start Qwen OAuth:",
"qwen_oauth_polling_error": "Failed to check authentication status:",
"oauth_callback_label": "Callback URL",
"oauth_callback_placeholder": "http://localhost:1455/auth/callback?code=...&state=...",
"oauth_callback_hint": "Remote browser mode: after the provider redirects to http://localhost:..., copy the full URL and submit it here.",
"oauth_callback_button": "Submit Callback URL",
"oauth_callback_required": "Please paste the full redirect URL first.",
"oauth_callback_success": "Callback URL submitted. Continue waiting for authentication.",
"oauth_callback_error": "Failed to submit callback URL:",
"oauth_callback_upgrade_hint": "Please update CLI Proxy API or check the connection.",
"oauth_callback_status_success": "Callback URL submitted, waiting for authentication...",
"oauth_callback_status_error": "Callback URL submission failed:",
"missing_state": "Unable to retrieve authentication state parameter",
"iflow_oauth_title": "iFlow OAuth",
"iflow_oauth_button": "Start iFlow Login",
@@ -571,11 +608,17 @@
"auto_refresh_disabled": "Auto refresh disabled",
"load_more_hint": "Scroll up to load more",
"hidden_lines": "Hidden: {{count}} lines",
"loaded_lines": "Loaded: {{count}} lines",
"filtered_lines": "Filtered: {{count}} lines",
"hide_management_logs": "Hide {{prefix}} logs",
"search_placeholder": "Search logs by content or keyword",
"search_empty_title": "No matching logs found",
"search_empty_desc": "Try a different keyword or clear the search filter.",
"search_empty_desc": "Try a different keyword or clear the filters.",
"double_click_copy_hint": "Double-click to copy raw log line",
"copy_success": "Log copied to clipboard",
"copy_failed": "Copy failed",
"lines": "lines",
"removed": "Removed",
"removed": "Filtered",
"upgrade_required_title": "Please Upgrade CLI Proxy API",
"upgrade_required_desc": "The current server version does not support the logs viewing feature. Please upgrade to the latest version of CLI Proxy API to use this feature."
},

View File

@@ -29,6 +29,7 @@
"required": "必填",
"api_key": "密钥",
"base_url": "地址",
"prefix": "前缀",
"proxy_url": "代理",
"alias": "别名",
"failure": "失败",
@@ -81,6 +82,7 @@
"status": "连接状态:"
},
"nav": {
"dashboard": "仪表盘",
"basic_settings": "基础设置",
"api_keys": "API 密钥",
"ai_providers": "AI 提供商",
@@ -91,6 +93,27 @@
"logs": "日志查看",
"system_info": "中心信息"
},
"dashboard": {
"title": "仪表盘",
"subtitle": "欢迎使用 CLI Proxy API 管理中心",
"openai_providers": "OpenAI 提供商",
"quick_actions": "快捷操作",
"current_config": "当前配置",
"management_keys": "管理密钥",
"provider_keys_detail": "G:{{gemini}} C:{{codex}} Cl:{{claude}} O:{{openai}}",
"oauth_credentials": "OAuth 凭证",
"usage_overview": "使用概览",
"total_requests": "总请求数",
"total_tokens": "总 Token 数",
"rpm_30min": "RPM (30分钟)",
"tpm_30min": "TPM (30分钟)",
"models_used": "使用模型数",
"no_usage_data": "暂无使用数据",
"view_detailed_usage": "查看详细统计",
"edit_settings": "编辑设置",
"available_models": "可用模型",
"available_models_desc": "所有提供商的模型总数"
},
"basic_settings": {
"title": "基础设置",
"debug_title": "调试模式",
@@ -110,7 +133,9 @@
"usage_statistics_enable": "启用使用统计",
"logging_title": "日志记录",
"logging_to_file_enable": "启用日志记录到文件",
"request_log_title": "请求日志",
"request_log_enable": "启用请求日志",
"request_log_warning": "仅在需要排查问题时开启,日常请保持关闭。",
"ws_auth_title": "WebSocket 鉴权",
"ws_auth_enable": "启用 /ws/* 鉴权"
},
@@ -149,6 +174,9 @@
"excluded_models_placeholder": "用逗号或换行分隔,例如: gemini-1.5-pro, gemini-1.5-flash",
"excluded_models_hint": "留空表示不过滤;保存时会自动去重并忽略空白。",
"excluded_models_count": "排除 {{count}} 个模型",
"prefix_label": "前缀 (可选):",
"prefix_placeholder": "例如: team-a",
"prefix_hint": "设置后可用 prefix/<model> 选择该条目。",
"config_toggle_label": "启用",
"config_disabled_badge": "已停用",
"codex_title": "Codex API 配置",
@@ -200,8 +228,6 @@
"ampcode_upstream_api_key_current": "当前Amp官方密钥: {{key}}",
"ampcode_clear_upstream_api_key": "清除官方密钥",
"ampcode_clear_upstream_api_key_confirm": "确定要清除 Ampcode 的 upstream API keyAmp官方",
"ampcode_restrict_management_label": "仅允许本机访问 Amp 管理路由",
"ampcode_restrict_management_hint": "开启后,/api/auth、/api/user、/api/threads 等 Amp 管理路由仅允许 127.0.0.1/::1 访问(推荐)。",
"ampcode_force_model_mappings_label": "强制应用模型映射",
"ampcode_force_model_mappings_hint": "开启后,模型映射将覆盖本地 API Key 可用性判断。",
"ampcode_model_mappings_label": "模型映射 (from → to)",
@@ -424,9 +450,10 @@
"gemini_cli_oauth_title": "Gemini CLI OAuth",
"gemini_cli_oauth_button": "开始 Gemini CLI 登录",
"gemini_cli_oauth_hint": "通过 OAuth 流程登录 Google Gemini CLI 服务,自动获取并保存认证文件。",
"gemini_cli_project_id_label": "Google Cloud 项目 ID (可选):",
"gemini_cli_project_id_placeholder": "输入 Google Cloud 项目 ID (可选)",
"gemini_cli_project_id_hint": "如果指定了项目 ID将使用该项目的认证信息。",
"gemini_cli_project_id_label": "Google Cloud 项目 ID:",
"gemini_cli_project_id_placeholder": "输入 Google Cloud 项目 ID",
"gemini_cli_project_id_hint": "请填写项目 ID用于 Gemini CLI OAuth 登录。",
"gemini_cli_project_id_required": "请填写 Google Cloud 项目 ID。",
"gemini_cli_oauth_url_label": "授权链接:",
"gemini_cli_open_link": "打开链接",
"gemini_cli_copy_link": "复制链接",
@@ -446,6 +473,16 @@
"qwen_oauth_status_error": "认证失败:",
"qwen_oauth_start_error": "启动 Qwen OAuth 失败:",
"qwen_oauth_polling_error": "检查认证状态失败:",
"oauth_callback_label": "回调 URL",
"oauth_callback_placeholder": "http://localhost:1455/auth/callback?code=...&state=...",
"oauth_callback_hint": "远程浏览器模式:当授权跳转到 http://localhost:... 后,复制完整 URL 并提交到这里。",
"oauth_callback_button": "提交回调 URL",
"oauth_callback_required": "请先粘贴完整的回调 URL。",
"oauth_callback_success": "回调 URL 已提交,请继续等待认证。",
"oauth_callback_error": "提交回调 URL 失败:",
"oauth_callback_upgrade_hint": "请更新CLI Proxy API或检查连接",
"oauth_callback_status_success": "回调 URL 已提交,等待认证中...",
"oauth_callback_status_error": "回调 URL 提交失败:",
"missing_state": "无法获取认证状态参数",
"iflow_oauth_title": "iFlow OAuth",
"iflow_oauth_button": "开始 iFlow 登录",
@@ -571,11 +608,17 @@
"auto_refresh_disabled": "自动刷新已关闭",
"load_more_hint": "向上滚动加载更多",
"hidden_lines": "已隐藏 {{count}} 行",
"loaded_lines": "已载入 {{count}} 行",
"filtered_lines": "已过滤 {{count}} 行",
"hide_management_logs": "屏蔽 {{prefix}} 日志",
"search_placeholder": "搜索日志内容或关键字",
"search_empty_title": "未找到匹配的日志",
"search_empty_desc": "尝试更换关键字或清空搜索条件。",
"search_empty_desc": "尝试更换关键字或清空筛选条件。",
"double_click_copy_hint": "双击复制日志原文",
"copy_success": "已复制日志原文",
"copy_failed": "复制失败",
"lines": "行",
"removed": "已删除",
"removed": "已过滤",
"upgrade_required_title": "需要升级 CLI Proxy API",
"upgrade_required_desc": "当前服务器版本不支持日志查看功能,请升级到最新版本的 CLI Proxy API 以使用此功能。"
},

View File

@@ -404,8 +404,17 @@
}
.excludedModelTag {
background: rgba(251, 191, 36, 0.2);
border-color: rgba(251, 191, 36, 0.4);
background: rgba(251, 191, 36, 0.22);
border-color: rgba(251, 191, 36, 0.55);
color: #fde68a;
.modelName {
color: #fde68a;
}
}
.excludedModelsLabel {
color: #fde68a;
}
.apiKeyEntryCard {

View File

@@ -39,6 +39,7 @@ interface ModelEntry {
interface OpenAIFormState {
name: string;
prefix: string;
baseUrl: string;
headers: HeaderEntry[];
testModel?: string;
@@ -49,7 +50,6 @@ interface OpenAIFormState {
interface AmpcodeFormState {
upstreamUrl: string;
upstreamApiKey: string;
restrictManagementToLocalhost: boolean;
forceModelMappings: boolean;
mappingEntries: ModelEntry[];
}
@@ -174,7 +174,6 @@ const entriesToAmpcodeMappings = (entries: ModelEntry[]): AmpcodeModelMapping[]
const buildAmpcodeFormState = (ampcode?: AmpcodeConfig | null): AmpcodeFormState => ({
upstreamUrl: ampcode?.upstreamUrl ?? '',
upstreamApiKey: '',
restrictManagementToLocalhost: ampcode?.restrictManagementToLocalhost ?? true,
forceModelMappings: ampcode?.forceModelMappings ?? false,
mappingEntries: ampcodeMappingsToEntries(ampcode?.modelMappings),
});
@@ -202,6 +201,7 @@ export function AiProvidersPage() {
const [geminiForm, setGeminiForm] = useState<GeminiKeyConfig & { excludedText: string }>({
apiKey: '',
prefix: '',
baseUrl: '',
headers: {},
excludedModels: [],
@@ -211,6 +211,7 @@ export function AiProvidersPage() {
ProviderKeyConfig & { modelEntries: ModelEntry[]; excludedText: string }
>({
apiKey: '',
prefix: '',
baseUrl: '',
proxyUrl: '',
headers: {},
@@ -221,6 +222,7 @@ export function AiProvidersPage() {
});
const [openaiForm, setOpenaiForm] = useState<OpenAIFormState>({
name: '',
prefix: '',
baseUrl: '',
headers: [],
apiKeyEntries: [buildApiKeyEntry()],
@@ -319,6 +321,7 @@ export function AiProvidersPage() {
setModal(null);
setGeminiForm({
apiKey: '',
prefix: '',
baseUrl: '',
headers: {},
excludedModels: [],
@@ -326,6 +329,7 @@ export function AiProvidersPage() {
});
setProviderForm({
apiKey: '',
prefix: '',
baseUrl: '',
proxyUrl: '',
headers: {},
@@ -336,6 +340,7 @@ export function AiProvidersPage() {
});
setOpenaiForm({
name: '',
prefix: '',
baseUrl: '',
headers: [],
apiKeyEntries: [buildApiKeyEntry()],
@@ -412,6 +417,7 @@ export function AiProvidersPage() {
const modelEntries = modelsToEntries(entry.models);
setOpenaiForm({
name: entry.name,
prefix: entry.prefix ?? '',
baseUrl: entry.baseUrl,
headers: headersToEntries(entry.headers),
testModel: entry.testModel,
@@ -701,9 +707,6 @@ export function AiProvidersPage() {
await ampcodeApi.clearUpstreamUrl();
}
await ampcodeApi.updateRestrictManagementToLocalhost(
ampcodeForm.restrictManagementToLocalhost
);
await ampcodeApi.updateForceModelMappings(ampcodeForm.forceModelMappings);
if (ampcodeLoaded || ampcodeMappingsDirty) {
@@ -720,12 +723,18 @@ export function AiProvidersPage() {
const previous = config?.ampcode ?? {};
const next: AmpcodeConfig = {
...previous,
upstreamUrl: upstreamUrl || undefined,
restrictManagementToLocalhost: ampcodeForm.restrictManagementToLocalhost,
forceModelMappings: ampcodeForm.forceModelMappings,
};
if (previous.upstreamApiKey) {
next.upstreamApiKey = previous.upstreamApiKey;
}
if (Array.isArray(previous.modelMappings)) {
next.modelMappings = previous.modelMappings;
}
if (overrideKey) {
next.upstreamApiKey = overrideKey;
}
@@ -756,6 +765,7 @@ export function AiProvidersPage() {
try {
const payload: GeminiKeyConfig = {
apiKey: geminiForm.apiKey.trim(),
prefix: geminiForm.prefix?.trim() || undefined,
baseUrl: geminiForm.baseUrl?.trim() || undefined,
headers: buildHeaderObject(headersToEntries(geminiForm.headers as any)),
excludedModels: parseExcludedModels(geminiForm.excludedText),
@@ -899,6 +909,7 @@ export function AiProvidersPage() {
const payload: ProviderKeyConfig = {
apiKey: providerForm.apiKey.trim(),
prefix: providerForm.prefix?.trim() || undefined,
baseUrl,
proxyUrl: providerForm.proxyUrl?.trim() || undefined,
headers: buildHeaderObject(headersToEntries(providerForm.headers as any)),
@@ -969,6 +980,7 @@ export function AiProvidersPage() {
try {
const payload: OpenAIProviderConfig = {
name: openaiForm.name.trim(),
prefix: openaiForm.prefix?.trim() || undefined,
baseUrl: openaiForm.baseUrl.trim(),
headers: buildHeaderObject(openaiForm.headers),
apiKeyEntries: openaiForm.apiKeyEntries.map((entry) => ({
@@ -1175,6 +1187,12 @@ export function AiProvidersPage() {
<span className={styles.fieldLabel}>{t('common.api_key')}:</span>
<span className={styles.fieldValue}>{maskApiKey(item.apiKey)}</span>
</div>
{item.prefix && (
<div className={styles.fieldRow}>
<span className={styles.fieldLabel}>{t('common.prefix')}:</span>
<span className={styles.fieldValue}>{item.prefix}</span>
</div>
)}
{/* Base URL 行 */}
{item.baseUrl && (
<div className={styles.fieldRow}>
@@ -1273,6 +1291,12 @@ export function AiProvidersPage() {
<span className={styles.fieldLabel}>{t('common.api_key')}:</span>
<span className={styles.fieldValue}>{maskApiKey(item.apiKey)}</span>
</div>
{item.prefix && (
<div className={styles.fieldRow}>
<span className={styles.fieldLabel}>{t('common.prefix')}:</span>
<span className={styles.fieldValue}>{item.prefix}</span>
</div>
)}
{/* Base URL 行 */}
{item.baseUrl && (
<div className={styles.fieldRow}>
@@ -1378,6 +1402,12 @@ export function AiProvidersPage() {
<span className={styles.fieldLabel}>{t('common.api_key')}:</span>
<span className={styles.fieldValue}>{maskApiKey(item.apiKey)}</span>
</div>
{item.prefix && (
<div className={styles.fieldRow}>
<span className={styles.fieldLabel}>{t('common.prefix')}:</span>
<span className={styles.fieldValue}>{item.prefix}</span>
</div>
)}
{/* Base URL 行 */}
{item.baseUrl && (
<div className={styles.fieldRow}>
@@ -1505,16 +1535,6 @@ export function AiProvidersPage() {
: t('common.not_set')}
</span>
</div>
<div className={styles.fieldRow}>
<span className={styles.fieldLabel}>
{t('ai_providers.ampcode_restrict_management_label')}:
</span>
<span className={styles.fieldValue}>
{(config?.ampcode?.restrictManagementToLocalhost ?? true)
? t('common.yes')
: t('common.no')}
</span>
</div>
<div className={styles.fieldRow}>
<span className={styles.fieldLabel}>
{t('ai_providers.ampcode_force_model_mappings_label')}:
@@ -1576,6 +1596,12 @@ export function AiProvidersPage() {
return (
<Fragment>
<div className="item-title">{item.name}</div>
{item.prefix && (
<div className={styles.fieldRow}>
<span className={styles.fieldLabel}>{t('common.prefix')}:</span>
<span className={styles.fieldValue}>{item.prefix}</span>
</div>
)}
{/* Base URL 行 */}
<div className={styles.fieldRow}>
<span className={styles.fieldLabel}>{t('common.base_url')}:</span>
@@ -1739,18 +1765,6 @@ export function AiProvidersPage() {
</Button>
</div>
<div className="form-group">
<ToggleSwitch
label={t('ai_providers.ampcode_restrict_management_label')}
checked={ampcodeForm.restrictManagementToLocalhost}
onChange={(value) =>
setAmpcodeForm((prev) => ({ ...prev, restrictManagementToLocalhost: value }))
}
disabled={ampcodeModalLoading || ampcodeSaving}
/>
<div className="hint">{t('ai_providers.ampcode_restrict_management_hint')}</div>
</div>
<div className="form-group">
<ToggleSwitch
label={t('ai_providers.ampcode_force_model_mappings_label')}
@@ -1806,6 +1820,13 @@ export function AiProvidersPage() {
value={geminiForm.apiKey}
onChange={(e) => setGeminiForm((prev) => ({ ...prev, apiKey: e.target.value }))}
/>
<Input
label={t('ai_providers.prefix_label')}
placeholder={t('ai_providers.prefix_placeholder')}
value={geminiForm.prefix ?? ''}
onChange={(e) => setGeminiForm((prev) => ({ ...prev, prefix: e.target.value }))}
hint={t('ai_providers.prefix_hint')}
/>
<Input
label={t('ai_providers.gemini_base_url_label')}
placeholder={t('ai_providers.gemini_base_url_placeholder')}
@@ -1870,6 +1891,13 @@ export function AiProvidersPage() {
value={providerForm.apiKey}
onChange={(e) => setProviderForm((prev) => ({ ...prev, apiKey: e.target.value }))}
/>
<Input
label={t('ai_providers.prefix_label')}
placeholder={t('ai_providers.prefix_placeholder')}
value={providerForm.prefix ?? ''}
onChange={(e) => setProviderForm((prev) => ({ ...prev, prefix: e.target.value }))}
hint={t('ai_providers.prefix_hint')}
/>
<Input
label={
modal?.type === 'codex'
@@ -1897,19 +1925,21 @@ export function AiProvidersPage() {
keyPlaceholder={t('common.custom_headers_key_placeholder')}
valuePlaceholder={t('common.custom_headers_value_placeholder')}
/>
<div className="form-group">
<label>{t('ai_providers.claude_models_label')}</label>
<ModelInputList
entries={providerForm.modelEntries}
onChange={(entries) =>
setProviderForm((prev) => ({ ...prev, modelEntries: entries }))
}
addLabel={t('ai_providers.claude_models_add_btn')}
namePlaceholder={t('common.model_name_placeholder')}
aliasPlaceholder={t('common.model_alias_placeholder')}
disabled={saving}
/>
</div>
{modal?.type === 'claude' && (
<div className="form-group">
<label>{t('ai_providers.claude_models_label')}</label>
<ModelInputList
entries={providerForm.modelEntries}
onChange={(entries) =>
setProviderForm((prev) => ({ ...prev, modelEntries: entries }))
}
addLabel={t('ai_providers.claude_models_add_btn')}
namePlaceholder={t('common.model_name_placeholder')}
aliasPlaceholder={t('common.model_alias_placeholder')}
disabled={saving}
/>
</div>
)}
<div className="form-group">
<label>{t('ai_providers.excluded_models_label')}</label>
<textarea
@@ -1950,6 +1980,13 @@ export function AiProvidersPage() {
value={openaiForm.name}
onChange={(e) => setOpenaiForm((prev) => ({ ...prev, name: e.target.value }))}
/>
<Input
label={t('ai_providers.prefix_label')}
placeholder={t('ai_providers.prefix_placeholder')}
value={openaiForm.prefix ?? ''}
onChange={(e) => setOpenaiForm((prev) => ({ ...prev, prefix: e.target.value }))}
hint={t('ai_providers.prefix_hint')}
/>
<Input
label={t('ai_providers.openai_add_modal_url_label')}
value={openaiForm.baseUrl}

View File

@@ -2,6 +2,10 @@
.container {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
min-height: 0;
}
.pageTitle {
@@ -21,6 +25,8 @@
display: flex;
flex-direction: column;
gap: $spacing-lg;
flex: 1;
min-height: 0;
}
.searchInputWrapper {
@@ -127,7 +133,8 @@
.editorWrapper {
width: 100%;
height: 500px;
flex: 1;
min-height: 200px;
border: 1px solid var(--border-color);
border-radius: $radius-lg;
overflow: hidden;
@@ -206,6 +213,14 @@
}
}
.configCard {
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
overflow: hidden;
}
.actions {
display: flex;
gap: $spacing-sm;

View File

@@ -224,7 +224,7 @@ export function ConfigPage() {
<h1 className={styles.pageTitle}>{t('config_management.title')}</h1>
<p className={styles.description}>{t('config_management.description')}</p>
<Card>
<Card className={styles.configCard}>
<div className={styles.content}>
{/* Editor */}
{error && <div className="error-box">{error}</div>}

View File

@@ -0,0 +1,320 @@
@use 'sass:color';
@use '../styles/variables.scss' as *;
.dashboard {
display: flex;
flex-direction: column;
gap: $spacing-lg;
max-width: 1000px;
margin: 0 auto;
}
.header {
margin-bottom: $spacing-sm;
}
.title {
font-size: 26px;
font-weight: 800;
color: var(--text-primary);
margin: 0;
line-height: 1.4;
}
.subtitle {
font-size: 15px;
color: var(--text-secondary);
margin: $spacing-xs 0 0 0;
}
.connectionCard {
display: flex;
align-items: center;
justify-content: space-between;
gap: $spacing-md;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: $radius-lg;
padding: $spacing-md $spacing-lg;
flex-wrap: wrap;
}
.connectionStatus {
display: flex;
align-items: center;
gap: $spacing-sm;
}
.statusDot {
width: 10px;
height: 10px;
border-radius: 50%;
background: $gray-400;
&.connected {
background: $success-color;
box-shadow: 0 0 8px rgba($success-color, 0.5);
}
&.connecting {
background: $warning-color;
animation: pulse 1s ease-in-out infinite;
}
&.disconnected {
background: $error-color;
}
}
@keyframes pulse {
0%, 100% {
opacity: 1;
}
50% {
opacity: 0.5;
}
}
.statusText {
font-weight: 600;
color: var(--text-primary);
}
.connectionInfo {
display: flex;
align-items: center;
gap: $spacing-md;
flex-wrap: wrap;
}
.serverUrl {
font-family: $font-mono;
font-size: 13px;
color: var(--text-secondary);
background: var(--bg-primary);
padding: 4px 10px;
border-radius: $radius-md;
border: 1px solid var(--border-color);
}
.serverVersion {
font-size: 13px;
font-weight: 600;
color: var(--primary-color);
background: rgba($primary-color, 0.1);
padding: 4px 10px;
border-radius: $radius-full;
}
.buildDate {
font-size: 12px;
color: var(--text-secondary);
}
.statsGrid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: $spacing-md;
@media (max-width: 900px) {
grid-template-columns: repeat(2, 1fr);
}
@media (max-width: 500px) {
grid-template-columns: 1fr;
}
}
.statCard {
display: flex;
align-items: center;
gap: $spacing-md;
padding: $spacing-lg;
background: var(--bg-primary);
border: 1px solid var(--border-color);
border-radius: $radius-lg;
text-decoration: none;
transition: all $transition-fast;
&:hover {
border-color: var(--primary-color);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
transform: translateY(-2px);
}
}
.statIcon {
display: flex;
align-items: center;
justify-content: center;
width: 48px;
height: 48px;
border-radius: $radius-md;
background: var(--bg-secondary);
color: var(--primary-color);
}
.statContent {
display: flex;
flex-direction: column;
gap: 2px;
}
.statValue {
font-size: 24px;
font-weight: 800;
color: var(--text-primary);
}
.statLabel {
font-size: 13px;
color: var(--text-secondary);
}
.statSublabel {
font-size: 11px;
color: var(--text-secondary);
opacity: 0.8;
margin-top: 2px;
}
.section {
display: flex;
flex-direction: column;
gap: $spacing-md;
}
.sectionTitle {
font-size: 16px;
font-weight: 700;
color: var(--text-primary);
margin: 0;
}
.actionsGrid {
display: flex;
flex-wrap: wrap;
gap: $spacing-sm;
a {
text-decoration: none;
}
}
.actionButton {
display: inline-flex;
align-items: center;
gap: $spacing-sm;
// Button 内部的 span 需要 flex 对齐图标和文字
> span {
display: inline-flex;
align-items: center;
gap: $spacing-sm;
}
svg {
flex-shrink: 0;
}
}
.configGrid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: $spacing-sm;
}
.configItem {
display: flex;
align-items: center;
justify-content: space-between;
gap: $spacing-sm;
padding: $spacing-sm $spacing-md;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: $radius-md;
}
.configLabel {
font-size: 13px;
color: var(--text-secondary);
}
.configValue {
font-size: 13px;
font-weight: 600;
color: var(--text-primary);
&.enabled {
color: $success-color;
}
&.disabled {
color: var(--text-secondary);
}
}
.configValueMono {
font-size: 12px;
font-family: $font-mono;
color: var(--text-secondary);
word-break: break-all;
}
.configItemFull {
grid-column: 1 / -1;
}
// Usage stats section
.usageGrid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
gap: $spacing-sm;
}
.usageCard {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: $spacing-md;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: $radius-md;
text-align: center;
}
.usageValue {
font-size: 22px;
font-weight: 800;
color: var(--primary-color);
}
.usageLabel {
font-size: 12px;
color: var(--text-secondary);
margin-top: 4px;
}
.usageLoading,
.usageEmpty {
padding: $spacing-lg;
text-align: center;
color: var(--text-secondary);
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: $radius-md;
}
.viewMoreLink {
display: inline-flex;
align-items: center;
font-size: 13px;
color: var(--primary-color);
text-decoration: none;
margin-top: $spacing-xs;
&:hover {
text-decoration: underline;
}
}

315
src/pages/DashboardPage.tsx Normal file
View File

@@ -0,0 +1,315 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { Link } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import {
IconKey,
IconBot,
IconFileText,
IconSatellite
} from '@/components/ui/icons';
import { useAuthStore, useConfigStore, useModelsStore } from '@/stores';
import { apiKeysApi, providersApi, authFilesApi } from '@/services/api';
import styles from './DashboardPage.module.scss';
interface QuickStat {
label: string;
value: number | string;
icon: React.ReactNode;
path: string;
loading?: boolean;
sublabel?: string;
}
interface ProviderStats {
gemini: number | null;
codex: number | null;
claude: number | null;
openai: number | null;
}
export function DashboardPage() {
const { t, i18n } = useTranslation();
const connectionStatus = useAuthStore((state) => state.connectionStatus);
const serverVersion = useAuthStore((state) => state.serverVersion);
const serverBuildDate = useAuthStore((state) => state.serverBuildDate);
const apiBase = useAuthStore((state) => state.apiBase);
const config = useConfigStore((state) => state.config);
const models = useModelsStore((state) => state.models);
const modelsLoading = useModelsStore((state) => state.loading);
const fetchModelsFromStore = useModelsStore((state) => state.fetchModels);
const [stats, setStats] = useState<{
apiKeys: number | null;
authFiles: number | null;
}>({
apiKeys: null,
authFiles: null
});
const [providerStats, setProviderStats] = useState<ProviderStats>({
gemini: null,
codex: null,
claude: null,
openai: null
});
const [loading, setLoading] = useState(true);
const apiKeysCache = useRef<string[]>([]);
useEffect(() => {
apiKeysCache.current = [];
}, [apiBase, config?.apiKeys]);
const normalizeApiKeyList = (input: any): string[] => {
if (!Array.isArray(input)) return [];
const seen = new Set<string>();
const keys: string[] = [];
input.forEach((item) => {
const value = typeof item === 'string' ? item : item?.['api-key'] ?? item?.apiKey ?? '';
const trimmed = String(value || '').trim();
if (!trimmed || seen.has(trimmed)) return;
seen.add(trimmed);
keys.push(trimmed);
});
return keys;
};
const resolveApiKeysForModels = useCallback(async () => {
if (apiKeysCache.current.length) {
return apiKeysCache.current;
}
const configKeys = normalizeApiKeyList(config?.apiKeys);
if (configKeys.length) {
apiKeysCache.current = configKeys;
return configKeys;
}
try {
const list = await apiKeysApi.list();
const normalized = normalizeApiKeyList(list);
if (normalized.length) {
apiKeysCache.current = normalized;
}
return normalized;
} catch {
return [];
}
}, [config?.apiKeys]);
const fetchModels = useCallback(async () => {
if (connectionStatus !== 'connected' || !apiBase) {
return;
}
try {
const apiKeys = await resolveApiKeysForModels();
const primaryKey = apiKeys[0];
await fetchModelsFromStore(apiBase, primaryKey);
} catch {
// Ignore model fetch errors on dashboard
}
}, [connectionStatus, apiBase, resolveApiKeysForModels, fetchModelsFromStore]);
useEffect(() => {
const fetchStats = async () => {
setLoading(true);
try {
const [keysRes, filesRes, geminiRes, codexRes, claudeRes, openaiRes] = await Promise.allSettled([
apiKeysApi.list(),
authFilesApi.list(),
providersApi.getGeminiKeys(),
providersApi.getCodexConfigs(),
providersApi.getClaudeConfigs(),
providersApi.getOpenAIProviders()
]);
setStats({
apiKeys: keysRes.status === 'fulfilled' ? keysRes.value.length : null,
authFiles: filesRes.status === 'fulfilled' ? filesRes.value.files.length : null
});
setProviderStats({
gemini: geminiRes.status === 'fulfilled' ? geminiRes.value.length : null,
codex: codexRes.status === 'fulfilled' ? codexRes.value.length : null,
claude: claudeRes.status === 'fulfilled' ? claudeRes.value.length : null,
openai: openaiRes.status === 'fulfilled' ? openaiRes.value.length : null
});
} finally {
setLoading(false);
}
};
if (connectionStatus === 'connected') {
fetchStats();
fetchModels();
} else {
setLoading(false);
}
}, [connectionStatus, fetchModels]);
// Calculate total provider keys only when all provider stats are available.
const providerStatsReady =
providerStats.gemini !== null &&
providerStats.codex !== null &&
providerStats.claude !== null &&
providerStats.openai !== null;
const hasProviderStats =
providerStats.gemini !== null ||
providerStats.codex !== null ||
providerStats.claude !== null ||
providerStats.openai !== null;
const totalProviderKeys = providerStatsReady
? (providerStats.gemini ?? 0) +
(providerStats.codex ?? 0) +
(providerStats.claude ?? 0) +
(providerStats.openai ?? 0)
: 0;
const quickStats: QuickStat[] = [
{
label: t('nav.api_keys'),
value: stats.apiKeys ?? '-',
icon: <IconKey size={24} />,
path: '/api-keys',
loading: loading && stats.apiKeys === null,
sublabel: t('dashboard.management_keys')
},
{
label: t('nav.ai_providers'),
value: loading ? '-' : providerStatsReady ? totalProviderKeys : '-',
icon: <IconBot size={24} />,
path: '/ai-providers',
loading: loading,
sublabel: hasProviderStats
? t('dashboard.provider_keys_detail', {
gemini: providerStats.gemini ?? '-',
codex: providerStats.codex ?? '-',
claude: providerStats.claude ?? '-',
openai: providerStats.openai ?? '-'
})
: undefined
},
{
label: t('nav.auth_files'),
value: stats.authFiles ?? '-',
icon: <IconFileText size={24} />,
path: '/auth-files',
loading: loading && stats.authFiles === null,
sublabel: t('dashboard.oauth_credentials')
},
{
label: t('dashboard.available_models'),
value: modelsLoading ? '-' : models.length,
icon: <IconSatellite size={24} />,
path: '/system',
loading: modelsLoading,
sublabel: t('dashboard.available_models_desc')
}
];
return (
<div className={styles.dashboard}>
<div className={styles.header}>
<h1 className={styles.title}>{t('dashboard.title')}</h1>
<p className={styles.subtitle}>{t('dashboard.subtitle')}</p>
</div>
<div className={styles.connectionCard}>
<div className={styles.connectionStatus}>
<span
className={`${styles.statusDot} ${
connectionStatus === 'connected'
? styles.connected
: connectionStatus === 'connecting'
? styles.connecting
: styles.disconnected
}`}
/>
<span className={styles.statusText}>
{t(
connectionStatus === 'connected'
? 'common.connected'
: connectionStatus === 'connecting'
? 'common.connecting'
: 'common.disconnected'
)}
</span>
</div>
<div className={styles.connectionInfo}>
<span className={styles.serverUrl}>{apiBase || '-'}</span>
{serverVersion && <span className={styles.serverVersion}>v{serverVersion}</span>}
{serverBuildDate && (
<span className={styles.buildDate}>
{new Date(serverBuildDate).toLocaleDateString(i18n.language)}
</span>
)}
</div>
</div>
<div className={styles.statsGrid}>
{quickStats.map((stat) => (
<Link key={stat.path} to={stat.path} className={styles.statCard}>
<div className={styles.statIcon}>{stat.icon}</div>
<div className={styles.statContent}>
<span className={styles.statValue}>{stat.loading ? '...' : stat.value}</span>
<span className={styles.statLabel}>{stat.label}</span>
{stat.sublabel && !stat.loading && (
<span className={styles.statSublabel}>{stat.sublabel}</span>
)}
</div>
</Link>
))}
</div>
{config && (
<div className={styles.section}>
<h2 className={styles.sectionTitle}>{t('dashboard.current_config')}</h2>
<div className={styles.configGrid}>
<div className={styles.configItem}>
<span className={styles.configLabel}>{t('basic_settings.debug_enable')}</span>
<span className={`${styles.configValue} ${config.debug ? styles.enabled : styles.disabled}`}>
{config.debug ? t('common.yes') : t('common.no')}
</span>
</div>
<div className={styles.configItem}>
<span className={styles.configLabel}>{t('basic_settings.usage_statistics_enable')}</span>
<span className={`${styles.configValue} ${config.usageStatisticsEnabled ? styles.enabled : styles.disabled}`}>
{config.usageStatisticsEnabled ? t('common.yes') : t('common.no')}
</span>
</div>
<div className={styles.configItem}>
<span className={styles.configLabel}>{t('basic_settings.logging_to_file_enable')}</span>
<span className={`${styles.configValue} ${config.loggingToFile ? styles.enabled : styles.disabled}`}>
{config.loggingToFile ? t('common.yes') : t('common.no')}
</span>
</div>
<div className={styles.configItem}>
<span className={styles.configLabel}>{t('basic_settings.retry_count_label')}</span>
<span className={styles.configValue}>{config.requestRetry ?? 0}</span>
</div>
<div className={styles.configItem}>
<span className={styles.configLabel}>{t('basic_settings.ws_auth_enable')}</span>
<span className={`${styles.configValue} ${config.wsAuth ? styles.enabled : styles.disabled}`}>
{config.wsAuth ? t('common.yes') : t('common.no')}
</span>
</div>
{config.proxyUrl && (
<div className={`${styles.configItem} ${styles.configItemFull}`}>
<span className={styles.configLabel}>{t('basic_settings.proxy_url_label')}</span>
<span className={styles.configValueMono}>{config.proxyUrl}</span>
</div>
)}
</div>
<Link to="/settings" className={styles.viewMoreLink}>
{t('dashboard.edit_settings')}
</Link>
</div>
)}
</div>
);
}

View File

@@ -1,5 +1,5 @@
import { useEffect, useMemo, useState } from 'react';
import { useNavigate, useLocation } from 'react-router-dom';
import { Navigate, useNavigate, useLocation } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { Button } from '@/components/ui/Button';
import { Input } from '@/components/ui/Input';
@@ -44,12 +44,10 @@ export function LoginPage() {
init();
}, [detectedBase, restoreSession, storedBase, storedKey]);
useEffect(() => {
if (isAuthenticated) {
const redirect = (location.state as any)?.from?.pathname || '/';
navigate(redirect, { replace: true });
}
}, [isAuthenticated, navigate, location.state]);
if (isAuthenticated) {
const redirect = (location.state as any)?.from?.pathname || '/';
return <Navigate to={redirect} replace />;
}
const handleUseCurrent = () => {
setApiBase(detectedBase);

View File

@@ -2,19 +2,64 @@
.container {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
min-height: 0;
}
.pageTitle {
font-size: 28px;
font-weight: 700;
color: var(--text-primary);
margin: 0 0 $spacing-xl 0;
margin: 0 0 $spacing-lg 0;
}
.tabBar {
display: flex;
gap: $spacing-xs;
margin-bottom: $spacing-lg;
border-bottom: 1px solid var(--border-color);
}
.tabItem {
@include button-reset;
padding: 12px 20px;
font-size: 14px;
font-weight: 500;
color: var(--text-secondary);
background: transparent;
border-bottom: 2px solid transparent;
margin-bottom: -1px;
cursor: pointer;
transition:
color 0.15s ease,
border-color 0.15s ease;
&:hover {
color: var(--text-primary);
}
}
.tabActive {
color: var(--primary-color);
border-bottom-color: var(--primary-color);
}
.content {
display: flex;
flex-direction: column;
gap: $spacing-lg;
flex: 1;
min-height: 0;
}
.logCard {
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
overflow: hidden;
}
.toolbar {
@@ -22,12 +67,71 @@
align-items: center;
gap: $spacing-sm;
flex-wrap: wrap;
margin-left: auto;
@include mobile {
align-items: flex-start;
margin-left: 0;
width: 100%;
}
}
.filters {
display: flex;
align-items: center;
gap: $spacing-md;
flex-wrap: wrap;
margin-bottom: $spacing-md;
:global(.form-group) {
margin: 0;
}
}
.searchWrapper {
flex: 1;
min-width: 220px;
max-width: 420px;
}
.searchInput {
padding-right: 44px !important;
}
.searchIcon {
color: var(--text-tertiary);
pointer-events: none;
}
.searchClear {
@include button-reset;
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border-radius: $radius-full;
color: var(--text-secondary);
&:hover {
background: var(--bg-secondary);
}
}
.filterStats {
margin-left: auto;
display: inline-flex;
align-items: center;
gap: 10px;
font-size: 12px;
color: var(--text-secondary);
white-space: nowrap;
}
.removedCount {
color: var(--text-tertiary);
}
.actionButton {
white-space: nowrap;
}
@@ -56,7 +160,8 @@
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: $radius-md;
max-height: 620px;
flex: 1;
min-height: 200px;
overflow: auto;
position: relative;
}
@@ -81,6 +186,12 @@
white-space: nowrap;
}
.loadMoreStats {
display: flex;
align-items: center;
gap: $spacing-md;
}
.logList {
display: flex;
flex-direction: column;
@@ -93,7 +204,9 @@
padding: 10px 12px;
border-bottom: 1px solid var(--border-color);
border-left: 3px solid transparent;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New',
cursor: copy;
font-family:
ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New',
monospace;
font-size: 12.5px;
line-height: 1.45;
@@ -129,15 +242,8 @@
.rowMain {
display: flex;
flex-direction: column;
gap: 4px;
min-width: 0;
}
.rowMeta {
display: flex;
align-items: center;
flex-wrap: wrap;
align-items: baseline;
gap: 6px;
min-width: 0;
}
@@ -178,6 +284,15 @@
}
}
.requestIdBadge {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono',
'Courier New', monospace;
font-size: 11px;
color: #0891b2;
background: rgba(8, 145, 178, 0.1);
border-color: rgba(8, 145, 178, 0.25);
}
.statusBadge {
font-variant-numeric: tabular-nums;
}
@@ -250,6 +365,5 @@
.message {
color: var(--text-secondary);
white-space: pre-wrap;
word-break: break-word;
}

View File

@@ -1,12 +1,22 @@
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { useDeferredValue, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Card } from '@/components/ui/Card';
import { Button } from '@/components/ui/Button';
import { EmptyState } from '@/components/ui/EmptyState';
import { Input } from '@/components/ui/Input';
import { ToggleSwitch } from '@/components/ui/ToggleSwitch';
import { IconDownload, IconRefreshCw, IconTimer, IconTrash2 } from '@/components/ui/icons';
import {
IconDownload,
IconEyeOff,
IconRefreshCw,
IconSearch,
IconTimer,
IconTrash2,
IconX,
} from '@/components/ui/icons';
import { useNotificationStore, useAuthStore } from '@/stores';
import { logsApi } from '@/services/api/logs';
import { MANAGEMENT_API_PREFIX } from '@/utils/constants';
import { formatUnixTimestamp } from '@/utils/format';
import styles from './LogsPage.module.scss';
@@ -34,19 +44,22 @@ type HttpMethod = (typeof HTTP_METHODS)[number];
const HTTP_METHOD_REGEX = new RegExp(`\\b(${HTTP_METHODS.join('|')})\\b`);
const LOG_TIMESTAMP_REGEX = /^\[?(\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}:\d{2}(?:\.\d{1,3})?)\]?/;
const LOG_LEVEL_REGEX = /^\[?(trace|debug|info|warn|warning|error|fatal)\]?(?=\s|\[|$)\s*/i;
const LOG_LEVEL_REGEX = /^\[?(trace|debug|info|warn|warning|error|fatal)\s*\]?(?=\s|\[|$)\s*/i;
const LOG_SOURCE_REGEX = /^\[([^\]]+)\]/;
const LOG_LATENCY_REGEX = /\b(\d+(?:\.\d+)?)(?:\s*)(µs|us|ms|s)\b/i;
const LOG_IPV4_REGEX = /\b(?:\d{1,3}\.){3}\d{1,3}\b/;
const LOG_IPV6_REGEX = /\b(?:[a-f0-9]{0,4}:){2,7}[a-f0-9]{0,4}\b/i;
const LOG_REQUEST_ID_REGEX = /^([a-f0-9]{8}|--------)$/i;
const LOG_TIME_OF_DAY_REGEX = /^\d{1,2}:\d{2}:\d{2}(?:\.\d{1,3})?$/;
const GIN_TIMESTAMP_SEGMENT_REGEX =
/^\[GIN\]\s+(\d{4})\/(\d{2})\/(\d{2})\s*-\s*(\d{2}:\d{2}:\d{2}(?:\.\d{1,3})?)\s*$/;
const HTTP_STATUS_PATTERNS: RegExp[] = [
/\|\s*([1-5]\d{2})\s*\|/,
/\b([1-5]\d{2})\s*-/,
new RegExp(`\\b(?:${HTTP_METHODS.join('|')})\\s+\\S+\\s+([1-5]\\d{2})\\b`),
/\b(?:status|code|http)[:\s]+([1-5]\d{2})\b/i,
/\b([1-5]\d{2})\s+(?:OK|Created|Accepted|No Content|Moved|Found|Bad Request|Unauthorized|Forbidden|Not Found|Method Not Allowed|Internal Server Error|Bad Gateway|Service Unavailable|Gateway Timeout)\b/i
/\b([1-5]\d{2})\s+(?:OK|Created|Accepted|No Content|Moved|Found|Bad Request|Unauthorized|Forbidden|Not Found|Method Not Allowed|Internal Server Error|Bad Gateway|Service Unavailable|Gateway Timeout)\b/i,
];
const detectHttpStatusCode = (text: string): number | undefined => {
@@ -78,11 +91,19 @@ const extractIp = (text: string): string | undefined => {
return candidate;
};
const normalizeTimestampToSeconds = (value: string): string => {
const trimmed = value.trim();
const match = trimmed.match(/^(\d{4}-\d{2}-\d{2})[ T](\d{2}:\d{2}:\d{2})/);
if (!match) return trimmed;
return `${match[1]} ${match[2]}`;
};
type ParsedLogLine = {
raw: string;
timestamp?: string;
level?: LogLevel;
source?: string;
requestId?: string;
statusCode?: number;
latency?: string;
ip?: string;
@@ -135,6 +156,16 @@ const parseLogLine = (raw: string): ParsedLogLine => {
remaining = remaining.slice(tsMatch[0].length).trim();
}
let requestId: string | undefined;
const requestIdMatch = remaining.match(/^\[([a-f0-9]{8}|--------)\]\s*/i);
if (requestIdMatch) {
const id = requestIdMatch[1];
if (!/^-+$/.test(id)) {
requestId = id;
}
remaining = remaining.slice(requestIdMatch[0].length).trim();
}
let level: LogLevel | undefined;
const lvlMatch = remaining.match(LOG_LEVEL_REGEX);
if (lvlMatch) {
@@ -163,10 +194,40 @@ const parseLogLine = (raw: string): ParsedLogLine => {
.filter(Boolean);
const consumed = new Set<number>();
const ginIndex = segments.findIndex((segment) => GIN_TIMESTAMP_SEGMENT_REGEX.test(segment));
if (ginIndex >= 0) {
const match = segments[ginIndex].match(GIN_TIMESTAMP_SEGMENT_REGEX);
if (match) {
const ginTimestamp = `${match[1]}-${match[2]}-${match[3]} ${match[4]}`;
const normalizedGin = normalizeTimestampToSeconds(ginTimestamp);
const normalizedParsed = timestamp ? normalizeTimestampToSeconds(timestamp) : undefined;
if (!timestamp) {
timestamp = ginTimestamp;
consumed.add(ginIndex);
} else if (normalizedParsed === normalizedGin) {
consumed.add(ginIndex);
}
}
}
// request id (8-char hex or dashes)
const requestIdIndex = segments.findIndex((segment) => LOG_REQUEST_ID_REGEX.test(segment));
if (requestIdIndex >= 0) {
const match = segments[requestIdIndex].match(LOG_REQUEST_ID_REGEX);
if (match) {
const id = match[1];
if (!/^-+$/.test(id)) {
requestId = id;
}
consumed.add(requestIdIndex);
}
}
// status code
const statusIndex = segments.findIndex((segment) => /^\d{3}\b/.test(segment));
const statusIndex = segments.findIndex((segment) => /^\d{3}$/.test(segment));
if (statusIndex >= 0) {
const match = segments[statusIndex].match(/^(\d{3})\b/);
const match = segments[statusIndex].match(/^(\d{3})$/);
if (match) {
const code = Number.parseInt(match[1], 10);
if (code >= 100 && code <= 599) {
@@ -187,9 +248,7 @@ const parseLogLine = (raw: string): ParsedLogLine => {
}
// ip
const ipIndex = segments.findIndex(
(segment) => Boolean(extractIp(segment))
);
const ipIndex = segments.findIndex((segment) => Boolean(extractIp(segment)));
if (ipIndex >= 0) {
const extracted = extractIp(segments[ipIndex]);
if (extracted) {
@@ -210,6 +269,16 @@ const parseLogLine = (raw: string): ParsedLogLine => {
consumed.add(methodIndex);
}
// source (e.g. [gin_logger.go:94])
const sourceIndex = segments.findIndex((segment) => LOG_SOURCE_REGEX.test(segment));
if (sourceIndex >= 0) {
const match = segments[sourceIndex].match(LOG_SOURCE_REGEX);
if (match) {
source = match[1];
consumed.add(sourceIndex);
}
}
message = segments.filter((_, index) => !consumed.has(index)).join(' | ');
} else {
statusCode = detectHttpStatusCode(remaining);
@@ -226,29 +295,81 @@ const parseLogLine = (raw: string): ParsedLogLine => {
if (!level) level = inferLogLevel(raw);
if (message) {
const match = message.match(GIN_TIMESTAMP_SEGMENT_REGEX);
if (match) {
const ginTimestamp = `${match[1]}-${match[2]}-${match[3]} ${match[4]}`;
if (!timestamp) timestamp = ginTimestamp;
if (normalizeTimestampToSeconds(timestamp) === normalizeTimestampToSeconds(ginTimestamp)) {
message = '';
}
}
}
return {
raw,
timestamp,
level,
source,
requestId,
statusCode,
latency,
ip,
method,
path,
message
message,
};
};
const getErrorMessage = (err: unknown): string => {
if (err instanceof Error) return err.message;
if (typeof err === 'string') return err;
if (typeof err !== 'object' || err === null) return '';
if (!('message' in err)) return '';
const message = (err as { message?: unknown }).message;
return typeof message === 'string' ? message : '';
};
const copyToClipboard = async (text: string) => {
try {
await navigator.clipboard.writeText(text);
return true;
} catch {
try {
const textarea = document.createElement('textarea');
textarea.value = text;
textarea.style.position = 'fixed';
textarea.style.opacity = '0';
textarea.style.left = '-9999px';
textarea.style.top = '0';
document.body.appendChild(textarea);
textarea.focus();
textarea.select();
const ok = document.execCommand('copy');
document.body.removeChild(textarea);
return ok;
} catch {
return false;
}
}
};
type TabType = 'logs' | 'errors';
export function LogsPage() {
const { t } = useTranslation();
const { showNotification } = useNotificationStore();
const connectionStatus = useAuthStore((state) => state.connectionStatus);
const [activeTab, setActiveTab] = useState<TabType>('logs');
const [logState, setLogState] = useState<LogState>({ buffer: [], visibleFrom: 0 });
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [autoRefresh, setAutoRefresh] = useState(false);
const [searchQuery, setSearchQuery] = useState('');
const deferredSearchQuery = useDeferredValue(searchQuery);
const [hideManagementLogs, setHideManagementLogs] = useState(false);
const [errorLogs, setErrorLogs] = useState<ErrorLogItem[]>([]);
const [loadingErrors, setLoadingErrors] = useState(false);
@@ -287,9 +408,8 @@ export function LogsPage() {
try {
pendingScrollToBottomRef.current = !incremental || isNearBottom(logViewerRef.current);
const params = incremental && latestTimestampRef.current > 0
? { after: latestTimestampRef.current }
: {};
const params =
incremental && latestTimestampRef.current > 0 ? { after: latestTimestampRef.current } : {};
const data = await logsApi.fetchLogs(params);
// 更新时间戳
@@ -321,10 +441,10 @@ export function LogsPage() {
const visibleFrom = Math.max(buffer.length - INITIAL_DISPLAY_LINES, 0);
setLogState({ buffer, visibleFrom });
}
} catch (err: any) {
} catch (err: unknown) {
console.error('Failed to load logs:', err);
if (!incremental) {
setError(err?.message || t('logs.load_error'));
setError(getErrorMessage(err) || t('logs.load_error'));
}
} finally {
if (!incremental) {
@@ -340,8 +460,12 @@ export function LogsPage() {
setLogState({ buffer: [], visibleFrom: 0 });
latestTimestampRef.current = 0;
showNotification(t('logs.clear_success'), 'success');
} catch (err: any) {
showNotification(`${t('notification.delete_failed')}: ${err?.message || ''}`, 'error');
} catch (err: unknown) {
const message = getErrorMessage(err);
showNotification(
`${t('notification.delete_failed')}${message ? `: ${message}` : ''}`,
'error'
);
}
};
@@ -367,16 +491,8 @@ export function LogsPage() {
try {
const res = await logsApi.fetchErrorLogs();
// API 返回 { files: [...] }
const files = (res as any)?.files;
const list: ErrorLogItem[] = Array.isArray(files)
? files.map((f: any) => ({
name: f.name,
size: f.size,
modified: f.modified
}))
: [];
setErrorLogs(list);
} catch (err: any) {
setErrorLogs(Array.isArray(res.files) ? res.files : []);
} catch (err: unknown) {
console.error('Failed to load error logs:', err);
// 静默失败,不影响主日志显示
setErrorLogs([]);
@@ -396,8 +512,12 @@ export function LogsPage() {
a.click();
window.URL.revokeObjectURL(url);
showNotification(t('logs.error_log_download_success'), 'success');
} catch (err: any) {
showNotification(`${t('notification.download_failed')}: ${err?.message || ''}`, 'error');
} catch (err: unknown) {
const message = getErrorMessage(err);
showNotification(
`${t('notification.download_failed')}${message ? `: ${message}` : ''}`,
'error'
);
}
};
@@ -434,23 +554,65 @@ export function LogsPage() {
() => logState.buffer.slice(logState.visibleFrom),
[logState.buffer, logState.visibleFrom]
);
const trimmedSearchQuery = deferredSearchQuery.trim();
const isSearching = trimmedSearchQuery.length > 0;
const baseLines = isSearching ? logState.buffer : visibleLines;
const { filteredLines, removedCount } = useMemo(() => {
let working = baseLines;
let removed = 0;
if (hideManagementLogs) {
const next: string[] = [];
for (const line of working) {
if (line.includes(MANAGEMENT_API_PREFIX)) {
removed += 1;
} else {
next.push(line);
}
}
working = next;
}
if (trimmedSearchQuery) {
const queryLowered = trimmedSearchQuery.toLowerCase();
const next: string[] = [];
for (const line of working) {
if (line.toLowerCase().includes(queryLowered)) {
next.push(line);
} else {
removed += 1;
}
}
working = next;
}
return { filteredLines: working, removedCount: removed };
}, [baseLines, hideManagementLogs, trimmedSearchQuery]);
const parsedVisibleLines = useMemo(
() => visibleLines.map((line) => parseLogLine(line)),
[visibleLines]
() => filteredLines.map((line) => parseLogLine(line)),
[filteredLines]
);
const canLoadMore = logState.visibleFrom > 0;
const canLoadMore = !isSearching && logState.visibleFrom > 0;
const handleLogScroll = () => {
const node = logViewerRef.current;
if (!node) return;
if (isSearching) return;
if (!canLoadMore) return;
if (pendingPrependScrollRef.current) return;
if (node.scrollTop > LOAD_MORE_THRESHOLD_PX) return;
pendingPrependScrollRef.current = { scrollHeight: node.scrollHeight, scrollTop: node.scrollTop };
pendingPrependScrollRef.current = {
scrollHeight: node.scrollHeight,
scrollTop: node.scrollTop,
};
setLogState((prev) => ({
...prev,
visibleFrom: Math.max(prev.visibleFrom - LOAD_MORE_LINES, 0)
visibleFrom: Math.max(prev.visibleFrom - LOAD_MORE_LINES, 0),
}));
};
@@ -464,185 +626,290 @@ export function LogsPage() {
pendingPrependScrollRef.current = null;
}, [logState.visibleFrom]);
const copyLogLine = async (raw: string) => {
const ok = await copyToClipboard(raw);
if (ok) {
showNotification(t('logs.copy_success', { defaultValue: 'Copied to clipboard' }), 'success');
} else {
showNotification(t('logs.copy_failed', { defaultValue: 'Copy failed' }), 'error');
}
};
return (
<div className={styles.container}>
<h1 className={styles.pageTitle}>{t('logs.title')}</h1>
<div className={styles.tabBar}>
<button
type="button"
className={`${styles.tabItem} ${activeTab === 'logs' ? styles.tabActive : ''}`}
onClick={() => setActiveTab('logs')}
>
{t('logs.log_content')}
</button>
<button
type="button"
className={`${styles.tabItem} ${activeTab === 'errors' ? styles.tabActive : ''}`}
onClick={() => setActiveTab('errors')}
>
{t('logs.error_logs_modal_title')}
</button>
</div>
<div className={styles.content}>
<Card
title={t('logs.log_content')}
extra={
<div className={styles.toolbar}>
<Button
variant="secondary"
size="sm"
onClick={() => loadLogs(false)}
disabled={disableControls || loading}
className={styles.actionButton}
>
<span className={styles.buttonContent}>
<IconRefreshCw size={16} />
{t('logs.refresh_button')}
</span>
</Button>
<ToggleSwitch
checked={autoRefresh}
onChange={(value) => setAutoRefresh(value)}
disabled={disableControls}
label={
<span className={styles.switchLabel}>
<IconTimer size={16} />
{t('logs.auto_refresh')}
</span>
}
/>
<Button
variant="secondary"
size="sm"
onClick={downloadLogs}
disabled={logState.buffer.length === 0}
className={styles.actionButton}
>
<span className={styles.buttonContent}>
<IconDownload size={16} />
{t('logs.download_button')}
</span>
</Button>
<Button
variant="danger"
size="sm"
onClick={clearLogs}
disabled={disableControls}
className={styles.actionButton}
>
<span className={styles.buttonContent}>
<IconTrash2 size={16} />
{t('logs.clear_button')}
</span>
</Button>
</div>
}
>
{error && <div className="error-box">{error}</div>}
{loading ? (
<div className="hint">{t('logs.loading')}</div>
) : logState.buffer.length > 0 ? (
<div ref={logViewerRef} className={styles.logPanel} onScroll={handleLogScroll}>
{canLoadMore && (
<div className={styles.loadMoreBanner}>
<span>{t('logs.load_more_hint')}</span>
<span className={styles.loadMoreCount}>
{t('logs.hidden_lines', { count: logState.visibleFrom })}
</span>
{activeTab === 'logs' && (
<Card className={styles.logCard}>
{error && <div className="error-box">{error}</div>}
<div className={styles.filters}>
<div className={styles.searchWrapper}>
<Input
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder={t('logs.search_placeholder')}
className={styles.searchInput}
rightElement={
searchQuery ? (
<button
type="button"
className={styles.searchClear}
onClick={() => setSearchQuery('')}
title="Clear"
aria-label="Clear"
>
<IconX size={16} />
</button>
) : (
<IconSearch size={16} className={styles.searchIcon} />
)
}
/>
</div>
)}
<div className={styles.logList}>
{parsedVisibleLines.map((line, index) => {
const rowClassNames = [styles.logRow];
if (line.level === 'warn') rowClassNames.push(styles.rowWarn);
if (line.level === 'error' || line.level === 'fatal') rowClassNames.push(styles.rowError);
return (
<div key={`${logState.visibleFrom + index}-${line.raw}`} className={rowClassNames.join(' ')}>
<div className={styles.timestamp}>{line.timestamp || ''}</div>
<div className={styles.rowMain}>
<div className={styles.rowMeta}>
{line.level && (
<span
className={[
styles.badge,
line.level === 'info' ? styles.levelInfo : '',
line.level === 'warn' ? styles.levelWarn : '',
line.level === 'error' || line.level === 'fatal' ? styles.levelError : '',
line.level === 'debug' ? styles.levelDebug : '',
line.level === 'trace' ? styles.levelTrace : ''
]
.filter(Boolean)
.join(' ')}
>
{line.level.toUpperCase()}
</span>
)}
{line.source && (
<span className={styles.source} title={line.source}>
{line.source}
</span>
)}
<ToggleSwitch
checked={hideManagementLogs}
onChange={setHideManagementLogs}
label={
<span className={styles.switchLabel}>
<IconEyeOff size={16} />
{t('logs.hide_management_logs', { prefix: MANAGEMENT_API_PREFIX })}
</span>
}
/>
{typeof line.statusCode === 'number' && (
<span
className={[
styles.badge,
styles.statusBadge,
line.statusCode >= 200 && line.statusCode < 300
? styles.statusSuccess
: line.statusCode >= 300 && line.statusCode < 400
? styles.statusInfo
: line.statusCode >= 400 && line.statusCode < 500
? styles.statusWarn
: styles.statusError
].join(' ')}
>
{line.statusCode}
</span>
)}
<div className={styles.toolbar}>
<Button
variant="secondary"
size="sm"
onClick={() => loadLogs(false)}
disabled={disableControls || loading}
className={styles.actionButton}
>
<span className={styles.buttonContent}>
<IconRefreshCw size={16} />
{t('logs.refresh_button')}
</span>
</Button>
<ToggleSwitch
checked={autoRefresh}
onChange={(value) => setAutoRefresh(value)}
disabled={disableControls}
label={
<span className={styles.switchLabel}>
<IconTimer size={16} />
{t('logs.auto_refresh')}
</span>
}
/>
<Button
variant="secondary"
size="sm"
onClick={downloadLogs}
disabled={logState.buffer.length === 0}
className={styles.actionButton}
>
<span className={styles.buttonContent}>
<IconDownload size={16} />
{t('logs.download_button')}
</span>
</Button>
<Button
variant="danger"
size="sm"
onClick={clearLogs}
disabled={disableControls}
className={styles.actionButton}
>
<span className={styles.buttonContent}>
<IconTrash2 size={16} />
{t('logs.clear_button')}
</span>
</Button>
</div>
</div>
{line.latency && <span className={styles.pill}>{line.latency}</span>}
{line.ip && <span className={styles.pill}>{line.ip}</span>}
{line.method && (
<span className={[styles.badge, styles.methodBadge].join(' ')}>
{line.method}
</span>
)}
{line.path && (
<span className={styles.path} title={line.path}>
{line.path}
</span>
)}
</div>
{line.message && <div className={styles.message}>{line.message}</div>}
{loading ? (
<div className="hint">{t('logs.loading')}</div>
) : logState.buffer.length > 0 && parsedVisibleLines.length > 0 ? (
<div ref={logViewerRef} className={styles.logPanel} onScroll={handleLogScroll}>
{canLoadMore && (
<div className={styles.loadMoreBanner}>
<span>{t('logs.load_more_hint')}</span>
<div className={styles.loadMoreStats}>
<span>
{t('logs.loaded_lines', { count: parsedVisibleLines.length })}
</span>
{removedCount > 0 && (
<span className={styles.loadMoreCount}>
{t('logs.filtered_lines', { count: removedCount })}
</span>
)}
<span className={styles.loadMoreCount}>
{t('logs.hidden_lines', { count: logState.visibleFrom })}
</span>
</div>
</div>
);
})}
</div>
</div>
) : (
<EmptyState title={t('logs.empty_title')} description={t('logs.empty_desc')} />
)}
</Card>
)}
<div className={styles.logList}>
{parsedVisibleLines.map((line, index) => {
const rowClassNames = [styles.logRow];
if (line.level === 'warn') rowClassNames.push(styles.rowWarn);
if (line.level === 'error' || line.level === 'fatal')
rowClassNames.push(styles.rowError);
return (
<div
key={`${logState.visibleFrom + index}-${line.raw}`}
className={rowClassNames.join(' ')}
onDoubleClick={() => {
void copyLogLine(line.raw);
}}
title={t('logs.double_click_copy_hint', {
defaultValue: 'Double-click to copy',
})}
>
<div className={styles.timestamp}>{line.timestamp || ''}</div>
<div className={styles.rowMain}>
{line.level && (
<span
className={[
styles.badge,
line.level === 'info' ? styles.levelInfo : '',
line.level === 'warn' ? styles.levelWarn : '',
line.level === 'error' || line.level === 'fatal'
? styles.levelError
: '',
line.level === 'debug' ? styles.levelDebug : '',
line.level === 'trace' ? styles.levelTrace : '',
]
.filter(Boolean)
.join(' ')}
>
{line.level.toUpperCase()}
</span>
)}
<Card
title={t('logs.error_logs_modal_title')}
extra={
<Button variant="secondary" size="sm" onClick={loadErrorLogs} loading={loadingErrors}>
{t('common.refresh')}
</Button>
}
>
{errorLogs.length === 0 ? (
<div className="hint">{t('logs.error_logs_empty')}</div>
) : (
<div className="item-list">
{errorLogs.map((item) => (
<div key={item.name} className="item-row">
<div className="item-meta">
<div className="item-title">{item.name}</div>
<div className="item-subtitle">
{item.size ? `${(item.size / 1024).toFixed(1)} KB` : ''}{' '}
{item.modified ? formatUnixTimestamp(item.modified) : ''}
</div>
</div>
<div className="item-actions">
<Button variant="secondary" size="sm" onClick={() => downloadErrorLog(item.name)}>
{t('logs.error_logs_download')}
</Button>
{line.source && (
<span className={styles.source} title={line.source}>
{line.source}
</span>
)}
{line.requestId && (
<span
className={[styles.badge, styles.requestIdBadge].join(' ')}
title={line.requestId}
>
{line.requestId}
</span>
)}
{typeof line.statusCode === 'number' && (
<span
className={[
styles.badge,
styles.statusBadge,
line.statusCode >= 200 && line.statusCode < 300
? styles.statusSuccess
: line.statusCode >= 300 && line.statusCode < 400
? styles.statusInfo
: line.statusCode >= 400 && line.statusCode < 500
? styles.statusWarn
: styles.statusError,
].join(' ')}
>
{line.statusCode}
</span>
)}
{line.latency && <span className={styles.pill}>{line.latency}</span>}
{line.ip && <span className={styles.pill}>{line.ip}</span>}
{line.method && (
<span className={[styles.badge, styles.methodBadge].join(' ')}>
{line.method}
</span>
)}
{line.path && (
<span className={styles.path} title={line.path}>
{line.path}
</span>
)}
{line.message && <span className={styles.message}>{line.message}</span>}
</div>
</div>
);
})}
</div>
</div>
))}
</div>
) : logState.buffer.length > 0 ? (
<EmptyState
title={t('logs.search_empty_title')}
description={t('logs.search_empty_desc')}
/>
) : (
<EmptyState title={t('logs.empty_title')} description={t('logs.empty_desc')} />
)}
</Card>
)}
{activeTab === 'errors' && (
<Card
extra={
<Button variant="secondary" size="sm" onClick={loadErrorLogs} loading={loadingErrors}>
{t('common.refresh')}
</Button>
}
>
{errorLogs.length === 0 ? (
<div className="hint">{t('logs.error_logs_empty')}</div>
) : (
<div className="item-list">
{errorLogs.map((item) => (
<div key={item.name} className="item-row">
<div className="item-meta">
<div className="item-title">{item.name}</div>
<div className="item-subtitle">
{item.size ? `${(item.size / 1024).toFixed(1)} KB` : ''}{' '}
{item.modified ? formatUnixTimestamp(item.modified) : ''}
</div>
</div>
<div className="item-actions">
<Button
variant="secondary"
size="sm"
onClick={() => downloadErrorLog(item.name)}
>
{t('logs.error_logs_download')}
</Button>
</div>
</div>
))}
</div>
)}
</Card>
)}
</Card>
</div>
</div>
);

View File

@@ -59,3 +59,47 @@
color: #3b82f6;
}
}
.callbackSection {
margin-top: $spacing-md;
display: flex;
flex-direction: column;
gap: $spacing-xs;
}
.callbackActions {
display: flex;
gap: $spacing-md;
}
.authUrlBox {
background: var(--bg-secondary);
border: 1px dashed var(--border-color);
border-radius: $radius-md;
padding: $spacing-md;
display: flex;
flex-direction: column;
gap: $spacing-xs;
}
.authUrlLabel {
color: var(--text-secondary);
font-size: 14px;
}
.authUrlValue {
font-weight: 700;
color: var(--text-primary);
word-break: break-all;
overflow-wrap: anywhere;
line-height: 1.5;
max-width: 100%;
}
.authUrlActions {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: $spacing-sm;
margin-top: $spacing-sm;
}

View File

@@ -1,11 +1,10 @@
import { useEffect, useRef, useState, useMemo } from 'react';
import { useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Card } from '@/components/ui/Card';
import { Button } from '@/components/ui/Button';
import { Input } from '@/components/ui/Input';
import { useNotificationStore } from '@/stores';
import { oauthApi, type OAuthProvider, type IFlowCookieAuthResponse } from '@/services/api/oauth';
import { isLocalhost } from '@/utils/connection';
import styles from './OAuthPage.module.scss';
interface ProviderState {
@@ -14,6 +13,12 @@ interface ProviderState {
status?: 'idle' | 'waiting' | 'success' | 'error';
error?: string;
polling?: boolean;
projectId?: string;
projectIdError?: string;
callbackUrl?: string;
callbackSubmitting?: boolean;
callbackStatus?: 'success' | 'error';
callbackError?: string;
}
interface IFlowCookieState {
@@ -33,6 +38,8 @@ const PROVIDERS: { id: OAuthProvider; titleKey: string; hintKey: string; urlLabe
{ id: 'iflow', titleKey: 'auth_login.iflow_oauth_title', hintKey: 'auth_login.iflow_oauth_hint', urlLabelKey: 'auth_login.iflow_oauth_url_label' }
];
const CALLBACK_SUPPORTED: OAuthProvider[] = ['codex', 'anthropic', 'antigravity', 'gemini-cli', 'iflow'];
export function OAuthPage() {
const { t } = useTranslation();
const { showNotification } = useNotificationStore();
@@ -40,15 +47,19 @@ export function OAuthPage() {
const [iflowCookie, setIflowCookie] = useState<IFlowCookieState>({ cookie: '', loading: false });
const timers = useRef<Record<string, number>>({});
// 检测是否为本地访问
const isLocal = useMemo(() => isLocalhost(window.location.hostname), []);
useEffect(() => {
return () => {
Object.values(timers.current).forEach((timer) => window.clearInterval(timer));
};
}, []);
const updateProviderState = (provider: OAuthProvider, next: Partial<ProviderState>) => {
setStates((prev) => ({
...prev,
[provider]: { ...(prev[provider] ?? {}), ...next }
}));
};
const startPolling = (provider: OAuthProvider, state: string) => {
if (timers.current[provider]) {
clearInterval(timers.current[provider]);
@@ -57,27 +68,18 @@ export function OAuthPage() {
try {
const res = await oauthApi.getAuthStatus(state);
if (res.status === 'ok') {
setStates((prev) => ({
...prev,
[provider]: { ...prev[provider], status: 'success', polling: false }
}));
updateProviderState(provider, { status: 'success', polling: false });
showNotification(t('auth_login.codex_oauth_status_success'), 'success');
window.clearInterval(timer);
delete timers.current[provider];
} else if (res.status === 'error') {
setStates((prev) => ({
...prev,
[provider]: { ...prev[provider], status: 'error', error: res.error, polling: false }
}));
updateProviderState(provider, { status: 'error', error: res.error, polling: false });
showNotification(`${t('auth_login.codex_oauth_status_error')} ${res.error || ''}`, 'error');
window.clearInterval(timer);
delete timers.current[provider];
}
} catch (err: any) {
setStates((prev) => ({
...prev,
[provider]: { ...prev[provider], status: 'error', error: err?.message, polling: false }
}));
updateProviderState(provider, { status: 'error', error: err?.message, polling: false });
window.clearInterval(timer);
delete timers.current[provider];
}
@@ -86,24 +88,35 @@ export function OAuthPage() {
};
const startAuth = async (provider: OAuthProvider) => {
setStates((prev) => ({
...prev,
[provider]: { ...prev[provider], status: 'waiting', polling: true, error: undefined }
}));
const projectId = provider === 'gemini-cli' ? (states[provider]?.projectId || '').trim() : undefined;
if (provider === 'gemini-cli' && !projectId) {
const message = t('auth_login.gemini_cli_project_id_required');
updateProviderState(provider, { projectIdError: message });
showNotification(message, 'warning');
return;
}
if (provider === 'gemini-cli') {
updateProviderState(provider, { projectIdError: undefined });
}
updateProviderState(provider, {
status: 'waiting',
polling: true,
error: undefined,
callbackStatus: undefined,
callbackError: undefined,
callbackUrl: ''
});
try {
const res = await oauthApi.startAuth(provider);
setStates((prev) => ({
...prev,
[provider]: { ...prev[provider], url: res.url, state: res.state, status: 'waiting', polling: true }
}));
const res = await oauthApi.startAuth(
provider,
provider === 'gemini-cli' ? { projectId: projectId! } : undefined
);
updateProviderState(provider, { url: res.url, state: res.state, status: 'waiting', polling: true });
if (res.state) {
startPolling(provider, res.state);
}
} catch (err: any) {
setStates((prev) => ({
...prev,
[provider]: { ...prev[provider], status: 'error', error: err?.message, polling: false }
}));
updateProviderState(provider, { status: 'error', error: err?.message, polling: false });
showNotification(`${t('auth_login.codex_oauth_start_error')} ${err?.message || ''}`, 'error');
}
};
@@ -118,6 +131,40 @@ export function OAuthPage() {
}
};
const submitCallback = async (provider: OAuthProvider) => {
const redirectUrl = (states[provider]?.callbackUrl || '').trim();
if (!redirectUrl) {
showNotification(t('auth_login.oauth_callback_required'), 'warning');
return;
}
updateProviderState(provider, {
callbackSubmitting: true,
callbackStatus: undefined,
callbackError: undefined
});
try {
await oauthApi.submitCallback(provider, redirectUrl);
updateProviderState(provider, { callbackSubmitting: false, callbackStatus: 'success' });
showNotification(t('auth_login.oauth_callback_success'), 'success');
} catch (err: any) {
const errorMessage =
err?.status === 404
? t('auth_login.oauth_callback_upgrade_hint', {
defaultValue: 'Please update CLI Proxy API or check the connection.'
})
: err?.message;
updateProviderState(provider, {
callbackSubmitting: false,
callbackStatus: 'error',
callbackError: errorMessage
});
const notificationMessage = errorMessage
? `${t('auth_login.oauth_callback_error')} ${errorMessage}`
: t('auth_login.oauth_callback_error');
showNotification(notificationMessage, 'error');
}
};
const submitIflowCookie = async () => {
const cookie = iflowCookie.cookie.trim();
if (!cookie) {
@@ -164,36 +211,38 @@ export function OAuthPage() {
<div className={styles.content}>
{PROVIDERS.map((provider) => {
const state = states[provider.id] || {};
// 非本地访问时禁用所有 OAuth 登录方式
const isDisabled = !isLocal;
const canSubmitCallback = CALLBACK_SUPPORTED.includes(provider.id) && Boolean(state.url);
return (
<div
key={provider.id}
style={isDisabled ? { opacity: 0.6, pointerEvents: 'none' } : undefined}
>
<div key={provider.id}>
<Card
title={t(provider.titleKey)}
extra={
<Button
onClick={() => startAuth(provider.id)}
loading={state.polling}
disabled={isDisabled}
>
<Button onClick={() => startAuth(provider.id)} loading={state.polling}>
{t('common.login')}
</Button>
}
>
<div className="hint">{t(provider.hintKey)}</div>
{isDisabled && (
<div className="status-badge warning" style={{ marginTop: 8 }}>
{t('auth_login.remote_access_disabled')}
</div>
{provider.id === 'gemini-cli' && (
<Input
label={t('auth_login.gemini_cli_project_id_label')}
hint={t('auth_login.gemini_cli_project_id_hint')}
value={state.projectId || ''}
error={state.projectIdError}
onChange={(e) =>
updateProviderState(provider.id, {
projectId: e.target.value,
projectIdError: undefined
})
}
placeholder={t('auth_login.gemini_cli_project_id_placeholder')}
/>
)}
{!isDisabled && state.url && (
<div className="connection-box">
<div className="label">{t(provider.urlLabelKey)}</div>
<div className="value">{state.url}</div>
<div className="item-actions" style={{ marginTop: 8 }}>
{state.url && (
<div className={`connection-box ${styles.authUrlBox}`}>
<div className={styles.authUrlLabel}>{t(provider.urlLabelKey)}</div>
<div className={styles.authUrlValue}>{state.url}</div>
<div className={styles.authUrlActions}>
<Button variant="secondary" size="sm" onClick={() => copyLink(state.url!)}>
{t('auth_login.codex_copy_link')}
</Button>
@@ -207,7 +256,44 @@ export function OAuthPage() {
</div>
</div>
)}
{!isDisabled && state.status && state.status !== 'idle' && (
{canSubmitCallback && (
<div className={styles.callbackSection}>
<Input
label={t('auth_login.oauth_callback_label')}
hint={t('auth_login.oauth_callback_hint')}
value={state.callbackUrl || ''}
onChange={(e) =>
updateProviderState(provider.id, {
callbackUrl: e.target.value,
callbackStatus: undefined,
callbackError: undefined
})
}
placeholder={t('auth_login.oauth_callback_placeholder')}
/>
<div className={styles.callbackActions}>
<Button
variant="secondary"
size="sm"
onClick={() => submitCallback(provider.id)}
loading={state.callbackSubmitting}
>
{t('auth_login.oauth_callback_button')}
</Button>
</div>
{state.callbackStatus === 'success' && state.status === 'waiting' && (
<div className="status-badge success" style={{ marginTop: 8 }}>
{t('auth_login.oauth_callback_status_success')}
</div>
)}
{state.callbackStatus === 'error' && (
<div className="status-badge error" style={{ marginTop: 8 }}>
{t('auth_login.oauth_callback_status_error')} {state.callbackError || ''}
</div>
)}
</div>
)}
{state.status && state.status !== 'idle' && (
<div className="status-badge" style={{ marginTop: 8 }}>
{state.status === 'success'
? t('auth_login.codex_oauth_status_success')

View File

@@ -16,7 +16,6 @@ type PendingKey =
| 'switchProject'
| 'switchPreview'
| 'usage'
| 'requestLog'
| 'loggingToFile'
| 'wsAuth';
@@ -70,7 +69,7 @@ export function SettingsPage() {
const toggleSetting = async (
section: PendingKey,
rawKey: 'debug' | 'usage-statistics-enabled' | 'request-log' | 'logging-to-file' | 'ws-auth',
rawKey: 'debug' | 'usage-statistics-enabled' | 'logging-to-file' | 'ws-auth',
value: boolean,
updater: (val: boolean) => Promise<any>,
successMessage: string
@@ -81,8 +80,6 @@ export function SettingsPage() {
return config?.debug ?? false;
case 'usage-statistics-enabled':
return config?.usageStatisticsEnabled ?? false;
case 'request-log':
return config?.requestLog ?? false;
case 'logging-to-file':
return config?.loggingToFile ?? false;
case 'ws-auth':
@@ -200,21 +197,6 @@ export function SettingsPage() {
}
/>
<ToggleSwitch
label={t('basic_settings.request_log_enable')}
checked={config?.requestLog ?? false}
disabled={disableControls || pending.requestLog || loading}
onChange={(value) =>
toggleSetting(
'requestLog',
'request-log',
value,
configApi.updateRequestLog,
t('notification.request_log_updated')
)
}
/>
<ToggleSwitch
label={t('basic_settings.logging_to_file_enable')}
checked={config?.loggingToFile ?? false}

View File

@@ -3,9 +3,11 @@
.container {
width: 100%;
min-height: 100%;
display: flex;
flex-direction: column;
gap: 20px;
position: relative;
}
.header {
@@ -39,6 +41,45 @@
padding: 16px;
}
.loadingOverlay {
position: absolute;
inset: 0;
z-index: 20;
display: flex;
align-items: center;
justify-content: center;
background: rgba(243, 244, 246, 0.75);
backdrop-filter: blur(6px);
-webkit-backdrop-filter: blur(6px);
}
:global([data-theme='dark']) .loadingOverlay {
background: rgba(25, 25, 25, 0.72);
}
.loadingOverlayContent {
display: inline-flex;
align-items: center;
gap: 10px;
padding: 12px 16px;
border-radius: $radius-lg;
border: 1px solid var(--border-color);
background: var(--bg-primary);
box-shadow: var(--shadow-lg);
}
.loadingOverlaySpinner {
border-color: rgba(59, 130, 246, 0.25);
border-top-color: var(--primary-color);
box-shadow: 0 0 10px rgba(59, 130, 246, 0.25);
}
.loadingOverlayText {
font-size: 13px;
font-weight: 600;
color: var(--text-secondary);
}
// Stats Grid
.statsGrid {
display: grid;

View File

@@ -16,6 +16,7 @@ import { Line } from 'react-chartjs-2';
import { Card } from '@/components/ui/Card';
import { Button } from '@/components/ui/Button';
import { Input } from '@/components/ui/Input';
import { LoadingSpinner } from '@/components/ui/LoadingSpinner';
import { IconDiamond, IconDollarSign, IconSatellite, IconTimer, IconTrendingUp } from '@/components/ui/icons';
import { useMediaQuery } from '@/hooks/useMediaQuery';
import { useThemeStore } from '@/stores';
@@ -516,6 +517,14 @@ export function UsagePage() {
return (
<div className={styles.container}>
{loading && !usage && (
<div className={styles.loadingOverlay} aria-busy="true">
<div className={styles.loadingOverlayContent}>
<LoadingSpinner size={28} className={styles.loadingOverlaySpinner} />
<span className={styles.loadingOverlayText}>{t('common.loading')}</span>
</div>
</div>
)}
<div className={styles.header}>
<h1 className={styles.pageTitle}>{t('usage_stats.title')}</h1>
<Button

View File

@@ -18,9 +18,6 @@ export const ampcodeApi = {
updateUpstreamApiKey: (apiKey: string) => apiClient.put('/ampcode/upstream-api-key', { value: apiKey }),
clearUpstreamApiKey: () => apiClient.delete('/ampcode/upstream-api-key'),
updateRestrictManagementToLocalhost: (enabled: boolean) =>
apiClient.put('/ampcode/restrict-management-to-localhost', { value: enabled }),
async getModelMappings(): Promise<AmpcodeModelMapping[]> {
const data = await apiClient.get('/ampcode/model-mappings');
const list = data?.['model-mappings'] ?? data?.modelMappings ?? data?.items ?? data;

View File

@@ -82,6 +82,10 @@ class ApiClient {
(config) => {
// 设置 baseURL
config.baseURL = this.apiBase;
if (config.url) {
// Normalize deprecated Gemini endpoint to the current path.
config.url = config.url.replace(/\/generative-language-api-key\b/g, '/gemini-api-key');
}
// 添加认证头
if (this.managementKey) {

View File

@@ -3,6 +3,7 @@
*/
import { apiClient } from './client';
import { LOGS_TIMEOUT_MS } from '@/utils/constants';
export interface LogsQuery {
after?: number;
@@ -14,16 +15,28 @@ export interface LogsResponse {
'latest-timestamp': number;
}
export interface ErrorLogFile {
name: string;
size?: number;
modified?: number;
}
export interface ErrorLogsResponse {
files?: ErrorLogFile[];
}
export const logsApi = {
fetchLogs: (params: LogsQuery = {}): Promise<LogsResponse> =>
apiClient.get('/logs', { params }),
apiClient.get('/logs', { params, timeout: LOGS_TIMEOUT_MS }),
clearLogs: () => apiClient.delete('/logs'),
fetchErrorLogs: () => apiClient.get('/request-error-logs'),
fetchErrorLogs: (): Promise<ErrorLogsResponse> =>
apiClient.get('/request-error-logs', { timeout: LOGS_TIMEOUT_MS }),
downloadErrorLog: (filename: string) =>
apiClient.getRaw(`/request-error-logs/${encodeURIComponent(filename)}`, {
responseType: 'blob'
})
responseType: 'blob',
timeout: LOGS_TIMEOUT_MS
}),
};

View File

@@ -17,6 +17,10 @@ export interface OAuthStartResponse {
state?: string;
}
export interface OAuthCallbackResponse {
status: 'ok';
}
export interface IFlowCookieAuthResponse {
status: 'ok' | 'error';
error?: string;
@@ -27,18 +31,37 @@ export interface IFlowCookieAuthResponse {
}
const WEBUI_SUPPORTED: OAuthProvider[] = ['codex', 'anthropic', 'antigravity', 'gemini-cli', 'iflow'];
const CALLBACK_PROVIDER_MAP: Partial<Record<OAuthProvider, string>> = {
'gemini-cli': 'gemini'
};
export const oauthApi = {
startAuth: (provider: OAuthProvider) =>
apiClient.get<OAuthStartResponse>(`/${provider}-auth-url`, {
params: WEBUI_SUPPORTED.includes(provider) ? { is_webui: true } : undefined
}),
startAuth: (provider: OAuthProvider, options?: { projectId?: string }) => {
const params: Record<string, string | boolean> = {};
if (WEBUI_SUPPORTED.includes(provider)) {
params.is_webui = true;
}
if (provider === 'gemini-cli' && options?.projectId) {
params.project_id = options.projectId;
}
return apiClient.get<OAuthStartResponse>(`/${provider}-auth-url`, {
params: Object.keys(params).length ? params : undefined
});
},
getAuthStatus: (state: string) =>
apiClient.get<{ status: 'ok' | 'wait' | 'error'; error?: string }>(`/get-auth-status`, {
params: { state }
}),
submitCallback: (provider: OAuthProvider, redirectUrl: string) => {
const callbackProvider = CALLBACK_PROVIDER_MAP[provider] ?? provider;
return apiClient.post<OAuthCallbackResponse>('/oauth-callback', {
provider: callbackProvider,
redirect_url: redirectUrl
});
},
/** iFlow cookie 认证 */
iflowCookieAuth: (cookie: string) =>
apiClient.post<IFlowCookieAuthResponse>('/iflow-auth-url', { cookie })

View File

@@ -48,6 +48,7 @@ const serializeApiKeyEntry = (entry: ApiKeyEntry) => {
const serializeProviderKey = (config: ProviderKeyConfig) => {
const payload: Record<string, any> = { 'api-key': config.apiKey };
if (config.prefix?.trim()) payload.prefix = config.prefix.trim();
if (config.baseUrl) payload['base-url'] = config.baseUrl;
if (config.proxyUrl) payload['proxy-url'] = config.proxyUrl;
const headers = serializeHeaders(config.headers);
@@ -62,6 +63,7 @@ const serializeProviderKey = (config: ProviderKeyConfig) => {
const serializeGeminiKey = (config: GeminiKeyConfig) => {
const payload: Record<string, any> = { 'api-key': config.apiKey };
if (config.prefix?.trim()) payload.prefix = config.prefix.trim();
if (config.baseUrl) payload['base-url'] = config.baseUrl;
const headers = serializeHeaders(config.headers);
if (headers) payload.headers = headers;
@@ -79,6 +81,7 @@ const serializeOpenAIProvider = (provider: OpenAIProviderConfig) => {
? provider.apiKeyEntries.map((entry) => serializeApiKeyEntry(entry))
: []
};
if (provider.prefix?.trim()) payload.prefix = provider.prefix.trim();
const headers = serializeHeaders(provider.headers);
if (headers) payload.headers = headers;
const models = serializeModelAliases(provider.models);

View File

@@ -70,6 +70,12 @@ const normalizeExcludedModels = (input: any): string[] => {
return normalized;
};
const normalizePrefix = (value: any): string | undefined => {
if (value === undefined || value === null) return undefined;
const trimmed = String(value).trim();
return trimmed ? trimmed : undefined;
};
const normalizeApiKeyEntry = (entry: any): ApiKeyEntry | null => {
if (!entry) return null;
const apiKey = entry['api-key'] ?? entry.apiKey ?? entry.key ?? (typeof entry === 'string' ? entry : '');
@@ -93,6 +99,8 @@ const normalizeProviderKeyConfig = (item: any): ProviderKeyConfig | null => {
if (!trimmed) return null;
const config: ProviderKeyConfig = { apiKey: trimmed };
const prefix = normalizePrefix(item.prefix ?? item['prefix']);
if (prefix) config.prefix = prefix;
const baseUrl = item['base-url'] ?? item.baseUrl;
const proxyUrl = item['proxy-url'] ?? item.proxyUrl;
if (baseUrl) config.baseUrl = String(baseUrl);
@@ -118,6 +126,8 @@ const normalizeGeminiKeyConfig = (item: any): GeminiKeyConfig | null => {
if (!trimmed) return null;
const config: GeminiKeyConfig = { apiKey: trimmed };
const prefix = normalizePrefix(item.prefix ?? item['prefix']);
if (prefix) config.prefix = prefix;
const baseUrl = item['base-url'] ?? item.baseUrl ?? item['base_url'];
if (baseUrl) config.baseUrl = String(baseUrl);
const headers = normalizeHeaders(item.headers);
@@ -155,6 +165,8 @@ const normalizeOpenAIProvider = (provider: any): OpenAIProviderConfig | null =>
apiKeyEntries
};
const prefix = normalizePrefix(provider.prefix ?? provider['prefix']);
if (prefix) result.prefix = prefix;
if (headers) result.headers = headers;
if (models.length) result.models = models;
if (priority !== undefined) result.priority = Number(priority);
@@ -205,15 +217,6 @@ const normalizeAmpcodeConfig = (payload: any): AmpcodeConfig | undefined => {
const upstreamApiKey = source['upstream-api-key'] ?? source.upstreamApiKey ?? source['upstream_api_key'];
if (upstreamApiKey) config.upstreamApiKey = String(upstreamApiKey);
const restrictManagementToLocalhost = normalizeBoolean(
source['restrict-management-to-localhost'] ??
source.restrictManagementToLocalhost ??
source['restrict_management_to_localhost']
);
if (restrictManagementToLocalhost !== undefined) {
config.restrictManagementToLocalhost = restrictManagementToLocalhost;
}
const forceModelMappings = normalizeBoolean(
source['force-model-mappings'] ?? source.forceModelMappings ?? source['force_model_mappings']
);

View File

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

View File

@@ -308,6 +308,12 @@ textarea {
}
}
.switch-label-left {
.label {
order: -1;
}
}
.pill {
padding: 4px 10px;
border-radius: $radius-full;
@@ -410,6 +416,17 @@ textarea {
background: var(--bg-primary);
}
.request-log-modal {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: $spacing-md;
.status-badge {
margin-bottom: 0;
}
}
.empty-state {
border: 1px dashed var(--border-color);
border-radius: $radius-lg;

View File

@@ -336,6 +336,8 @@
display: flex;
flex-direction: column;
gap: $spacing-lg;
min-height: 0;
overflow: hidden;
@media (max-width: $breakpoint-mobile) {
padding: $spacing-md;

View File

@@ -10,7 +10,6 @@ export interface AmpcodeModelMapping {
export interface AmpcodeConfig {
upstreamUrl?: string;
upstreamApiKey?: string;
restrictManagementToLocalhost?: boolean;
modelMappings?: AmpcodeModelMapping[];
forceModelMappings?: boolean;
}

View File

@@ -18,6 +18,7 @@ export interface ApiKeyEntry {
export interface GeminiKeyConfig {
apiKey: string;
prefix?: string;
baseUrl?: string;
headers?: Record<string, string>;
excludedModels?: string[];
@@ -25,6 +26,7 @@ export interface GeminiKeyConfig {
export interface ProviderKeyConfig {
apiKey: string;
prefix?: string;
baseUrl?: string;
proxyUrl?: string;
headers?: Record<string, string>;
@@ -34,6 +36,7 @@ export interface ProviderKeyConfig {
export interface OpenAIProviderConfig {
name: string;
prefix?: string;
baseUrl: string;
apiKeyEntries: ApiKeyEntry[];
headers?: Record<string, string>;

View File

@@ -18,6 +18,7 @@ export const LOG_REFRESH_DELAY_MS = 500;
// 日志相关
export const MAX_LOG_LINES = 2000;
export const LOG_FETCH_LIMIT = 2500;
export const LOGS_TIMEOUT_MS = 60 * 1000;
// 认证文件分页
export const DEFAULT_AUTH_FILES_PAGE_SIZE = 20;