Compare commits

...

72 Commits

Author SHA1 Message Date
Supra4E8C
3a66dc225d feat(auth): add remember-password login and clear local auth data card 2025-12-31 20:04:32 +08:00
Supra4E8C
eadfd7a957 feat(quota): group Gemini CLI buckets and refine Gemini quota groups 2025-12-30 21:48:12 +08:00
Supra4E8C
f739e0b372 style(config): double editor height 2025-12-30 18:29:47 +08:00
Supra4E8C
23fb88e5fd feat(quota): add zustand store for quota state caching 2025-12-30 18:29:28 +08:00
Supra4E8C
49b9259452 feat(quota): add quota page and update i18n 2025-12-30 14:13:04 +08:00
Supra4E8C
4e26b6c92d feat(auth-files): add Gemini CLI quota card and API call 2025-12-30 12:18:20 +08:00
Supra4E8C
215ce61b48 fix: error display 2025-12-30 00:17:51 +08:00
Supra4E8C
a48e06a28c fix(auth-files): use account id for codex quota and show remaining 2025-12-29 23:13:55 +08:00
Supra4E8C
8a59ab73a1 chore(i18n): update antigravity refresh label 2025-12-29 12:33:04 +08:00
Supra4E8C
66d58288b4 fix(auth): update antigravity fetchAvailableModels endpoint 2025-12-29 12:09:37 +08:00
Supra4E8C
be3f58f0a8 fix(auth-files): cache Antigravity quota to avoid auto refresh on reopen 2025-12-29 01:18:18 +08:00
Supra4E8C
c299e403cc feat(auth-files): add Antigravity quota page size 2025-12-29 00:48:31 +08:00
Supra4E8C
769c05e459 fix: defult language 2025-12-29 00:17:44 +08:00
Supra4E8C
5ef3406068 fix(config-page): restore page and editor scrolling with fixed card height 2025-12-28 23:50:08 +08:00
Supra4E8C
95cbfb8c59 feat(auth-files): add antigravity quota cards with grouping, pagination, and i18n 2025-12-28 23:39:26 +08:00
Supra4E8C
c17217875c fix(ai-providers): route openai compat model fetch/test through api-call to avoid CORS 2025-12-28 18:10:21 +08:00
Supra4E8C
981f7ac9b2 refactor(i18n): support per-provider empty state and OAuth messages 2025-12-28 17:41:25 +08:00
Supra4E8C
762db81252 fix: lang fix 2025-12-28 11:53:58 +08:00
Supra4E8C
79f6d87d7b fix(api): improve version header parsing for non-plain headers 2025-12-28 10:55:34 +08:00
Supra4E8C
c5d4356d6c fix 2025-12-28 01:04:22 +08:00
Supra4E8C
c989dbf1b6 feat(auth-files): add oauth excluded provider tag 2025-12-28 00:48:36 +08:00
Supra4E8C
3cffa19319 fix(footer): prevent copying management center version text 2025-12-28 00:03:16 +08:00
Supra4E8C
2367f122a8 fix(logs): remove action hint text 2025-12-27 23:59:57 +08:00
Supra4E8C
69a8e1657e fix(layout): keep header fixed on mobile scroll 2025-12-27 23:53:23 +08:00
Supra4E8C
987ce0ec4b feat(modal): add floating close button and collapse animatio 2025-12-27 23:40:56 +08:00
Supra4E8C
03bf58671e fix(logs): improve responsive layout for logs page on mobile and small screens 2025-12-27 17:07:31 +08:00
Supra4E8C
cb6b810d6d perf(providers,auth-files): cache status bar data and add auto-refresh 2025-12-27 15:26:38 +08:00
Supra4E8C
408e6e5872 feat(auth-files): add visual status bar for auth file health monitoring 2025-12-27 15:12:38 +08:00
Supra4E8C
b3808add0f feat(providers): add visual status bar for API key health monitoring 2025-12-27 15:02:32 +08:00
Supra4E8C
0b2e6efe28 fix(logs): keep log panel scroll within viewport 2025-12-27 14:42:10 +08:00
Supra4E8C
8ca6d31a26 feat(oauth): add vertex json login via vertex/import 2025-12-27 08:02:46 +08:00
Supra4E8C
66c6073bbc feat(usage): add usage stats export/import actions 2025-12-27 00:49:41 +08:00
Supra4E8C
2dd3f233d3 feat(logs): add long-press request log download and hints 2025-12-27 00:30:18 +08:00
Supra4E8C
7a65e03ad3 fix(layout): prevent footer gap on non-scroll pages 2025-12-26 23:53:13 +08:00
Supra4E8C
589a5bad4c fix(oauth): use resolvedTheme for provider icons 2025-12-26 18:54:16 +08:00
Supra4E8C
bcaa0c8545 Merge branch 'main' of https://github.com/router-for-me/Cli-Proxy-API-Management-Center 2025-12-26 18:42:47 +08:00
Supra4E8C
312a06a8b8 fix(logs): clarify error request logs list behavior 2025-12-26 18:42:41 +08:00
Supra4E8C
24861dabd2 Merge pull request #29 from XYenon/feat/auto-theme-mode
feat: add auto theme mode (follow system preference)
2025-12-26 18:04:40 +08:00
Supra4E8C
ea1bdc3ac1 fix(logs): make error log panel scrollable 2025-12-26 17:52:23 +08:00
Supra4E8C
46701b40ad fix(layout): restore scroll and set panel heights 2025-12-26 17:37:49 +08:00
XYenon
c9fc22bae5 fix: use resolvedTheme instead of theme for dark mode detection
When theme is set to 'auto', checking theme === 'dark' returns false even
when the system preference is dark mode. This caused charts and custom
styles to use light mode colors in a dark UI.

Fixed by using resolvedTheme which correctly resolves to 'light' or 'dark'
based on system preference when in auto mode.

Fixes the issue reported in PR #29 review.
2025-12-26 00:19:04 +08:00
Supra4E8C
ff9bd8a33b fix(ai-providers): allow empty Claude base URL 2025-12-25 23:55:31 +08:00
Supra4E8C
d0c376fc31 fix(layout): restore full-height panels on mobile/HiDPI 2025-12-25 23:33:52 +08:00
Supra4E8C
d09db34c34 Merge pull request #28 from notdp/main
feat(oauth): add provider icons to oauth login cards
2025-12-25 20:57:51 +08:00
dp
9dd37245bd feat(icons): convert png to svg and add icons to ai providers page 2025-12-25 20:48:12 +08:00
Supra4E8C
834ba43231 fix: ConfigPage.module.scss LogPage.moudle.scss 2025-12-25 01:09:00 +08:00
Supra4E8C
684502c8b6 fix: layout.scss 2025-12-24 23:39:40 +08:00
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
XYenon
961cc802b2 fix: address PR review feedback
- Use unique ID prefix for clipPath to avoid duplicate ID issues
- Add cleanup function to initializeTheme to prevent memory leak
- Change tooltip to show action description instead of current theme name
2025-12-24 00:18:44 +08:00
XYenon
5f7df33469 feat: add auto theme mode (follow system preference)
- Add 'auto' to Theme type
- Implement cycleTheme (light -> dark -> auto)
- Add autoTheme icon (sun with half-filled center)
- Listen to system theme changes in auto mode

Also includes some Prettier formatting fixes.
2025-12-24 00:02:59 +08:00
dp
39847fa56d feat(oauth): add provider icons to oauth login cards 2025-12-23 14:05:35 +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
74 changed files with 7841 additions and 1744 deletions

View File

@@ -15,6 +15,9 @@ jobs:
steps: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@v4 uses: actions/checkout@v4
with:
fetch-depth: 0
fetch-tags: true
- name: Setup Node.js - name: Setup Node.js
uses: actions/setup-node@v4 uses: actions/setup-node@v4
@@ -36,27 +39,25 @@ jobs:
mv index.html management.html mv index.html management.html
ls -lh 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 - name: Create Release
uses: softprops/action-gh-release@v1 uses: softprops/action-gh-release@v1
with: with:
files: dist/management.html files: dist/management.html
body: | body_path: release-notes.md
## 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
draft: false draft: false
prerelease: false prerelease: false
env: env:

3
.gitignore vendored
View File

@@ -10,6 +10,8 @@ api.md
usage.json usage.json
CLAUDE.md CLAUDE.md
AGENTS.md AGENTS.md
antigravity_usage.json
codex_usage.json
node_modules node_modules
dist dist
@@ -17,6 +19,7 @@ dist-ssr
*.local *.local
# Editor directories and files # Editor directories and files
settings.local.json
.vscode/* .vscode/*
!.vscode/extensions.json !.vscode/extensions.json
.idea .idea

212
README.md
View File

@@ -1,6 +1,6 @@
# CLI Proxy API Management Center # 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) [中文文档](README_CN.md)
@@ -10,181 +10,121 @@ A modern React-based WebUI for managing the CLI Proxy API, completely refactored
Since version 6.0.19, the WebUI ships with the main program; access it via `/management.html` on the API port once the service is running. Since version 6.0.19, the 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 ## Quick start
- **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
### User Experience ### Option A: Use the WebUI bundled in CLIProxyAPI (recommended)
- **Responsive Design**: Full mobile support with collapsible sidebar 1. Start your CLI Proxy API service.
- **Theme System**: Light/dark mode with persistent preference 2. Open: `http://<host>:<api_port>/management.html`
- **Internationalization**: English and Simplified Chinese (zh-CN) with seamless switching 3. Enter your **management key** and connect.
- **Real-time Feedback**: Toast notifications for all operations
- **Security**: Masked secrets, encrypted local storage
## Tech Stack The address is auto-detected from the current page URL; manual override is supported.
- **Frontend Framework**: React 19 with TypeScript ### Option B: Run the dev server
- **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
```bash ```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 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 ```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 Tip: opening `dist/index.html` via `file://` may be blocked by browser CORS; serving it (preview/static server) is more reliable.
npm run build # TypeScript check + Vite production build
```
The build outputs a single `dist/index.html` file with all assets inlined. ## Connecting to the server
### Other Commands ### API address
```bash You can enter any of the following; the UI will normalize it:
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)
```
## 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)** The management key is sent with every request as:
After starting the CLI Proxy API service, visit `http://your-server:8317/management.html`
2. **Standalone (Built file)** - `Authorization: Bearer <MANAGEMENT_KEY>` (default)
Open the built `dist/index.html` directly in a browser, or host it on any static file server
3. **Development Server** This is different from the proxy `api-keys` you manage inside the UI (those are for client requests to the proxy endpoints).
Run `npm run dev` and open `http://localhost:5173`
### Initial Configuration ### Remote management
1. The login page auto-detects the current address; you can modify it if needed If you connect from a non-localhost browser, the server must allow remote management (e.g. `allow-remote-management: true`).
2. Enter your management key See `api.md` for the full authentication rules, server-side limits, and edge cases.
3. Click Connect to authenticate
4. Credentials are encrypted and saved locally for auto-login
> **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.
``` ## Build & release notes
├── 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
```
### 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) ## Security notes
- **API Client**: Singleton `apiClient` in `src/services/api/client.ts` with auth interceptors
- **State Management**: Zustand stores with localStorage persistence for auth/theme/language - The management key is stored in browser `localStorage` using a lightweight obfuscation format (`enc::v1::...`) to avoid plaintext storage; treat it as sensitive.
- **Styling**: SCSS variables auto-injected; CSS Modules for component-scoped styles - Use a dedicated browser profile/device for management. Be cautious when enabling remote management and evaluate its exposure surface.
- **Build Output**: Single-file bundle for easy distribution (all assets inlined)
## Troubleshooting ## 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 ## Development
2. Check if the API address is correct
3. Verify that the management key is valid
4. Ensure your firewall allows the connection
### Data Not Updating ```bash
npm run dev # Vite dev server
1. Click the "Refresh All" button in the header npm run build # tsc + Vite build
2. Check your network connection npm run preview # serve dist locally
3. Open browser DevTools console for error details npm run lint # ESLint (fails on warnings)
npm run format # Prettier
### Logs & Config Editor npm run type-check # tsc --noEmit
```
- **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
## Contributing ## Contributing
We welcome Issues and Pull Requests! Please follow these guidelines: Issues and PRs are welcome. Please include:
1. Fork the repository - Reproduction steps (server version + UI version)
2. Create a feature branch (`git checkout -b feature/amazing-feature`) - Screenshots for UI changes
3. Commit your changes with clear messages - Verification notes (`npm run lint`, `npm run type-check`)
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
## License ## License
This project is licensed under the MIT License. MIT

View File

@@ -1,6 +1,6 @@
# CLI Proxy API 管理中心 # CLI Proxy API 管理中心
用于管理 CLI Proxy API 的现代化 React Web 界面,采用全新技术栈重构,提供更好的可维护性、类型安全性和用户体验 用于管理与排障 **CLI Proxy API** 的单文件 WebUIReact + TypeScript通过 **Management API** 完成配置、凭据、日志与统计等运维工作
[English](README.md) [English](README.md)
@@ -8,183 +8,123 @@
**示例地址**: https://remote.router-for.me/ **示例地址**: https://remote.router-for.me/
**最低版本要求**: ≥ 6.3.0(推荐 ≥ 6.5.0 **最低版本要求**: ≥ 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.
## 功能特点 ## 这是什么(以及不是什么)
### 核心功能 - 本仓库只包含 Web 管理界面本身,通过 CLI Proxy API 的 **Management API**`/v0/management`)读取/修改配置、上传凭据、查看日志与使用统计。
-**不是** 代理本体,不参与流量转发。
- **登录与认证**: 自动检测当前地址(支持手动修改),加密自动登录,会话持久化
- **基础设置**: 调试模式、代理 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 支持)
## 快速开始 ## 快速开始
### 环境要求 ### 方式 A使用 CLIProxyAPI 自带的 WebUI推荐
- Node.js 18+(推荐 LTS 版本) 1. 启动 CLI Proxy API 服务。
- npm 9+ 2. 打开:`http://<host>:<api_port>/management.html`
3. 输入 **管理密钥** 并连接。
### 安装 页面会根据当前地址自动推断 API 地址,也支持手动修改。
### 方式 B开发调试
```bash ```bash
# 克隆仓库
git clone https://github.com/router-for-me/Cli-Proxy-API-Management-Center.git
cd Cli-Proxy-API-Management-Center
# 安装依赖
npm install npm install
npm run dev
``` ```
### 开发 打开 `http://localhost:5173`,然后连接到你的 CLI Proxy API 实例。
### 方式 C构建单文件 HTML
```bash ```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 ```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 本仓库 - 复现步骤(服务端版本 + UI 版本)
2. 创建功能分支(`git checkout -b feature/amazing-feature` - UI 改动截图
3. 提交更改,使用清晰的提交信息 - 验证记录(`npm run lint``npm run type-check`
4. 推送到分支
5. 开启 Pull Request
### 开发规范
- 提交前运行 `npm run lint``npm run type-check`
- 遵循现有代码模式和命名规范
- 使用 TypeScript 严格模式
- 编写有意义的提交信息
## 许可证 ## 许可证
本项目采用 MIT 许可证。 MIT

View File

@@ -1,5 +1,5 @@
<!doctype html> <!doctype html>
<html lang="en"> <html lang="zh-CN">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20xmlns%3Axlink%3D%22http%3A%2F%2Fwww.w3.org%2F1999%2Fxlink%22%20aria-hidden%3D%22true%22%20role%3D%22img%22%20class%3D%22iconify%20iconify--logos%22%20width%3D%2231.88%22%20height%3D%2232%22%20preserveAspectRatio%3D%22xMidYMid%20meet%22%20viewBox%3D%220%200%20256%20257%22%3E%3Cdefs%3E%3ClinearGradient%20id%3D%22IconifyId1813088fe1fbc01fb466%22%20x1%3D%22-.828%25%22%20x2%3D%2257.636%25%22%20y1%3D%227.652%25%22%20y2%3D%2278.411%25%22%3E%3Cstop%20offset%3D%220%25%22%20stop-color%3D%22%2341D1FF%22%3E%3C%2Fstop%3E%3Cstop%20offset%3D%22100%25%22%20stop-color%3D%22%23BD34FE%22%3E%3C%2Fstop%3E%3C%2FlinearGradient%3E%3ClinearGradient%20id%3D%22IconifyId1813088fe1fbc01fb467%22%20x1%3D%2243.376%25%22%20x2%3D%2250.316%25%22%20y1%3D%222.242%25%22%20y2%3D%2289.03%25%22%3E%3Cstop%20offset%3D%220%25%22%20stop-color%3D%22%23FFEA83%22%3E%3C%2Fstop%3E%3Cstop%20offset%3D%228.333%25%22%20stop-color%3D%22%23FFDD35%22%3E%3C%2Fstop%3E%3Cstop%20offset%3D%22100%25%22%20stop-color%3D%22%23FFA800%22%3E%3C%2Fstop%3E%3C%2FlinearGradient%3E%3C%2Fdefs%3E%3Cpath%20fill%3D%22url(%23IconifyId1813088fe1fbc01fb466)%22%20d%3D%22M255.153%2037.938L134.897%20252.976c-2.483%204.44-8.862%204.466-11.382.048L.875%2037.958c-2.746-4.814%201.371-10.646%206.827-9.67l120.385%2021.517a6.537%206.537%200%200%200%202.322-.004l117.867-21.483c5.438-.991%209.574%204.796%206.877%209.62Z%22%3E%3C%2Fpath%3E%3Cpath%20fill%3D%22url(%23IconifyId1813088fe1fbc01fb467)%22%20d%3D%22M185.432.063L96.44%2017.501a3.268%203.268%200%200%200-2.634%203.014l-5.474%2092.456a3.268%203.268%200%200%200%203.997%203.378l24.777-5.718c2.318-.535%204.413%201.507%203.936%203.838l-7.361%2036.047c-.495%202.426%201.782%204.5%204.151%203.78l15.304-4.649c2.372-.72%204.652%201.36%204.15%203.788l-11.698%2056.621c-.732%203.542%203.979%205.473%205.943%202.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505%204.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z%22%3E%3C%2Fpath%3E%3C%2Fsvg%3E" /> <link rel="icon" type="image/svg+xml" href="data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20xmlns%3Axlink%3D%22http%3A%2F%2Fwww.w3.org%2F1999%2Fxlink%22%20aria-hidden%3D%22true%22%20role%3D%22img%22%20class%3D%22iconify%20iconify--logos%22%20width%3D%2231.88%22%20height%3D%2232%22%20preserveAspectRatio%3D%22xMidYMid%20meet%22%20viewBox%3D%220%200%20256%20257%22%3E%3Cdefs%3E%3ClinearGradient%20id%3D%22IconifyId1813088fe1fbc01fb466%22%20x1%3D%22-.828%25%22%20x2%3D%2257.636%25%22%20y1%3D%227.652%25%22%20y2%3D%2278.411%25%22%3E%3Cstop%20offset%3D%220%25%22%20stop-color%3D%22%2341D1FF%22%3E%3C%2Fstop%3E%3Cstop%20offset%3D%22100%25%22%20stop-color%3D%22%23BD34FE%22%3E%3C%2Fstop%3E%3C%2FlinearGradient%3E%3ClinearGradient%20id%3D%22IconifyId1813088fe1fbc01fb467%22%20x1%3D%2243.376%25%22%20x2%3D%2250.316%25%22%20y1%3D%222.242%25%22%20y2%3D%2289.03%25%22%3E%3Cstop%20offset%3D%220%25%22%20stop-color%3D%22%23FFEA83%22%3E%3C%2Fstop%3E%3Cstop%20offset%3D%228.333%25%22%20stop-color%3D%22%23FFDD35%22%3E%3C%2Fstop%3E%3Cstop%20offset%3D%22100%25%22%20stop-color%3D%22%23FFA800%22%3E%3C%2Fstop%3E%3C%2FlinearGradient%3E%3C%2Fdefs%3E%3Cpath%20fill%3D%22url(%23IconifyId1813088fe1fbc01fb466)%22%20d%3D%22M255.153%2037.938L134.897%20252.976c-2.483%204.44-8.862%204.466-11.382.048L.875%2037.958c-2.746-4.814%201.371-10.646%206.827-9.67l120.385%2021.517a6.537%206.537%200%200%200%202.322-.004l117.867-21.483c5.438-.991%209.574%204.796%206.877%209.62Z%22%3E%3C%2Fpath%3E%3Cpath%20fill%3D%22url(%23IconifyId1813088fe1fbc01fb467)%22%20d%3D%22M185.432.063L96.44%2017.501a3.268%203.268%200%200%200-2.634%203.014l-5.474%2092.456a3.268%203.268%200%200%200%203.997%203.378l24.777-5.718c2.318-.535%204.413%201.507%203.936%203.838l-7.361%2036.047c-.495%202.426%201.782%204.5%204.151%203.78l15.304-4.649c2.372-.72%204.652%201.36%204.15%203.788l-11.698%2056.621c-.732%203.542%203.979%205.473%205.943%202.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505%204.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z%22%3E%3C%2Fpath%3E%3C%2Fsvg%3E" />

View File

@@ -1,29 +1,42 @@
import { useEffect } from 'react'; import { useCallback, useEffect, useState } from 'react';
import { HashRouter, Navigate, Route, Routes } from 'react-router-dom'; import { HashRouter, Navigate, Route, Routes } from 'react-router-dom';
import { LoginPage } from '@/pages/LoginPage'; import { LoginPage } from '@/pages/LoginPage';
import { DashboardPage } from '@/pages/DashboardPage';
import { SettingsPage } from '@/pages/SettingsPage'; import { SettingsPage } from '@/pages/SettingsPage';
import { ApiKeysPage } from '@/pages/ApiKeysPage'; import { ApiKeysPage } from '@/pages/ApiKeysPage';
import { AiProvidersPage } from '@/pages/AiProvidersPage'; import { AiProvidersPage } from '@/pages/AiProvidersPage';
import { AuthFilesPage } from '@/pages/AuthFilesPage'; import { AuthFilesPage } from '@/pages/AuthFilesPage';
import { OAuthPage } from '@/pages/OAuthPage'; import { OAuthPage } from '@/pages/OAuthPage';
import { QuotaPage } from '@/pages/QuotaPage';
import { UsagePage } from '@/pages/UsagePage'; import { UsagePage } from '@/pages/UsagePage';
import { ConfigPage } from '@/pages/ConfigPage'; import { ConfigPage } from '@/pages/ConfigPage';
import { LogsPage } from '@/pages/LogsPage'; import { LogsPage } from '@/pages/LogsPage';
import { SystemPage } from '@/pages/SystemPage'; import { SystemPage } from '@/pages/SystemPage';
import { NotificationContainer } from '@/components/common/NotificationContainer'; import { NotificationContainer } from '@/components/common/NotificationContainer';
import { SplashScreen } from '@/components/common/SplashScreen';
import { MainLayout } from '@/components/layout/MainLayout'; import { MainLayout } from '@/components/layout/MainLayout';
import { ProtectedRoute } from '@/router/ProtectedRoute'; import { ProtectedRoute } from '@/router/ProtectedRoute';
import { useAuthStore, useLanguageStore, useThemeStore } from '@/stores'; import { useAuthStore, useLanguageStore, useThemeStore } from '@/stores';
const SPLASH_DURATION = 1500;
const SPLASH_FADE_DURATION = 400;
function App() { function App() {
const initializeTheme = useThemeStore((state) => state.initializeTheme); const initializeTheme = useThemeStore((state) => state.initializeTheme);
const language = useLanguageStore((state) => state.language); const language = useLanguageStore((state) => state.language);
const setLanguage = useLanguageStore((state) => state.setLanguage); const setLanguage = useLanguageStore((state) => state.setLanguage);
const restoreSession = useAuthStore((state) => state.restoreSession); const restoreSession = useAuthStore((state) => state.restoreSession);
const [splashReadyToFade, setSplashReadyToFade] = useState(false);
const [showSplash, setShowSplash] = useState(true);
const [authReady, setAuthReady] = useState(false);
useEffect(() => { useEffect(() => {
initializeTheme(); const cleanupTheme = initializeTheme();
restoreSession(); void restoreSession().finally(() => {
setAuthReady(true);
});
return cleanupTheme;
}, [initializeTheme, restoreSession]); }, [initializeTheme, restoreSession]);
useEffect(() => { useEffect(() => {
@@ -31,6 +44,31 @@ function App() {
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, []); // 仅用于首屏同步 i18n 语言 }, []); // 仅用于首屏同步 i18n 语言
useEffect(() => {
document.documentElement.lang = language;
}, [language]);
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 ( return (
<HashRouter> <HashRouter>
<NotificationContainer /> <NotificationContainer />
@@ -44,17 +82,19 @@ function App() {
</ProtectedRoute> </ProtectedRoute>
} }
> >
<Route index element={<Navigate to="/settings" replace />} /> <Route index element={<DashboardPage />} />
<Route path="dashboard" element={<DashboardPage />} />
<Route path="settings" element={<SettingsPage />} /> <Route path="settings" element={<SettingsPage />} />
<Route path="api-keys" element={<ApiKeysPage />} /> <Route path="api-keys" element={<ApiKeysPage />} />
<Route path="ai-providers" element={<AiProvidersPage />} /> <Route path="ai-providers" element={<AiProvidersPage />} />
<Route path="auth-files" element={<AuthFilesPage />} /> <Route path="auth-files" element={<AuthFilesPage />} />
<Route path="oauth" element={<OAuthPage />} /> <Route path="oauth" element={<OAuthPage />} />
<Route path="quota" element={<QuotaPage />} />
<Route path="usage" element={<UsagePage />} /> <Route path="usage" element={<UsagePage />} />
<Route path="config" element={<ConfigPage />} /> <Route path="config" element={<ConfigPage />} />
<Route path="logs" element={<LogsPage />} /> <Route path="logs" element={<LogsPage />} />
<Route path="system" element={<SystemPage />} /> <Route path="system" element={<SystemPage />} />
<Route path="*" element={<Navigate to="/settings" replace />} /> <Route path="*" element={<Navigate to="/" replace />} />
</Route> </Route>
</Routes> </Routes>
</HashRouter> </HashRouter>

6
src/assets/icons/amp.svg Normal file
View File

@@ -0,0 +1,6 @@
<svg width="400" height="400" viewBox="0 0 28 28" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M13.9197 13.61L17.3816 26.566L14.242 27.4049L11.2645 16.2643L0.119926 13.2906L0.957817 10.15L13.9197 13.61Z" fill="#F34E3F"/>
<path d="M13.7391 16.0892L4.88169 24.9056L2.58872 22.6019L11.4461 13.7865L13.7391 16.0892Z" fill="#F34E3F"/>
<path d="M18.9386 8.58315L22.4005 21.5392L19.2609 22.3781L16.2833 11.2374L5.13879 8.26381L5.97668 5.12318L18.9386 8.58315Z" fill="#F34E3F"/>
<path d="M23.9803 3.55632L27.4422 16.5124L24.3025 17.3512L21.325 6.21062L10.1805 3.23698L11.0183 0.0963593L23.9803 3.55632Z" fill="#F34E3F"/>
</svg>

After

Width:  |  Height:  |  Size: 632 B

View File

@@ -0,0 +1,28 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- Generator: visioncortex VTracer 0.6.4 -->
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="64" height="59">
<path d="M0,0 L8,0 L14,4 L19,14 L27,40 L32,50 L36,54 L35,59 L30,59 L22,52 L11,35 L6,33 L-1,34 L-6,39 L-14,52 L-22,59 L-28,59 L-27,53 L-22,47 L-17,34 L-10,12 L-5,3 Z " fill="#3789F9" transform="translate(28,0)"/>
<path d="M0,0 L8,0 L14,4 L19,14 L25,35 L21,34 L16,29 L11,26 L7,20 L7,18 L2,16 L-3,15 L-8,18 L-12,19 L-9,9 L-4,2 Z " fill="#6D80D8" transform="translate(28,0)"/>
<path d="M0,0 L8,0 L14,4 L19,14 L20,19 L13,15 L10,12 L3,10 L-1,8 L-7,7 L-4,2 Z " fill="#D78240" transform="translate(28,0)"/>
<path d="M0,0 L5,1 L10,4 L12,9 L1,8 L-5,13 L-10,21 L-13,26 L-16,26 L-9,5 L-4,2 Z M6,7 Z " fill="#3294CC" transform="translate(25,14)"/>
<path d="M0,0 L5,2 L10,10 L12,18 L5,14 L1,10 L0,4 L-3,3 L0,2 Z " fill="#E45C49" transform="translate(36,1)"/>
<path d="M0,0 L9,1 L12,3 L12,5 L7,6 L4,8 L-1,11 L-5,12 L-2,2 Z " fill="#90AE64" transform="translate(21,7)"/>
<path d="M0,0 L5,1 L5,4 L-2,7 L-7,11 L-11,10 L-9,5 L-4,2 Z " fill="#53A89A" transform="translate(25,14)"/>
<path d="M0,0 L5,0 L16,9 L17,13 L12,12 L8,9 L8,7 L4,5 L0,2 Z " fill="#B5677D" transform="translate(33,11)"/>
<path d="M0,0 L6,0 L14,6 L19,11 L23,12 L22,15 L15,12 L10,8 L10,6 L4,5 Z " fill="#778998" transform="translate(27,12)"/>
<path d="M0,0 L4,2 L-11,17 L-12,14 L-5,4 Z " fill="#3390DF" transform="translate(26,21)"/>
<path d="M0,0 L2,1 L-4,5 L-9,9 L-13,13 L-14,10 L-13,7 L-6,4 L-3,1 Z " fill="#3FA1B7" transform="translate(27,18)"/>
<path d="M0,0 L4,0 L9,5 L13,6 L12,9 L5,6 L0,2 Z " fill="#8277BB" transform="translate(37,18)"/>
<path d="M0,0 L5,1 L7,6 L-2,5 Z M1,4 Z " fill="#4989CF" transform="translate(30,17)"/>
<path d="M0,0 L5,1 L2,3 L-3,6 L-7,7 L-6,3 Z " fill="#71B774" transform="translate(23,12)"/>
<path d="M0,0 L7,1 L9,7 L5,6 L0,1 Z " fill="#6687E9" transform="translate(44,28)"/>
<path d="M0,0 L7,0 L5,1 L5,3 L8,4 L4,5 L-2,4 Z " fill="#C7AF38" transform="translate(23,3)"/>
<path d="M0,0 L8,0 L8,3 L4,4 L-4,3 Z " fill="#EF842A" transform="translate(28,0)"/>
<path d="M0,0 L7,4 L7,6 L10,6 L11,10 L4,6 L0,2 Z " fill="#CD5D67" transform="translate(37,9)"/>
<path d="M0,0 L5,2 L9,8 L8,11 L2,3 L0,2 Z " fill="#F35241" transform="translate(36,1)"/>
<path d="M0,0 L8,2 L9,6 L4,5 L0,2 Z " fill="#A667A2" transform="translate(41,18)"/>
<path d="M0,0 L9,1 L8,3 L-2,3 Z " fill="#A4B34C" transform="translate(21,7)"/>
<path d="M0,0 L2,0 L7,5 L8,7 L3,6 L0,2 Z " fill="#617FCF" transform="translate(35,18)"/>
<path d="M0,0 L5,2 L8,7 L4,5 L0,2 Z " fill="#9D7784" transform="translate(33,11)"/>
<path d="M0,0 L6,2 L6,4 L0,3 Z " fill="#BC7F59" transform="translate(31,7)"/>
</svg>

After

Width:  |  Height:  |  Size: 2.6 KiB

View File

@@ -0,0 +1 @@
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Claude</title><path d="M4.709 15.955l4.72-2.647.08-.23-.08-.128H9.2l-.79-.048-2.698-.073-2.339-.097-2.266-.122-.571-.121L0 11.784l.055-.352.48-.321.686.06 1.52.103 2.278.158 1.652.097 2.449.255h.389l.055-.157-.134-.098-.103-.097-2.358-1.596-2.552-1.688-1.336-.972-.724-.491-.364-.462-.158-1.008.656-.722.881.06.225.061.893.686 1.908 1.476 2.491 1.833.365.304.145-.103.019-.073-.164-.274-1.355-2.446-1.446-2.49-.644-1.032-.17-.619a2.97 2.97 0 01-.104-.729L6.283.134 6.696 0l.996.134.42.364.62 1.414 1.002 2.229 1.555 3.03.456.898.243.832.091.255h.158V9.01l.128-1.706.237-2.095.23-2.695.08-.76.376-.91.747-.492.584.28.48.685-.067.444-.286 1.851-.559 2.903-.364 1.942h.212l.243-.242.985-1.306 1.652-2.064.73-.82.85-.904.547-.431h1.033l.76 1.129-.34 1.166-1.064 1.347-.881 1.142-1.264 1.7-.79 1.36.073.11.188-.02 2.856-.606 1.543-.28 1.841-.315.833.388.091.395-.328.807-1.969.486-2.309.462-3.439.813-.042.03.049.061 1.549.146.662.036h1.622l3.02.225.79.522.474.638-.079.485-1.215.62-1.64-.389-3.829-.91-1.312-.329h-.182v.11l1.093 1.068 2.006 1.81 2.509 2.33.127.578-.322.455-.34-.049-2.205-1.657-.851-.747-1.926-1.62h-.128v.17l.444.649 2.345 3.521.122 1.08-.17.353-.608.213-.668-.122-1.374-1.925-1.415-2.167-1.143-1.943-.14.08-.674 7.254-.316.37-.729.28-.607-.461-.322-.747.322-1.476.389-1.924.315-1.53.286-1.9.17-.632-.012-.042-.14.018-1.434 1.967-2.18 2.945-1.726 1.845-.414.164-.717-.37.067-.662.401-.589 2.388-3.036 1.44-1.882.93-1.086-.006-.158h-.055L4.132 18.56l-1.13.146-.487-.456.061-.746.231-.243 1.908-1.312-.006.006z" fill="#D97757" fill-rule="nonzero"></path></svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

View File

@@ -0,0 +1 @@
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Gemini</title><path d="M20.616 10.835a14.147 14.147 0 01-4.45-3.001 14.111 14.111 0 01-3.678-6.452.503.503 0 00-.975 0 14.134 14.134 0 01-3.679 6.452 14.155 14.155 0 01-4.45 3.001c-.65.28-1.318.505-2.002.678a.502.502 0 000 .975c.684.172 1.35.397 2.002.677a14.147 14.147 0 014.45 3.001 14.112 14.112 0 013.679 6.453.502.502 0 00.975 0c.172-.685.397-1.351.677-2.003a14.145 14.145 0 013.001-4.45 14.113 14.113 0 016.453-3.678.503.503 0 000-.975 13.245 13.245 0 01-2.003-.678z" fill="#3186FF"></path><path d="M20.616 10.835a14.147 14.147 0 01-4.45-3.001 14.111 14.111 0 01-3.678-6.452.503.503 0 00-.975 0 14.134 14.134 0 01-3.679 6.452 14.155 14.155 0 01-4.45 3.001c-.65.28-1.318.505-2.002.678a.502.502 0 000 .975c.684.172 1.35.397 2.002.677a14.147 14.147 0 014.45 3.001 14.112 14.112 0 013.679 6.453.502.502 0 00.975 0c.172-.685.397-1.351.677-2.003a14.145 14.145 0 013.001-4.45 14.113 14.113 0 016.453-3.678.503.503 0 000-.975 13.245 13.245 0 01-2.003-.678z" fill="url(#lobe-icons-gemini-fill-0)"></path><path d="M20.616 10.835a14.147 14.147 0 01-4.45-3.001 14.111 14.111 0 01-3.678-6.452.503.503 0 00-.975 0 14.134 14.134 0 01-3.679 6.452 14.155 14.155 0 01-4.45 3.001c-.65.28-1.318.505-2.002.678a.502.502 0 000 .975c.684.172 1.35.397 2.002.677a14.147 14.147 0 014.45 3.001 14.112 14.112 0 013.679 6.453.502.502 0 00.975 0c.172-.685.397-1.351.677-2.003a14.145 14.145 0 013.001-4.45 14.113 14.113 0 016.453-3.678.503.503 0 000-.975 13.245 13.245 0 01-2.003-.678z" fill="url(#lobe-icons-gemini-fill-1)"></path><path d="M20.616 10.835a14.147 14.147 0 01-4.45-3.001 14.111 14.111 0 01-3.678-6.452.503.503 0 00-.975 0 14.134 14.134 0 01-3.679 6.452 14.155 14.155 0 01-4.45 3.001c-.65.28-1.318.505-2.002.678a.502.502 0 000 .975c.684.172 1.35.397 2.002.677a14.147 14.147 0 014.45 3.001 14.112 14.112 0 013.679 6.453.502.502 0 00.975 0c.172-.685.397-1.351.677-2.003a14.145 14.145 0 013.001-4.45 14.113 14.113 0 016.453-3.678.503.503 0 000-.975 13.245 13.245 0 01-2.003-.678z" fill="url(#lobe-icons-gemini-fill-2)"></path><defs><linearGradient gradientUnits="userSpaceOnUse" id="lobe-icons-gemini-fill-0" x1="7" x2="11" y1="15.5" y2="12"><stop stop-color="#08B962"></stop><stop offset="1" stop-color="#08B962" stop-opacity="0"></stop></linearGradient><linearGradient gradientUnits="userSpaceOnUse" id="lobe-icons-gemini-fill-1" x1="8" x2="11.5" y1="5.5" y2="11"><stop stop-color="#F94543"></stop><stop offset="1" stop-color="#F94543" stop-opacity="0"></stop></linearGradient><linearGradient gradientUnits="userSpaceOnUse" id="lobe-icons-gemini-fill-2" x1="3.5" x2="17.5" y1="13.5" y2="12"><stop stop-color="#FABC12"></stop><stop offset=".46" stop-color="#FABC12" stop-opacity="0"></stop></linearGradient></defs></svg>

After

Width:  |  Height:  |  Size: 2.8 KiB

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" fill="none" version="1.1" width="32" height="32" viewBox="0 0 32 32"><defs><filter id="master_svg0_278_51503" filterUnits="objectBoundingBox" color-interpolation-filters="sRGB" x="0" y="0" width="1" height="1"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur in="BackgroundImageFix" stdDeviation="1.3333334922790527"/><feComposite in2="SourceAlpha" operator="in" result="effect1_foregroundBlur"/><feBlend mode="normal" in="SourceGraphic" in2="effect1_foregroundBlur" result="shape"/></filter><linearGradient x1="0.07353696972131729" y1="0.12899449467658997" x2="0.9907095821060244" y2="0.9383787344260006" id="master_svg1_93_40276"><stop offset="0%" stop-color="#5C5CFF" stop-opacity="1"/><stop offset="100%" stop-color="#AE5CFF" stop-opacity="1"/></linearGradient></defs><g><g filter="url(#master_svg0_278_51503)"><rect x="0" y="0" width="32" height="32" rx="16" fill="#F0F2F5" fill-opacity="0"/></g><g><g><path d="M31.843111328125,14.751C31.315411328125,7.18121,25.497411328125,1.04691,17.966011328125,0.119698C10.434711328125,-0.807512,3.302541328125,3.73244,0.954596328125,10.9482C0.345662328125,12.8248,1.732821328125,14.751,3.705641328125,14.751C4.950051328125,14.7517,6.055631328125,13.9569,6.451401328125,12.7772C7.497331328125,9.65101,10.504411328125,3.91401,18.482011328125,3.91401Q29.445911328125,3.91401,31.843111328125,14.751ZM9.127681328125,17.3314L9.127681328125,13.0862Q9.127681328125,13.0022,9.144081328125,12.9198Q9.160481328125,12.8373,9.192641328125,12.7597Q9.224801328125,12.682,9.271501328125,12.6122Q9.318191328125,12.5423,9.377621328125,12.4828Q9.437051328125,12.4234,9.506931328125,12.3767Q9.576811328125,12.33,9.654461328125,12.2979Q9.732111328125,12.2657,9.814541328125,12.2493Q9.896971328125,12.2329,9.981021328125,12.2329L11.049211328125,12.2329Q11.133211328125,12.2329,11.215711328125,12.2493Q11.298111328125,12.2657,11.375811328125,12.2979Q11.453411328125,12.33,11.523311328125,12.3767Q11.593211328125,12.4234,11.652611328125,12.4828Q11.712011328125,12.5423,11.758711328125,12.6122Q11.805411328125,12.682,11.837611328125,12.7597Q11.869711328125,12.8373,11.886111328125,12.9198Q11.902511328125,13.0022,11.902511328125,13.0862L11.902511328125,17.3314Q11.902511328125,17.4154,11.886111328125,17.4978Q11.869711328125,17.5803,11.837611328125,17.6579Q11.805411328125,17.7356,11.758711328125,17.8055Q11.712011328125,17.8753,11.652611328125,17.9348Q11.593211328125,17.9942,11.523311328125,18.0409Q11.453411328125,18.0876,11.375811328125,18.1197Q11.298111328125,18.1519,11.215711328125,18.1683Q11.133211328125,18.1847,11.049211328125,18.1847L9.981021328125,18.1847Q9.896971328125,18.1847,9.814541328125,18.1683Q9.732111328125,18.1519,9.654461328125,18.1197Q9.576811328125,18.0876,9.506931328125,18.0409Q9.437051328125,17.9942,9.377621328125,17.9348Q9.318191328125,17.8753,9.271501328125,17.8055Q9.224801328125,17.7356,9.192641328125,17.6579Q9.160481328125,17.5803,9.144081328125,17.4978Q9.127681328125,17.4154,9.127681328125,17.3314ZM17.273611328125,17.3295C17.272611328125,17.8015,17.654911328125,18.1847,18.126911328125,18.1847L19.408411328125,18.1847C19.879011328125,18.1847,20.260711328125,17.8038,20.261811328125,17.3332L20.266411328125,15.2107L20.266411328125,15.2069L20.261811328125,13.0844C20.260711328125,12.6138,19.879011328125,12.2329,19.408411328125,12.2329L18.126911328125,12.2329C17.654911328125,12.2329,17.272611328125,12.6161,17.273611328125,13.0881L17.278211328125,15.2069L17.278211328125,15.2107L17.273611328125,17.3295ZM13.574711328125,28.0523C21.552211328125,28.0523,24.559311328125,22.3153,25.605811328125,19.1897C26.001411328125,18.0098,27.107111328125,17.215,28.351511328125,17.2158C30.323811328125,17.2158,31.711511328125,19.1416,31.102611328125,21.0181C30.552411328125,22.7189,29.716211328125,24.3134,28.629811328125,25.733L30.137611328125,30.2235L24.775211328125,29.3432C14.645911328125,36.0484,1.048779328125,29.3346,0.214111328125,17.2158Q2.611231328125,28.0523,13.574711328125,28.0523Z" fill-rule="evenodd" fill="url(#master_svg1_93_40276)" fill-opacity="1"/></g></g></g></svg>

After

Width:  |  Height:  |  Size: 4.1 KiB

View File

@@ -0,0 +1 @@
<svg fill="#ffffff" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>OpenAI</title><path d="M21.55 10.004a5.416 5.416 0 00-.478-4.501c-1.217-2.09-3.662-3.166-6.05-2.66A5.59 5.59 0 0010.831 1C8.39.995 6.224 2.546 5.473 4.838A5.553 5.553 0 001.76 7.496a5.487 5.487 0 00.691 6.5 5.416 5.416 0 00.477 4.502c1.217 2.09 3.662 3.165 6.05 2.66A5.586 5.586 0 0013.168 23c2.443.006 4.61-1.546 5.361-3.84a5.553 5.553 0 003.715-2.66 5.488 5.488 0 00-.693-6.497v.001zm-8.381 11.558a4.199 4.199 0 01-2.675-.954c.034-.018.093-.05.132-.074l4.44-2.53a.71.71 0 00.364-.623v-6.176l1.877 1.069c.02.01.033.029.036.05v5.115c-.003 2.274-1.87 4.118-4.174 4.123zM4.192 17.78a4.059 4.059 0 01-.498-2.763c.032.02.09.055.131.078l4.44 2.53c.225.13.504.13.73 0l5.42-3.088v2.138a.068.068 0 01-.027.057L9.9 19.288c-1.999 1.136-4.552.46-5.707-1.51h-.001zM3.023 8.216A4.15 4.15 0 015.198 6.41l-.002.151v5.06a.711.711 0 00.364.624l5.42 3.087-1.876 1.07a.067.067 0 01-.063.005l-4.489-2.559c-1.995-1.14-2.679-3.658-1.53-5.63h.001zm15.417 3.54l-5.42-3.088L14.896 7.6a.067.067 0 01.063-.006l4.489 2.557c1.998 1.14 2.683 3.662 1.529 5.633a4.163 4.163 0 01-2.174 1.807V12.38a.71.71 0 00-.363-.623zm1.867-2.773a6.04 6.04 0 00-.132-.078l-4.44-2.53a.731.731 0 00-.729 0l-5.42 3.088V7.325a.068.068 0 01.027-.057L14.1 4.713c2-1.137 4.555-.46 5.707 1.513.487.833.664 1.809.499 2.757h.001zm-11.741 3.81l-1.877-1.068a.065.065 0 01-.036-.051V6.559c.001-2.277 1.873-4.122 4.181-4.12.976 0 1.92.338 2.671.954-.034.018-.092.05-.131.073l-4.44 2.53a.71.71 0 00-.365.623l-.003 6.173v.002zm1.02-2.168L12 9.25l2.414 1.375v2.75L12 14.75l-2.415-1.375v-2.75z"></path></svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

View File

@@ -0,0 +1 @@
<svg fill="#000000" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>OpenAI</title><path d="M21.55 10.004a5.416 5.416 0 00-.478-4.501c-1.217-2.09-3.662-3.166-6.05-2.66A5.59 5.59 0 0010.831 1C8.39.995 6.224 2.546 5.473 4.838A5.553 5.553 0 001.76 7.496a5.487 5.487 0 00.691 6.5 5.416 5.416 0 00.477 4.502c1.217 2.09 3.662 3.165 6.05 2.66A5.586 5.586 0 0013.168 23c2.443.006 4.61-1.546 5.361-3.84a5.553 5.553 0 003.715-2.66 5.488 5.488 0 00-.693-6.497v.001zm-8.381 11.558a4.199 4.199 0 01-2.675-.954c.034-.018.093-.05.132-.074l4.44-2.53a.71.71 0 00.364-.623v-6.176l1.877 1.069c.02.01.033.029.036.05v5.115c-.003 2.274-1.87 4.118-4.174 4.123zM4.192 17.78a4.059 4.059 0 01-.498-2.763c.032.02.09.055.131.078l4.44 2.53c.225.13.504.13.73 0l5.42-3.088v2.138a.068.068 0 01-.027.057L9.9 19.288c-1.999 1.136-4.552.46-5.707-1.51h-.001zM3.023 8.216A4.15 4.15 0 015.198 6.41l-.002.151v5.06a.711.711 0 00.364.624l5.42 3.087-1.876 1.07a.067.067 0 01-.063.005l-4.489-2.559c-1.995-1.14-2.679-3.658-1.53-5.63h.001zm15.417 3.54l-5.42-3.088L14.896 7.6a.067.067 0 01.063-.006l4.489 2.557c1.998 1.14 2.683 3.662 1.529 5.633a4.163 4.163 0 01-2.174 1.807V12.38a.71.71 0 00-.363-.623zm1.867-2.773a6.04 6.04 0 00-.132-.078l-4.44-2.53a.731.731 0 00-.729 0l-5.42 3.088V7.325a.068.068 0 01.027-.057L14.1 4.713c2-1.137 4.555-.46 5.707 1.513.487.833.664 1.809.499 2.757h.001zm-11.741 3.81l-1.877-1.068a.065.065 0 01-.036-.051V6.559c.001-2.277 1.873-4.122 4.181-4.12.976 0 1.92.338 2.671.954-.034.018-.092.05-.131.073l-4.44 2.53a.71.71 0 00-.365.623l-.003 6.173v.002zm1.02-2.168L12 9.25l2.414 1.375v2.75L12 14.75l-2.415-1.375v-2.75z"></path></svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

View File

@@ -0,0 +1 @@
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Qwen</title><path d="M12.604 1.34c.393.69.784 1.382 1.174 2.075a.18.18 0 00.157.091h5.552c.174 0 .322.11.446.327l1.454 2.57c.19.337.24.478.024.837-.26.43-.513.864-.76 1.3l-.367.658c-.106.196-.223.28-.04.512l2.652 4.637c.172.301.111.494-.043.77-.437.785-.882 1.564-1.335 2.34-.159.272-.352.375-.68.37-.777-.016-1.552-.01-2.327.016a.099.099 0 00-.081.05 575.097 575.097 0 01-2.705 4.74c-.169.293-.38.363-.725.364-.997.003-2.002.004-3.017.002a.537.537 0 01-.465-.271l-1.335-2.323a.09.09 0 00-.083-.049H4.982c-.285.03-.553-.001-.805-.092l-1.603-2.77a.543.543 0 01-.002-.54l1.207-2.12a.198.198 0 000-.197 550.951 550.951 0 01-1.875-3.272l-.79-1.395c-.16-.31-.173-.496.095-.965.465-.813.927-1.625 1.387-2.436.132-.234.304-.334.584-.335a338.3 338.3 0 012.589-.001.124.124 0 00.107-.063l2.806-4.895a.488.488 0 01.422-.246c.524-.001 1.053 0 1.583-.006L11.704 1c.341-.003.724.032.9.34zm-3.432.403a.06.06 0 00-.052.03L6.254 6.788a.157.157 0 01-.135.078H3.253c-.056 0-.07.025-.041.074l5.81 10.156c.025.042.013.062-.034.063l-2.795.015a.218.218 0 00-.2.116l-1.32 2.31c-.044.078-.021.118.068.118l5.716.008c.046 0 .08.02.104.061l1.403 2.454c.046.081.092.082.139 0l5.006-8.76.783-1.382a.055.055 0 01.096 0l1.424 2.53a.122.122 0 00.107.062l2.763-.02a.04.04 0 00.035-.02.041.041 0 000-.04l-2.9-5.086a.108.108 0 010-.113l.293-.507 1.12-1.977c.024-.041.012-.062-.035-.062H9.2c-.059 0-.073-.026-.043-.077l1.434-2.505a.107.107 0 000-.114L9.225 1.774a.06.06 0 00-.053-.031zm6.29 8.02c.046 0 .058.02.034.06l-.832 1.465-2.613 4.585a.056.056 0 01-.05.029.058.058 0 01-.05-.029L8.498 9.841c-.02-.034-.01-.052.028-.054l.216-.012 6.722-.012z" fill="url(#lobe-icons-qwen-fill)" fill-rule="nonzero"></path><defs><linearGradient id="lobe-icons-qwen-fill" x1="0%" x2="100%" y1="0%" y2="0%"><stop offset="0%" stop-color="#6336E7" stop-opacity=".84"></stop><stop offset="100%" stop-color="#6F69F7" stop-opacity=".84"></stop></linearGradient></defs></svg>

After

Width:  |  Height:  |  Size: 2.0 KiB

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24px" height="24px"><path d="M20,13.89A.77.77,0,0,0,19,13.73l-7,5.14v.22a.72.72,0,1,1,0,1.43v0a.74.74,0,0,0,.45-.15l7.41-5.47A.76.76,0,0,0,20,13.89Z" style="fill:#669df6"/><path d="M12,20.52a.72.72,0,0,1,0-1.43h0v-.22L5,13.73a.76.76,0,0,0-1,.16.74.74,0,0,0,.16,1l7.41,5.47a.73.73,0,0,0,.44.15v0Z" style="fill:#aecbfa"/><path d="M12,18.34a1.47,1.47,0,1,0,1.47,1.47A1.47,1.47,0,0,0,12,18.34Zm0,2.18a.72.72,0,1,1,.72-.71A.71.71,0,0,1,12,20.52Z" style="fill:#4285f4"/><path d="M6,6.11a.76.76,0,0,1-.75-.75V3.48a.76.76,0,1,1,1.51,0V5.36A.76.76,0,0,1,6,6.11Z" style="fill:#aecbfa"/><circle cx="5.98" cy="12" r="0.76" style="fill:#aecbfa"/><circle cx="5.98" cy="9.79" r="0.76" style="fill:#aecbfa"/><circle cx="5.98" cy="7.57" r="0.76" style="fill:#aecbfa"/><path d="M18,8.31a.76.76,0,0,1-.75-.76V5.67a.75.75,0,1,1,1.5,0V7.55A.75.75,0,0,1,18,8.31Z" style="fill:#4285f4"/><circle cx="18.02" cy="12.01" r="0.76" style="fill:#4285f4"/><circle cx="18.02" cy="9.76" r="0.76" style="fill:#4285f4"/><circle cx="18.02" cy="3.48" r="0.76" style="fill:#4285f4"/><path d="M12,15a.76.76,0,0,1-.75-.75V12.34a.76.76,0,0,1,1.51,0v1.89A.76.76,0,0,1,12,15Z" style="fill:#669df6"/><circle cx="12" cy="16.45" r="0.76" style="fill:#669df6"/><circle cx="12" cy="10.14" r="0.76" style="fill:#669df6"/><circle cx="12" cy="7.92" r="0.76" style="fill:#669df6"/><path d="M15,10.54a.76.76,0,0,1-.75-.75V7.91a.76.76,0,1,1,1.51,0V9.79A.76.76,0,0,1,15,10.54Z" style="fill:#4285f4"/><circle cx="15.01" cy="5.69" r="0.76" style="fill:#4285f4"/><circle cx="15.01" cy="14.19" r="0.76" style="fill:#4285f4"/><circle cx="15.01" cy="11.97" r="0.76" style="fill:#4285f4"/><circle cx="8.99" cy="14.19" r="0.76" style="fill:#aecbfa"/><circle cx="8.99" cy="7.92" r="0.76" style="fill:#aecbfa"/><circle cx="8.99" cy="5.69" r="0.76" style="fill:#aecbfa"/><path d="M9,12.73A.76.76,0,0,1,8.24,12V10.1a.75.75,0,1,1,1.5,0V12A.75.75,0,0,1,9,12.73Z" style="fill:#aecbfa"/></svg>

After

Width:  |  Height:  |  Size: 1.9 KiB

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

@@ -1,32 +1,52 @@
import { ReactNode, SVGProps, useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'; import {
import { NavLink, Outlet } from 'react-router-dom'; ReactNode,
SVGProps,
useCallback,
useEffect,
useLayoutEffect,
useRef,
useState,
} from 'react';
import { NavLink, Outlet, useLocation } from 'react-router-dom';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Button } from '@/components/ui/Button'; import { Button } from '@/components/ui/Button';
import { Modal } from '@/components/ui/Modal';
import { ToggleSwitch } from '@/components/ui/ToggleSwitch';
import { import {
IconBot, IconBot,
IconChartLine, IconChartLine,
IconFileText, IconFileText,
IconInfo, IconInfo,
IconKey, IconKey,
IconLayoutDashboard,
IconScrollText, IconScrollText,
IconSettings, IconSettings,
IconShield, IconShield,
IconSlidersHorizontal IconSlidersHorizontal,
IconTimer,
} from '@/components/ui/icons'; } from '@/components/ui/icons';
import { INLINE_LOGO_JPEG } from '@/assets/logoInline'; import { INLINE_LOGO_JPEG } from '@/assets/logoInline';
import { useAuthStore, useConfigStore, useLanguageStore, useNotificationStore, useThemeStore } from '@/stores'; import {
import { versionApi } from '@/services/api'; useAuthStore,
useConfigStore,
useLanguageStore,
useNotificationStore,
useThemeStore,
} from '@/stores';
import { configApi, versionApi } from '@/services/api';
const sidebarIcons: Record<string, ReactNode> = { const sidebarIcons: Record<string, ReactNode> = {
dashboard: <IconLayoutDashboard size={18} />,
settings: <IconSlidersHorizontal size={18} />, settings: <IconSlidersHorizontal size={18} />,
apiKeys: <IconKey size={18} />, apiKeys: <IconKey size={18} />,
aiProviders: <IconBot size={18} />, aiProviders: <IconBot size={18} />,
authFiles: <IconFileText size={18} />, authFiles: <IconFileText size={18} />,
oauth: <IconShield size={18} />, oauth: <IconShield size={18} />,
quota: <IconTimer size={18} />,
usage: <IconChartLine size={18} />, usage: <IconChartLine size={18} />,
config: <IconSettings size={18} />, config: <IconSettings size={18} />,
logs: <IconScrollText size={18} />, logs: <IconScrollText size={18} />,
system: <IconInfo size={18} /> system: <IconInfo size={18} />,
}; };
// Header action icons - smaller size for header buttons // Header action icons - smaller size for header buttons
@@ -40,7 +60,7 @@ const headerIconProps: SVGProps<SVGSVGElement> = {
strokeLinecap: 'round', strokeLinecap: 'round',
strokeLinejoin: 'round', strokeLinejoin: 'round',
'aria-hidden': 'true', 'aria-hidden': 'true',
focusable: 'false' focusable: 'false',
}; };
const headerIcons = { const headerIcons = {
@@ -98,14 +118,33 @@ const headerIcons = {
<path d="M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9z" /> <path d="M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9z" />
</svg> </svg>
), ),
autoTheme: (
<svg {...headerIconProps}>
<defs>
<clipPath id="mainLayoutAutoThemeSunLeftHalf">
<rect x="0" y="0" width="12" height="24" />
</clipPath>
</defs>
<circle cx="12" cy="12" r="4" />
<circle cx="12" cy="12" r="4" clipPath="url(#mainLayoutAutoThemeSunLeftHalf)" fill="currentColor" />
<path d="M12 2v2" />
<path d="M12 20v2" />
<path d="M4.93 4.93l1.41 1.41" />
<path d="M17.66 17.66l1.41 1.41" />
<path d="M2 12h2" />
<path d="M20 12h2" />
<path d="M6.34 17.66l-1.41 1.41" />
<path d="M19.07 4.93l-1.41 1.41" />
</svg>
),
logout: ( logout: (
<svg {...headerIconProps}> <svg {...headerIconProps}>
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" /> <path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" />
<path d="m16 17 5-5-5-5" /> <path d="m16 17 5-5-5-5" />
<path d="M21 12H9" /> <path d="M21 12H9" />
</svg> </svg>
) ),
}; };
const parseVersionSegments = (version?: string | null) => { const parseVersionSegments = (version?: string | null) => {
if (!version) return null; if (!version) return null;
@@ -136,6 +175,7 @@ const compareVersions = (latest?: string | null, current?: string | null) => {
export function MainLayout() { export function MainLayout() {
const { t, i18n } = useTranslation(); const { t, i18n } = useTranslation();
const { showNotification } = useNotificationStore(); const { showNotification } = useNotificationStore();
const location = useLocation();
const apiBase = useAuthStore((state) => state.apiBase); const apiBase = useAuthStore((state) => state.apiBase);
const serverVersion = useAuthStore((state) => state.serverVersion); const serverVersion = useAuthStore((state) => state.serverVersion);
@@ -146,20 +186,31 @@ export function MainLayout() {
const config = useConfigStore((state) => state.config); const config = useConfigStore((state) => state.config);
const fetchConfig = useConfigStore((state) => state.fetchConfig); const fetchConfig = useConfigStore((state) => state.fetchConfig);
const clearCache = useConfigStore((state) => state.clearCache); const clearCache = useConfigStore((state) => state.clearCache);
const updateConfigValue = useConfigStore((state) => state.updateConfigValue);
const theme = useThemeStore((state) => state.theme); const theme = useThemeStore((state) => state.theme);
const toggleTheme = useThemeStore((state) => state.toggleTheme); const cycleTheme = useThemeStore((state) => state.cycleTheme);
const toggleLanguage = useLanguageStore((state) => state.toggleLanguage); const toggleLanguage = useLanguageStore((state) => state.toggleLanguage);
const [sidebarOpen, setSidebarOpen] = useState(false); const [sidebarOpen, setSidebarOpen] = useState(false);
const [sidebarCollapsed, setSidebarCollapsed] = useState(false); const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
const [checkingVersion, setCheckingVersion] = useState(false); const [checkingVersion, setCheckingVersion] = useState(false);
const [brandExpanded, setBrandExpanded] = useState(true); 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 brandCollapseTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const headerRef = useRef<HTMLElement | 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 fullBrandName = 'CLI Proxy API Management Center';
const abbrBrandName = t('title.abbr'); const abbrBrandName = t('title.abbr');
const requestLogEnabled = config?.requestLog ?? false;
const requestLogDirty = requestLogDraft !== requestLogEnabled;
const canEditRequestLog = connectionStatus === 'connected' && Boolean(config);
const isLogsPage = location.pathname.startsWith('/logs');
// 将顶栏高度写入 CSS 变量,确保侧栏/内容区计算一致,防止滚动时抖动 // 将顶栏高度写入 CSS 变量,确保侧栏/内容区计算一致,防止滚动时抖动
useLayoutEffect(() => { useLayoutEffect(() => {
@@ -173,7 +224,9 @@ export function MainLayout() {
updateHeaderHeight(); updateHeaderHeight();
const resizeObserver = const resizeObserver =
typeof ResizeObserver !== 'undefined' && headerRef.current ? new ResizeObserver(updateHeaderHeight) : null; typeof ResizeObserver !== 'undefined' && headerRef.current
? new ResizeObserver(updateHeaderHeight)
: null;
if (resizeObserver && headerRef.current) { if (resizeObserver && headerRef.current) {
resizeObserver.observe(headerRef.current); resizeObserver.observe(headerRef.current);
} }
@@ -201,6 +254,20 @@ export function MainLayout() {
}; };
}, []); }, []);
useEffect(() => {
if (requestLogModalOpen && !requestLogTouched) {
setRequestLogDraft(requestLogEnabled);
}
}, [requestLogModalOpen, requestLogTouched, requestLogEnabled]);
useEffect(() => {
return () => {
if (versionTapTimer.current) {
clearTimeout(versionTapTimer.current);
}
};
}, []);
const handleBrandClick = useCallback(() => { const handleBrandClick = useCallback(() => {
if (!brandExpanded) { if (!brandExpanded) {
setBrandExpanded(true); setBrandExpanded(true);
@@ -214,6 +281,60 @@ export function MainLayout() {
} }
}, [brandExpanded]); }, [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(() => { useEffect(() => {
fetchConfig().catch(() => { fetchConfig().catch(() => {
// ignore initial failure; login flow会提示 // ignore initial failure; login flow会提示
@@ -230,15 +351,19 @@ export function MainLayout() {
: 'muted'; : 'muted';
const navItems = [ const navItems = [
{ path: '/', label: t('nav.dashboard'), icon: sidebarIcons.dashboard },
{ path: '/settings', label: t('nav.basic_settings'), icon: sidebarIcons.settings }, { path: '/settings', label: t('nav.basic_settings'), icon: sidebarIcons.settings },
{ path: '/api-keys', label: t('nav.api_keys'), icon: sidebarIcons.apiKeys }, { path: '/api-keys', label: t('nav.api_keys'), icon: sidebarIcons.apiKeys },
{ path: '/ai-providers', label: t('nav.ai_providers'), icon: sidebarIcons.aiProviders }, { path: '/ai-providers', label: t('nav.ai_providers'), icon: sidebarIcons.aiProviders },
{ path: '/auth-files', label: t('nav.auth_files'), icon: sidebarIcons.authFiles }, { path: '/auth-files', label: t('nav.auth_files'), icon: sidebarIcons.authFiles },
{ path: '/oauth', label: t('nav.oauth', { defaultValue: 'OAuth' }), icon: sidebarIcons.oauth }, { path: '/oauth', label: t('nav.oauth', { defaultValue: 'OAuth' }), icon: sidebarIcons.oauth },
{ path: '/quota', label: t('nav.quota_management'), icon: sidebarIcons.quota },
{ path: '/usage', label: t('nav.usage_stats'), icon: sidebarIcons.usage }, { path: '/usage', label: t('nav.usage_stats'), icon: sidebarIcons.usage },
{ path: '/config', label: t('nav.config_management'), icon: sidebarIcons.config }, { path: '/config', label: t('nav.config_management'), icon: sidebarIcons.config },
...(config?.loggingToFile ? [{ path: '/logs', label: t('nav.logs'), icon: sidebarIcons.logs }] : []), ...(config?.loggingToFile
{ path: '/system', label: t('nav.system_info'), icon: sidebarIcons.system } ? [{ path: '/logs', label: t('nav.logs'), icon: sidebarIcons.logs }]
: []),
{ path: '/system', label: t('nav.system_info'), icon: sidebarIcons.system },
]; ];
const handleRefreshAll = async () => { const handleRefreshAll = async () => {
@@ -287,7 +412,11 @@ export function MainLayout() {
<button <button
className="sidebar-toggle-header" className="sidebar-toggle-header"
onClick={() => setSidebarCollapsed((prev) => !prev)} onClick={() => setSidebarCollapsed((prev) => !prev)}
title={sidebarCollapsed ? t('sidebar.expand', { defaultValue: '展开' }) : t('sidebar.collapse', { defaultValue: '收起' })} title={
sidebarCollapsed
? t('sidebar.expand', { defaultValue: '展开' })
: t('sidebar.collapse', { defaultValue: '收起' })
}
> >
{sidebarCollapsed ? headerIcons.chevronRight : headerIcons.chevronLeft} {sidebarCollapsed ? headerIcons.chevronRight : headerIcons.chevronLeft}
</button> </button>
@@ -317,20 +446,40 @@ export function MainLayout() {
</div> </div>
<div className="header-actions"> <div className="header-actions">
<Button className="mobile-menu-btn" variant="ghost" size="sm" onClick={() => setSidebarOpen((prev) => !prev)}> <Button
className="mobile-menu-btn"
variant="ghost"
size="sm"
onClick={() => setSidebarOpen((prev) => !prev)}
>
{headerIcons.menu} {headerIcons.menu}
</Button> </Button>
<Button variant="ghost" size="sm" onClick={handleRefreshAll} title={t('header.refresh_all')}> <Button
variant="ghost"
size="sm"
onClick={handleRefreshAll}
title={t('header.refresh_all')}
>
{headerIcons.refresh} {headerIcons.refresh}
</Button> </Button>
<Button variant="ghost" size="sm" onClick={handleVersionCheck} loading={checkingVersion} title={t('system_info.version_check_button')}> <Button
variant="ghost"
size="sm"
onClick={handleVersionCheck}
loading={checkingVersion}
title={t('system_info.version_check_button')}
>
{headerIcons.update} {headerIcons.update}
</Button> </Button>
<Button variant="ghost" size="sm" onClick={toggleLanguage} title={t('language.switch')}> <Button variant="ghost" size="sm" onClick={toggleLanguage} title={t('language.switch')}>
{headerIcons.language} {headerIcons.language}
</Button> </Button>
<Button variant="ghost" size="sm" onClick={toggleTheme} title={t('theme.switch')}> <Button variant="ghost" size="sm" onClick={cycleTheme} title={t('theme.switch')}>
{theme === 'dark' ? headerIcons.sun : headerIcons.moon} {theme === 'auto'
? headerIcons.autoTheme
: theme === 'dark'
? headerIcons.moon
: headerIcons.sun}
</Button> </Button>
<Button variant="ghost" size="sm" onClick={logout} title={t('header.logout')}> <Button variant="ghost" size="sm" onClick={logout} title={t('header.logout')}>
{headerIcons.logout} {headerIcons.logout}
@@ -340,7 +489,9 @@ export function MainLayout() {
</header> </header>
<div className="main-body"> <div className="main-body">
<aside className={`sidebar ${sidebarOpen ? 'open' : ''} ${sidebarCollapsed ? 'collapsed' : ''}`}> <aside
className={`sidebar ${sidebarOpen ? 'open' : ''} ${sidebarCollapsed ? 'collapsed' : ''}`}
>
<div className="nav-section"> <div className="nav-section">
{navItems.map((item) => ( {navItems.map((item) => (
<NavLink <NavLink
@@ -357,8 +508,8 @@ export function MainLayout() {
</div> </div>
</aside> </aside>
<div className="content"> <div className={`content${isLogsPage ? ' content-logs' : ''}`}>
<main className="main-content"> <main className={`main-content${isLogsPage ? ' main-content-logs' : ''}`}>
<Outlet /> <Outlet />
</main> </main>
@@ -366,16 +517,52 @@ export function MainLayout() {
<span> <span>
{t('footer.api_version')}: {serverVersion || t('system_info.version_unknown')} {t('footer.api_version')}: {serverVersion || t('system_info.version_unknown')}
</span> </span>
<span> <span className="footer-version" onClick={handleVersionTap}>
{t('footer.version')}: {__APP_VERSION__ || t('system_info.version_unknown')} {t('footer.version')}: {__APP_VERSION__ || t('system_info.version_unknown')}
</span> </span>
<span> <span>
{t('footer.build_date')}:{' '} {t('footer.build_date')}:{' '}
{serverBuildDate ? new Date(serverBuildDate).toLocaleString(i18n.language) : t('system_info.version_unknown')} {serverBuildDate
? new Date(serverBuildDate).toLocaleString(i18n.language)
: t('system_info.version_unknown')}
</span> </span>
</footer> </footer>
</div> </div>
</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> </div>
); );
} }

View File

@@ -3,11 +3,12 @@ import type { PropsWithChildren, ReactNode } from 'react';
interface CardProps { interface CardProps {
title?: ReactNode; title?: ReactNode;
extra?: ReactNode; extra?: ReactNode;
className?: string;
} }
export function Card({ title, extra, children }: PropsWithChildren<CardProps>) { export function Card({ title, extra, children, className }: PropsWithChildren<CardProps>) {
return ( return (
<div className="card"> <div className={className ? `card ${className}` : 'card'}>
{(title || extra) && ( {(title || extra) && (
<div className="card-header"> <div className="card-header">
<div className="title">{title}</div> <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 ( return (
<div <div
className="loading-spinner" className={`loading-spinner${className ? ` ${className}` : ''}`}
style={{ width: size, height: size, borderWidth: size / 7 }} style={{ width: size, height: size, borderWidth: size / 7 }}
role="status" role="status"
aria-live="polite" aria-live="polite"

View File

@@ -1,4 +1,4 @@
import type { PropsWithChildren, ReactNode } from 'react'; import { useState, useEffect, useCallback, useRef, type PropsWithChildren, type ReactNode } from 'react';
import { IconX } from './icons'; import { IconX } from './icons';
interface ModalProps { interface ModalProps {
@@ -9,23 +9,70 @@ interface ModalProps {
width?: number | string; width?: number | string;
} }
export function Modal({ open, title, onClose, footer, width = 520, children }: PropsWithChildren<ModalProps>) { const CLOSE_ANIMATION_DURATION = 350;
if (!open) return null;
const handleMaskClick = (event: React.MouseEvent<HTMLDivElement>) => { export function Modal({ open, title, onClose, footer, width = 520, children }: PropsWithChildren<ModalProps>) {
if (event.target === event.currentTarget) { const [isVisible, setIsVisible] = useState(false);
const [isClosing, setIsClosing] = useState(false);
const closeTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const startClose = useCallback(
(notifyParent: boolean) => {
if (closeTimerRef.current !== null) return;
setIsClosing(true);
closeTimerRef.current = window.setTimeout(() => {
setIsVisible(false);
setIsClosing(false);
closeTimerRef.current = null;
if (notifyParent) {
onClose(); onClose();
} }
}, CLOSE_ANIMATION_DURATION);
},
[onClose]
);
useEffect(() => {
if (open) {
if (closeTimerRef.current !== null) {
window.clearTimeout(closeTimerRef.current);
closeTimerRef.current = null;
}
setIsVisible(true);
setIsClosing(false);
return;
}
if (isVisible) {
startClose(false);
}
}, [open, isVisible, startClose]);
const handleClose = useCallback(() => {
startClose(true);
}, [startClose]);
useEffect(() => {
return () => {
if (closeTimerRef.current !== null) {
window.clearTimeout(closeTimerRef.current);
}
}; };
}, []);
if (!open && !isVisible) return null;
const overlayClass = `modal-overlay ${isClosing ? 'modal-overlay-closing' : 'modal-overlay-entering'}`;
const modalClass = `modal ${isClosing ? 'modal-closing' : 'modal-entering'}`;
return ( return (
<div className="modal-overlay" onClick={handleMaskClick}> <div className={overlayClass}>
<div className="modal" style={{ width }} role="dialog" aria-modal="true"> <div className={modalClass} style={{ width }} role="dialog" aria-modal="true">
<button className="modal-close-floating" onClick={handleClose} aria-label="Close">
<IconX size={20} />
</button>
<div className="modal-header"> <div className="modal-header">
<div className="modal-title">{title}</div> <div className="modal-title">{title}</div>
<button className="modal-close" onClick={onClose} aria-label="Close">
<IconX size={18} />
</button>
</div> </div>
<div className="modal-body">{children}</div> <div className="modal-body">{children}</div>
{footer && <div className="modal-footer">{footer}</div>} {footer && <div className="modal-footer">{footer}</div>}

View File

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

View File

@@ -303,3 +303,14 @@ export function IconCode({ size = 20, ...props }: IconProps) {
</svg> </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

@@ -6,14 +6,14 @@ import i18n from 'i18next';
import { initReactI18next } from 'react-i18next'; import { initReactI18next } from 'react-i18next';
import zhCN from './locales/zh-CN.json'; import zhCN from './locales/zh-CN.json';
import en from './locales/en.json'; import en from './locales/en.json';
import { STORAGE_KEY_LANGUAGE } from '@/utils/constants'; import { getInitialLanguage } from '@/utils/language';
i18n.use(initReactI18next).init({ i18n.use(initReactI18next).init({
resources: { resources: {
'zh-CN': { translation: zhCN }, 'zh-CN': { translation: zhCN },
en: { translation: en } en: { translation: en }
}, },
lng: localStorage.getItem(STORAGE_KEY_LANGUAGE) || 'zh-CN', lng: getInitialLanguage(),
fallbackLng: 'zh-CN', fallbackLng: 'zh-CN',
interpolation: { interpolation: {
escapeValue: false // React 已经转义 escapeValue: false // React 已经转义

View File

@@ -29,10 +29,13 @@
"required": "Required", "required": "Required",
"api_key": "Key", "api_key": "Key",
"base_url": "Address", "base_url": "Address",
"prefix": "Prefix",
"proxy_url": "Proxy", "proxy_url": "Proxy",
"alias": "Alias", "alias": "Alias",
"failure": "Failure", "failure": "Failure",
"unknown_error": "Unknown error", "unknown_error": "Unknown error",
"quota_update_required": "Please update the CPA version or check for updates",
"quota_check_credential": "Please check the credential status",
"copy": "Copy", "copy": "Copy",
"custom_headers_label": "Custom Headers", "custom_headers_label": "Custom Headers",
"custom_headers_hint": "Optional HTTP headers to send with the request. Leave blank to remove.", "custom_headers_hint": "Optional HTTP headers to send with the request. Leave blank to remove.",
@@ -60,6 +63,7 @@
"custom_connection_placeholder": "Eg: https://example.com:8317", "custom_connection_placeholder": "Eg: https://example.com:8317",
"custom_connection_hint": "By default the current URL is used. Override it here if needed.", "custom_connection_hint": "By default the current URL is used. Override it here if needed.",
"use_current_address": "Use Current URL", "use_current_address": "Use Current URL",
"remember_password_label": "Remember password",
"management_key_label": "Management Key:", "management_key_label": "Management Key:",
"management_key_placeholder": "Enter the management key", "management_key_placeholder": "Enter the management key",
"connect_button": "Connect", "connect_button": "Connect",
@@ -81,16 +85,39 @@
"status": "Connection Status:" "status": "Connection Status:"
}, },
"nav": { "nav": {
"dashboard": "Dashboard",
"basic_settings": "Basic Settings", "basic_settings": "Basic Settings",
"api_keys": "API Keys", "api_keys": "API Keys",
"ai_providers": "AI Providers", "ai_providers": "AI Providers",
"auth_files": "Auth Files", "auth_files": "Auth Files",
"oauth": "OAuth Login", "oauth": "OAuth Login",
"quota_management": "Quota Management",
"usage_stats": "Usage Statistics", "usage_stats": "Usage Statistics",
"config_management": "Config Management", "config_management": "Config Management",
"logs": "Logs Viewer", "logs": "Logs Viewer",
"system_info": "Management Center Info" "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": { "basic_settings": {
"title": "Basic Settings", "title": "Basic Settings",
"debug_title": "Debug Mode", "debug_title": "Debug Mode",
@@ -110,7 +137,9 @@
"usage_statistics_enable": "Enable usage statistics", "usage_statistics_enable": "Enable usage statistics",
"logging_title": "Logging", "logging_title": "Logging",
"logging_to_file_enable": "Enable logging to file", "logging_to_file_enable": "Enable logging to file",
"request_log_title": "Request Logging",
"request_log_enable": "Enable 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_title": "WebSocket Authentication",
"ws_auth_enable": "Require auth for /ws/*" "ws_auth_enable": "Require auth for /ws/*"
}, },
@@ -149,6 +178,9 @@
"excluded_models_placeholder": "Comma or newline separated, e.g. gemini-1.5-pro, gemini-1.5-flash", "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_hint": "Leave empty to allow all models; values are trimmed and deduplicated automatically.",
"excluded_models_count": "Excluding {{count}} models", "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_toggle_label": "Enabled",
"config_disabled_badge": "Disabled", "config_disabled_badge": "Disabled",
"codex_title": "Codex API Configuration", "codex_title": "Codex API Configuration",
@@ -200,8 +232,6 @@
"ampcode_upstream_api_key_current": "Current Amp official key: {{key}}", "ampcode_upstream_api_key_current": "Current Amp official key: {{key}}",
"ampcode_clear_upstream_api_key": "Clear official 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_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_label": "Force model mappings",
"ampcode_force_model_mappings_hint": "When enabled, mappings override local API-key availability checks.", "ampcode_force_model_mappings_hint": "When enabled, mappings override local API-key availability checks.",
"ampcode_model_mappings_label": "Model mappings (from → to)", "ampcode_model_mappings_label": "Model mappings (from → to)",
@@ -331,8 +361,55 @@
"models_excluded_badge": "Excluded", "models_excluded_badge": "Excluded",
"models_excluded_hint": "This model is excluded by OAuth" "models_excluded_hint": "This model is excluded by OAuth"
}, },
"antigravity_quota": {
"title": "Antigravity Quota",
"empty_title": "No Antigravity Auth Files",
"empty_desc": "Upload an Antigravity credential to view remaining quota.",
"idle": "Not loaded. Click Refresh Button.",
"loading": "Loading quota...",
"load_failed": "Failed to load quota: {{message}}",
"missing_auth_index": "Auth file missing auth_index",
"empty_models": "No quota data available",
"refresh_button": "Refresh Quota",
"fetch_all": "Fetch All"
},
"codex_quota": {
"title": "Codex Quota",
"empty_title": "No Codex Auth Files",
"empty_desc": "Upload a Codex credential to view quota.",
"idle": "Not loaded. Click Refresh Button.",
"loading": "Loading quota...",
"load_failed": "Failed to load quota: {{message}}",
"missing_auth_index": "Auth file missing auth_index",
"missing_account_id": "Codex credential missing ChatGPT account ID",
"empty_windows": "No quota data available",
"no_access": "This credential has no Codex access (plan: free).",
"refresh_button": "Refresh Quota",
"fetch_all": "Fetch All",
"primary_window": "5-hour limit",
"secondary_window": "Weekly limit",
"code_review_window": "Code review limit",
"plan_label": "Plan",
"plan_plus": "Plus",
"plan_team": "Team",
"plan_free": "Free"
},
"gemini_cli_quota": {
"title": "Gemini CLI Quota",
"empty_title": "No Gemini CLI Auth Files",
"empty_desc": "Upload a Gemini CLI credential to view remaining quota.",
"idle": "Not loaded. Click Refresh Button.",
"loading": "Loading quota...",
"load_failed": "Failed to load quota: {{message}}",
"missing_auth_index": "Auth file missing auth_index",
"missing_project_id": "Gemini CLI credential missing project ID",
"empty_buckets": "No quota data available",
"refresh_button": "Refresh Quota",
"fetch_all": "Fetch All",
"remaining_amount": "Remaining {{count}}"
},
"vertex_import": { "vertex_import": {
"title": "Vertex AI Credential Import", "title": "Vertex JSON Login",
"description": "Upload a Google service account JSON to store it as auth-dir/vertex-<project>.json using the same rules as the CLI vertex-import helper.", "description": "Upload a Google service account JSON to store it as auth-dir/vertex-<project>.json using the same rules as the CLI vertex-import helper.",
"location_label": "Region (optional)", "location_label": "Region (optional)",
"location_placeholder": "us-central1", "location_placeholder": "us-central1",
@@ -424,9 +501,10 @@
"gemini_cli_oauth_title": "Gemini CLI OAuth", "gemini_cli_oauth_title": "Gemini CLI OAuth",
"gemini_cli_oauth_button": "Start Gemini CLI Login", "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_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_label": "Google Cloud Project ID:",
"gemini_cli_project_id_placeholder": "Enter Google Cloud Project ID (optional)", "gemini_cli_project_id_placeholder": "Enter Google Cloud Project ID",
"gemini_cli_project_id_hint": "If a project ID is specified, authentication information for that project will be used.", "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_oauth_url_label": "Authorization URL:",
"gemini_cli_open_link": "Open Link", "gemini_cli_open_link": "Open Link",
"gemini_cli_copy_link": "Copy Link", "gemini_cli_copy_link": "Copy Link",
@@ -446,6 +524,16 @@
"qwen_oauth_status_error": "Authentication failed:", "qwen_oauth_status_error": "Authentication failed:",
"qwen_oauth_start_error": "Failed to start Qwen OAuth:", "qwen_oauth_start_error": "Failed to start Qwen OAuth:",
"qwen_oauth_polling_error": "Failed to check authentication status:", "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", "missing_state": "Unable to retrieve authentication state parameter",
"iflow_oauth_title": "iFlow OAuth", "iflow_oauth_title": "iFlow OAuth",
"iflow_oauth_button": "Start iFlow Login", "iflow_oauth_button": "Start iFlow Login",
@@ -497,6 +585,11 @@
"by_hour": "By Hour", "by_hour": "By Hour",
"by_day": "By Day", "by_day": "By Day",
"refresh": "Refresh", "refresh": "Refresh",
"export": "Export",
"import": "Import",
"export_success": "Usage export downloaded",
"import_success": "Import complete: added {{added}}, skipped {{skipped}}, total {{total}}, failed {{failed}}",
"import_invalid": "Invalid usage export file",
"chart_line_label_1": "Line 1", "chart_line_label_1": "Line 1",
"chart_line_label_2": "Line 2", "chart_line_label_2": "Line 2",
"chart_line_label_3": "Line 3", "chart_line_label_3": "Line 3",
@@ -552,12 +645,16 @@
"error_log_button": "Select Error Log", "error_log_button": "Select Error Log",
"error_logs_modal_title": "Error Request Logs", "error_logs_modal_title": "Error Request Logs",
"error_logs_description": "Pick an error request log file to download (only generated when request logging is off).", "error_logs_description": "Pick an error request log file to download (only generated when request logging is off).",
"error_logs_request_log_enabled": "Request logging is enabled, so this list will always be empty. Disable request logging and refresh to view error logs.",
"error_logs_empty": "No error request log files found", "error_logs_empty": "No error request log files found",
"error_logs_load_error": "Failed to load error log list", "error_logs_load_error": "Failed to load error log list",
"error_logs_size": "Size", "error_logs_size": "Size",
"error_logs_modified": "Last modified", "error_logs_modified": "Last modified",
"error_logs_download": "Download", "error_logs_download": "Download",
"error_log_download_success": "Error log downloaded successfully", "error_log_download_success": "Error log downloaded successfully",
"request_log_download_title": "Download Request Log",
"request_log_download_confirm": "Download request log for ID {{id}}?",
"request_log_download_success": "Request log downloaded successfully",
"empty_title": "No Logs Available", "empty_title": "No Logs Available",
"empty_desc": "When \"Enable logging to file\" is enabled, logs will be displayed here", "empty_desc": "When \"Enable logging to file\" is enabled, logs will be displayed here",
"log_content": "Log Content", "log_content": "Log Content",
@@ -571,6 +668,8 @@
"auto_refresh_disabled": "Auto refresh disabled", "auto_refresh_disabled": "Auto refresh disabled",
"load_more_hint": "Scroll up to load more", "load_more_hint": "Scroll up to load more",
"hidden_lines": "Hidden: {{count}} lines", "hidden_lines": "Hidden: {{count}} lines",
"loaded_lines": "Loaded: {{count}} lines",
"filtered_lines": "Filtered: {{count}} lines",
"hide_management_logs": "Hide {{prefix}} logs", "hide_management_logs": "Hide {{prefix}} logs",
"search_placeholder": "Search logs by content or keyword", "search_placeholder": "Search logs by content or keyword",
"search_empty_title": "No matching logs found", "search_empty_title": "No matching logs found",
@@ -607,6 +706,11 @@
"search_prev": "Previous", "search_prev": "Previous",
"search_next": "Next" "search_next": "Next"
}, },
"quota_management": {
"title": "Quota Management",
"description": "Monitor OAuth quota status for Antigravity, Codex, and Gemini CLI credentials.",
"refresh_files": "Refresh auth files"
},
"system_info": { "system_info": {
"title": "Management Center Info", "title": "Management Center Info",
"connection_status_title": "Connection Status", "connection_status_title": "Connection Status",
@@ -642,7 +746,11 @@
"link_webui_repo": "WebUI Repository", "link_webui_repo": "WebUI Repository",
"link_webui_repo_desc": "Management Center frontend source code", "link_webui_repo_desc": "Management Center frontend source code",
"link_docs": "Documentation", "link_docs": "Documentation",
"link_docs_desc": "Usage tutorials and configuration guides" "link_docs_desc": "Usage tutorials and configuration guides",
"clear_login_title": "Local Login Data",
"clear_login_desc": "Clear locally saved login data and sign out. Usage stats pricing settings will remain untouched.",
"clear_login_button": "Clear login data",
"clear_login_confirm": "Clear local login data and sign out now?"
}, },
"notification": { "notification": {
"debug_updated": "Debug settings updated", "debug_updated": "Debug settings updated",
@@ -655,6 +763,7 @@
"logging_to_file_updated": "Logging settings updated", "logging_to_file_updated": "Logging settings updated",
"request_log_updated": "Request logging setting updated", "request_log_updated": "Request logging setting updated",
"ws_auth_updated": "WebSocket authentication setting updated", "ws_auth_updated": "WebSocket authentication setting updated",
"login_storage_cleared": "Local login data cleared",
"api_key_added": "API key added successfully", "api_key_added": "API key added successfully",
"api_key_updated": "API key updated successfully", "api_key_updated": "API key updated successfully",
"api_key_deleted": "API key deleted successfully", "api_key_deleted": "API key deleted successfully",

View File

@@ -29,10 +29,13 @@
"required": "必填", "required": "必填",
"api_key": "密钥", "api_key": "密钥",
"base_url": "地址", "base_url": "地址",
"prefix": "前缀",
"proxy_url": "代理", "proxy_url": "代理",
"alias": "别名", "alias": "别名",
"failure": "失败", "failure": "失败",
"unknown_error": "未知错误", "unknown_error": "未知错误",
"quota_update_required": "请更新 CPA 版本或检查更新",
"quota_check_credential": "请检查凭证状态",
"copy": "复制", "copy": "复制",
"custom_headers_label": "自定义请求头", "custom_headers_label": "自定义请求头",
"custom_headers_hint": "可选,设置需要附带到请求中的 HTTP 头,名称和值均不能为空。", "custom_headers_hint": "可选,设置需要附带到请求中的 HTTP 头,名称和值均不能为空。",
@@ -60,6 +63,7 @@
"custom_connection_placeholder": "例如: https://example.com:8317", "custom_connection_placeholder": "例如: https://example.com:8317",
"custom_connection_hint": "默认使用当前访问地址,若需要可手动输入其他地址。", "custom_connection_hint": "默认使用当前访问地址,若需要可手动输入其他地址。",
"use_current_address": "使用当前地址", "use_current_address": "使用当前地址",
"remember_password_label": "记住密码",
"management_key_label": "管理密钥:", "management_key_label": "管理密钥:",
"management_key_placeholder": "请输入管理密钥", "management_key_placeholder": "请输入管理密钥",
"connect_button": "连接", "connect_button": "连接",
@@ -81,16 +85,39 @@
"status": "连接状态:" "status": "连接状态:"
}, },
"nav": { "nav": {
"dashboard": "仪表盘",
"basic_settings": "基础设置", "basic_settings": "基础设置",
"api_keys": "API 密钥", "api_keys": "API 密钥",
"ai_providers": "AI 提供商", "ai_providers": "AI 提供商",
"auth_files": "认证文件", "auth_files": "认证文件",
"oauth": "OAuth 登录", "oauth": "OAuth 登录",
"quota_management": "配额管理",
"usage_stats": "使用统计", "usage_stats": "使用统计",
"config_management": "配置管理", "config_management": "配置管理",
"logs": "日志查看", "logs": "日志查看",
"system_info": "中心信息" "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": { "basic_settings": {
"title": "基础设置", "title": "基础设置",
"debug_title": "调试模式", "debug_title": "调试模式",
@@ -110,7 +137,9 @@
"usage_statistics_enable": "启用使用统计", "usage_statistics_enable": "启用使用统计",
"logging_title": "日志记录", "logging_title": "日志记录",
"logging_to_file_enable": "启用日志记录到文件", "logging_to_file_enable": "启用日志记录到文件",
"request_log_title": "请求日志",
"request_log_enable": "启用请求日志", "request_log_enable": "启用请求日志",
"request_log_warning": "仅在需要排查问题时开启,日常请保持关闭。",
"ws_auth_title": "WebSocket 鉴权", "ws_auth_title": "WebSocket 鉴权",
"ws_auth_enable": "启用 /ws/* 鉴权" "ws_auth_enable": "启用 /ws/* 鉴权"
}, },
@@ -149,6 +178,9 @@
"excluded_models_placeholder": "用逗号或换行分隔,例如: gemini-1.5-pro, gemini-1.5-flash", "excluded_models_placeholder": "用逗号或换行分隔,例如: gemini-1.5-pro, gemini-1.5-flash",
"excluded_models_hint": "留空表示不过滤;保存时会自动去重并忽略空白。", "excluded_models_hint": "留空表示不过滤;保存时会自动去重并忽略空白。",
"excluded_models_count": "排除 {{count}} 个模型", "excluded_models_count": "排除 {{count}} 个模型",
"prefix_label": "前缀 (可选):",
"prefix_placeholder": "例如: team-a",
"prefix_hint": "设置后可用 prefix/<model> 选择该条目。",
"config_toggle_label": "启用", "config_toggle_label": "启用",
"config_disabled_badge": "已停用", "config_disabled_badge": "已停用",
"codex_title": "Codex API 配置", "codex_title": "Codex API 配置",
@@ -200,8 +232,6 @@
"ampcode_upstream_api_key_current": "当前Amp官方密钥: {{key}}", "ampcode_upstream_api_key_current": "当前Amp官方密钥: {{key}}",
"ampcode_clear_upstream_api_key": "清除官方密钥", "ampcode_clear_upstream_api_key": "清除官方密钥",
"ampcode_clear_upstream_api_key_confirm": "确定要清除 Ampcode 的 upstream API keyAmp官方", "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_label": "强制应用模型映射",
"ampcode_force_model_mappings_hint": "开启后,模型映射将覆盖本地 API Key 可用性判断。", "ampcode_force_model_mappings_hint": "开启后,模型映射将覆盖本地 API Key 可用性判断。",
"ampcode_model_mappings_label": "模型映射 (from → to)", "ampcode_model_mappings_label": "模型映射 (from → to)",
@@ -331,8 +361,55 @@
"models_excluded_badge": "已排除", "models_excluded_badge": "已排除",
"models_excluded_hint": "此模型已被 OAuth 排除" "models_excluded_hint": "此模型已被 OAuth 排除"
}, },
"antigravity_quota": {
"title": "Antigravity 额度",
"empty_title": "暂无 Antigravity 认证",
"empty_desc": "上传 Antigravity 认证文件后即可查看额度。",
"idle": "尚未加载额度,请点击刷新按钮。",
"loading": "正在加载额度...",
"load_failed": "额度获取失败:{{message}}",
"missing_auth_index": "认证文件缺少 auth_index",
"empty_models": "暂无额度数据",
"refresh_button": "刷新额度",
"fetch_all": "获取全部"
},
"codex_quota": {
"title": "Codex 额度",
"empty_title": "暂无 Codex 认证",
"empty_desc": "上传 Codex 认证文件后即可查看额度。",
"idle": "尚未加载额度,请点击刷新按钮。",
"loading": "正在加载额度...",
"load_failed": "额度获取失败:{{message}}",
"missing_auth_index": "认证文件缺少 auth_index",
"missing_account_id": "Codex 凭证缺少 ChatGPT 账号 ID",
"empty_windows": "暂无额度数据",
"no_access": "该凭证已无 Codex 访问权限free。",
"refresh_button": "刷新额度",
"fetch_all": "获取全部",
"primary_window": "5 小时限额",
"secondary_window": "周限额",
"code_review_window": "代码审查限额",
"plan_label": "套餐",
"plan_plus": "Plus",
"plan_team": "Team",
"plan_free": "Free"
},
"gemini_cli_quota": {
"title": "Gemini CLI 额度",
"empty_title": "暂无 Gemini CLI 认证",
"empty_desc": "上传 Gemini CLI 认证文件后即可查看额度。",
"idle": "尚未加载额度,请点击刷新按钮。",
"loading": "正在加载额度...",
"load_failed": "额度获取失败:{{message}}",
"missing_auth_index": "认证文件缺少 auth_index",
"missing_project_id": "Gemini CLI 凭证缺少 Project ID",
"empty_buckets": "暂无额度数据",
"refresh_button": "刷新额度",
"fetch_all": "获取全部",
"remaining_amount": "剩余 {{count}}"
},
"vertex_import": { "vertex_import": {
"title": "Vertex AI 凭证导入", "title": "Vertex JSON 登录",
"description": "上传 Google 服务账号 JSON使用 CLI vertex-import 同步规则写入 auth-dir/vertex-<project>.json。", "description": "上传 Google 服务账号 JSON使用 CLI vertex-import 同步规则写入 auth-dir/vertex-<project>.json。",
"location_label": "目标区域 (可选)", "location_label": "目标区域 (可选)",
"location_placeholder": "us-central1", "location_placeholder": "us-central1",
@@ -424,9 +501,10 @@
"gemini_cli_oauth_title": "Gemini CLI OAuth", "gemini_cli_oauth_title": "Gemini CLI OAuth",
"gemini_cli_oauth_button": "开始 Gemini CLI 登录", "gemini_cli_oauth_button": "开始 Gemini CLI 登录",
"gemini_cli_oauth_hint": "通过 OAuth 流程登录 Google Gemini CLI 服务,自动获取并保存认证文件。", "gemini_cli_oauth_hint": "通过 OAuth 流程登录 Google Gemini CLI 服务,自动获取并保存认证文件。",
"gemini_cli_project_id_label": "Google Cloud 项目 ID (可选):", "gemini_cli_project_id_label": "Google Cloud 项目 ID:",
"gemini_cli_project_id_placeholder": "输入 Google Cloud 项目 ID (可选)", "gemini_cli_project_id_placeholder": "输入 Google Cloud 项目 ID",
"gemini_cli_project_id_hint": "如果指定了项目 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_oauth_url_label": "授权链接:",
"gemini_cli_open_link": "打开链接", "gemini_cli_open_link": "打开链接",
"gemini_cli_copy_link": "复制链接", "gemini_cli_copy_link": "复制链接",
@@ -446,6 +524,16 @@
"qwen_oauth_status_error": "认证失败:", "qwen_oauth_status_error": "认证失败:",
"qwen_oauth_start_error": "启动 Qwen OAuth 失败:", "qwen_oauth_start_error": "启动 Qwen OAuth 失败:",
"qwen_oauth_polling_error": "检查认证状态失败:", "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": "无法获取认证状态参数", "missing_state": "无法获取认证状态参数",
"iflow_oauth_title": "iFlow OAuth", "iflow_oauth_title": "iFlow OAuth",
"iflow_oauth_button": "开始 iFlow 登录", "iflow_oauth_button": "开始 iFlow 登录",
@@ -497,6 +585,11 @@
"by_hour": "按小时", "by_hour": "按小时",
"by_day": "按天", "by_day": "按天",
"refresh": "刷新", "refresh": "刷新",
"export": "导出数据",
"import": "导入数据",
"export_success": "使用统计已导出",
"import_success": "导入完成:新增 {{added}},跳过 {{skipped}},总请求 {{total}},失败 {{failed}}",
"import_invalid": "导入文件格式不正确",
"chart_line_label_1": "曲线 1", "chart_line_label_1": "曲线 1",
"chart_line_label_2": "曲线 2", "chart_line_label_2": "曲线 2",
"chart_line_label_3": "曲线 3", "chart_line_label_3": "曲线 3",
@@ -552,12 +645,16 @@
"error_log_button": "选择错误日志", "error_log_button": "选择错误日志",
"error_logs_modal_title": "错误请求日志", "error_logs_modal_title": "错误请求日志",
"error_logs_description": "请选择要下载的错误请求日志文件(仅在关闭请求日志时生成)。", "error_logs_description": "请选择要下载的错误请求日志文件(仅在关闭请求日志时生成)。",
"error_logs_request_log_enabled": "当前已开启请求日志,按接口约定错误请求日志列表会始终为空。关闭请求日志后再刷新即可查看。",
"error_logs_empty": "暂无错误请求日志文件", "error_logs_empty": "暂无错误请求日志文件",
"error_logs_load_error": "加载错误日志列表失败", "error_logs_load_error": "加载错误日志列表失败",
"error_logs_size": "大小", "error_logs_size": "大小",
"error_logs_modified": "最后修改", "error_logs_modified": "最后修改",
"error_logs_download": "下载", "error_logs_download": "下载",
"error_log_download_success": "错误日志下载成功", "error_log_download_success": "错误日志下载成功",
"request_log_download_title": "下载报文",
"request_log_download_confirm": "是否要下载id为{{id}}的报文?",
"request_log_download_success": "报文下载成功",
"empty_title": "暂无日志记录", "empty_title": "暂无日志记录",
"empty_desc": "当启用\"日志记录到文件\"功能后,日志将显示在这里", "empty_desc": "当启用\"日志记录到文件\"功能后,日志将显示在这里",
"log_content": "日志内容", "log_content": "日志内容",
@@ -571,6 +668,8 @@
"auto_refresh_disabled": "自动刷新已关闭", "auto_refresh_disabled": "自动刷新已关闭",
"load_more_hint": "向上滚动加载更多", "load_more_hint": "向上滚动加载更多",
"hidden_lines": "已隐藏 {{count}} 行", "hidden_lines": "已隐藏 {{count}} 行",
"loaded_lines": "已载入 {{count}} 行",
"filtered_lines": "已过滤 {{count}} 行",
"hide_management_logs": "屏蔽 {{prefix}} 日志", "hide_management_logs": "屏蔽 {{prefix}} 日志",
"search_placeholder": "搜索日志内容或关键字", "search_placeholder": "搜索日志内容或关键字",
"search_empty_title": "未找到匹配的日志", "search_empty_title": "未找到匹配的日志",
@@ -607,6 +706,11 @@
"search_prev": "上一个", "search_prev": "上一个",
"search_next": "下一个" "search_next": "下一个"
}, },
"quota_management": {
"title": "配额管理",
"description": "集中查看 OAuth 额度与剩余情况",
"refresh_files": "刷新认证文件"
},
"system_info": { "system_info": {
"title": "管理中心信息", "title": "管理中心信息",
"connection_status_title": "连接状态", "connection_status_title": "连接状态",
@@ -642,7 +746,11 @@
"link_webui_repo": "WebUI 仓库", "link_webui_repo": "WebUI 仓库",
"link_webui_repo_desc": "管理中心前端界面源代码", "link_webui_repo_desc": "管理中心前端界面源代码",
"link_docs": "使用教程", "link_docs": "使用教程",
"link_docs_desc": "配置指南和使用说明" "link_docs_desc": "配置指南和使用说明",
"clear_login_title": "本地登录信息",
"clear_login_desc": "清理本地保存的登录信息并退出登录,不会影响使用统计中的价格设置。",
"clear_login_button": "清理登录信息",
"clear_login_confirm": "确认清理本地登录信息并退出登录?"
}, },
"notification": { "notification": {
"debug_updated": "调试设置已更新", "debug_updated": "调试设置已更新",
@@ -655,6 +763,7 @@
"logging_to_file_updated": "日志记录设置已更新", "logging_to_file_updated": "日志记录设置已更新",
"request_log_updated": "请求日志设置已更新", "request_log_updated": "请求日志设置已更新",
"ws_auth_updated": "WebSocket 鉴权设置已更新", "ws_auth_updated": "WebSocket 鉴权设置已更新",
"login_storage_cleared": "本地登录信息已清理",
"api_key_added": "API密钥添加成功", "api_key_added": "API密钥添加成功",
"api_key_updated": "API密钥更新成功", "api_key_updated": "API密钥更新成功",
"api_key_deleted": "API密钥删除成功", "api_key_deleted": "API密钥删除成功",

View File

@@ -5,6 +5,17 @@
width: 100%; width: 100%;
} }
.cardTitle {
display: flex;
align-items: center;
gap: $spacing-sm;
}
.cardTitleIcon {
width: 24px;
height: 24px;
}
.pageTitle { .pageTitle {
font-size: 28px; font-size: 28px;
font-weight: 700; font-weight: 700;
@@ -386,6 +397,79 @@
line-height: 1.5; line-height: 1.5;
} }
// 状态监测栏
.statusBar {
display: flex;
align-items: center;
gap: 8px;
margin-top: 8px;
padding: 8px 0;
max-width: 280px;
}
.statusBlocks {
display: flex;
gap: 2px;
flex: 1;
min-width: 180px;
}
.statusBlock {
flex: 1;
height: 8px;
border-radius: 2px;
min-width: 6px;
transition: transform 0.15s ease, opacity 0.15s ease;
&:hover {
transform: scaleY(1.5);
opacity: 0.85;
}
}
.statusBlockSuccess {
background-color: var(--success-color, #22c55e);
}
.statusBlockFailure {
background-color: var(--danger-color, #ef4444);
}
.statusBlockMixed {
background-color: var(--warning-color, #f59e0b);
}
.statusBlockIdle {
background-color: var(--border-secondary, #e5e7eb);
}
.statusRate {
display: flex;
align-items: center;
gap: 4px;
font-size: 12px;
font-weight: 600;
white-space: nowrap;
padding: 4px 8px;
border-radius: 6px;
background: var(--bg-tertiary);
}
.statusRateHigh {
color: var(--success-badge-text, #065f46);
background: var(--success-badge-bg, #d1fae5);
}
.statusRateMedium {
color: var(--warning-text, #92400e);
background: var(--warning-bg, #fef3c7);
}
.statusRateLow {
color: var(--failure-badge-text, #991b1b);
background: var(--failure-badge-bg, #fee2e2);
}
// 暗色主题适配 // 暗色主题适配
:global([data-theme='dark']) { :global([data-theme='dark']) {
.headerBadge { .headerBadge {
@@ -404,8 +488,17 @@
} }
.excludedModelTag { .excludedModelTag {
background: rgba(251, 191, 36, 0.2); background: rgba(251, 191, 36, 0.22);
border-color: rgba(251, 191, 36, 0.4); border-color: rgba(251, 191, 36, 0.55);
color: #fde68a;
.modelName {
color: #fde68a;
}
}
.excludedModelsLabel {
color: #fde68a;
} }
.apiKeyEntryCard { .apiKeyEntryCard {
@@ -416,4 +509,23 @@
.apiKeyEntryIndex { .apiKeyEntryIndex {
background: var(--primary-color); background: var(--primary-color);
} }
.statusBlockIdle {
background-color: var(--border-primary, #374151);
}
.statusRateHigh {
background: rgba(34, 197, 94, 0.2);
color: #86efac;
}
.statusRateMedium {
background: rgba(251, 191, 36, 0.2);
color: #fde68a;
}
.statusRateLow {
background: rgba(239, 68, 68, 0.2);
color: #fca5a5;
}
} }

View File

@@ -1,5 +1,6 @@
import { Fragment, useCallback, useEffect, useMemo, useState, type ReactNode } from 'react'; import { Fragment, useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useInterval } from '@/hooks/useInterval';
import { Card } from '@/components/ui/Card'; import { Card } from '@/components/ui/Card';
import { Button } from '@/components/ui/Button'; import { Button } from '@/components/ui/Button';
import { Input } from '@/components/ui/Input'; import { Input } from '@/components/ui/Input';
@@ -9,8 +10,20 @@ import { HeaderInputList } from '@/components/ui/HeaderInputList';
import { ModelInputList, modelsToEntries, entriesToModels } from '@/components/ui/ModelInputList'; import { ModelInputList, modelsToEntries, entriesToModels } from '@/components/ui/ModelInputList';
import { ToggleSwitch } from '@/components/ui/ToggleSwitch'; import { ToggleSwitch } from '@/components/ui/ToggleSwitch';
import { IconCheck, IconX } from '@/components/ui/icons'; import { IconCheck, IconX } from '@/components/ui/icons';
import { useAuthStore, useConfigStore, useNotificationStore } from '@/stores'; import { useAuthStore, useConfigStore, useNotificationStore, useThemeStore } from '@/stores';
import { ampcodeApi, modelsApi, providersApi, usageApi } from '@/services/api'; import {
ampcodeApi,
apiCallApi,
getApiCallErrorMessage,
modelsApi,
providersApi,
usageApi
} from '@/services/api';
import iconGemini from '@/assets/icons/gemini.svg';
import iconOpenaiLight from '@/assets/icons/openai-light.svg';
import iconOpenaiDark from '@/assets/icons/openai-dark.svg';
import iconClaude from '@/assets/icons/claude.svg';
import iconAmp from '@/assets/icons/amp.svg';
import type { import type {
GeminiKeyConfig, GeminiKeyConfig,
ProviderKeyConfig, ProviderKeyConfig,
@@ -19,7 +32,8 @@ import type {
AmpcodeConfig, AmpcodeConfig,
AmpcodeModelMapping, AmpcodeModelMapping,
} from '@/types'; } from '@/types';
import type { KeyStats, KeyStatBucket } from '@/utils/usage'; import type { KeyStats, KeyStatBucket, UsageDetail } from '@/utils/usage';
import { collectUsageDetails, calculateStatusBarData } from '@/utils/usage';
import type { ModelInfo } from '@/utils/models'; import type { ModelInfo } from '@/utils/models';
import { headersToEntries, buildHeaderObject, type HeaderEntry } from '@/utils/headers'; import { headersToEntries, buildHeaderObject, type HeaderEntry } from '@/utils/headers';
import { maskApiKey } from '@/utils/format'; import { maskApiKey } from '@/utils/format';
@@ -39,6 +53,7 @@ interface ModelEntry {
interface OpenAIFormState { interface OpenAIFormState {
name: string; name: string;
prefix: string;
baseUrl: string; baseUrl: string;
headers: HeaderEntry[]; headers: HeaderEntry[];
testModel?: string; testModel?: string;
@@ -49,7 +64,6 @@ interface OpenAIFormState {
interface AmpcodeFormState { interface AmpcodeFormState {
upstreamUrl: string; upstreamUrl: string;
upstreamApiKey: string; upstreamApiKey: string;
restrictManagementToLocalhost: boolean;
forceModelMappings: boolean; forceModelMappings: boolean;
mappingEntries: ModelEntry[]; mappingEntries: ModelEntry[];
} }
@@ -84,18 +98,25 @@ const parseExcludedModels = (text: string): string[] =>
const excludedModelsToText = (models?: string[]) => const excludedModelsToText = (models?: string[]) =>
Array.isArray(models) ? models.join('\n') : ''; Array.isArray(models) ? models.join('\n') : '';
const normalizeOpenAIBaseUrl = (baseUrl: string): string => {
let trimmed = String(baseUrl || '').trim();
if (!trimmed) return '';
trimmed = trimmed.replace(/\/?v0\/management\/?$/i, '');
trimmed = trimmed.replace(/\/+$/g, '');
if (!/^https?:\/\//i.test(trimmed)) {
trimmed = `http://${trimmed}`;
}
return trimmed;
};
const buildOpenAIModelsEndpoint = (baseUrl: string): string => { const buildOpenAIModelsEndpoint = (baseUrl: string): string => {
const trimmed = String(baseUrl || '') const trimmed = normalizeOpenAIBaseUrl(baseUrl);
.trim()
.replace(/\/+$/g, '');
if (!trimmed) return ''; if (!trimmed) return '';
return trimmed.endsWith('/v1') ? `${trimmed}/models` : `${trimmed}/v1/models`; return trimmed.endsWith('/v1') ? `${trimmed}/models` : `${trimmed}/v1/models`;
}; };
const buildOpenAIChatCompletionsEndpoint = (baseUrl: string): string => { const buildOpenAIChatCompletionsEndpoint = (baseUrl: string): string => {
const trimmed = String(baseUrl || '') const trimmed = normalizeOpenAIBaseUrl(baseUrl);
.trim()
.replace(/\/+$/g, '');
if (!trimmed) return ''; if (!trimmed) return '';
if (trimmed.endsWith('/chat/completions')) { if (trimmed.endsWith('/chat/completions')) {
return trimmed; return trimmed;
@@ -174,7 +195,6 @@ const entriesToAmpcodeMappings = (entries: ModelEntry[]): AmpcodeModelMapping[]
const buildAmpcodeFormState = (ampcode?: AmpcodeConfig | null): AmpcodeFormState => ({ const buildAmpcodeFormState = (ampcode?: AmpcodeConfig | null): AmpcodeFormState => ({
upstreamUrl: ampcode?.upstreamUrl ?? '', upstreamUrl: ampcode?.upstreamUrl ?? '',
upstreamApiKey: '', upstreamApiKey: '',
restrictManagementToLocalhost: ampcode?.restrictManagementToLocalhost ?? true,
forceModelMappings: ampcode?.forceModelMappings ?? false, forceModelMappings: ampcode?.forceModelMappings ?? false,
mappingEntries: ampcodeMappingsToEntries(ampcode?.modelMappings), mappingEntries: ampcodeMappingsToEntries(ampcode?.modelMappings),
}); });
@@ -182,6 +202,7 @@ const buildAmpcodeFormState = (ampcode?: AmpcodeConfig | null): AmpcodeFormState
export function AiProvidersPage() { export function AiProvidersPage() {
const { t } = useTranslation(); const { t } = useTranslation();
const { showNotification } = useNotificationStore(); const { showNotification } = useNotificationStore();
const { theme } = useThemeStore();
const connectionStatus = useAuthStore((state) => state.connectionStatus); const connectionStatus = useAuthStore((state) => state.connectionStatus);
const config = useConfigStore((state) => state.config); const config = useConfigStore((state) => state.config);
@@ -197,11 +218,14 @@ export function AiProvidersPage() {
const [claudeConfigs, setClaudeConfigs] = useState<ProviderKeyConfig[]>([]); const [claudeConfigs, setClaudeConfigs] = useState<ProviderKeyConfig[]>([]);
const [openaiProviders, setOpenaiProviders] = useState<OpenAIProviderConfig[]>([]); const [openaiProviders, setOpenaiProviders] = useState<OpenAIProviderConfig[]>([]);
const [keyStats, setKeyStats] = useState<KeyStats>({ bySource: {}, byAuthIndex: {} }); const [keyStats, setKeyStats] = useState<KeyStats>({ bySource: {}, byAuthIndex: {} });
const [usageDetails, setUsageDetails] = useState<UsageDetail[]>([]);
const loadingKeyStatsRef = useRef(false);
const [modal, setModal] = useState<ProviderModal | null>(null); const [modal, setModal] = useState<ProviderModal | null>(null);
const [geminiForm, setGeminiForm] = useState<GeminiKeyConfig & { excludedText: string }>({ const [geminiForm, setGeminiForm] = useState<GeminiKeyConfig & { excludedText: string }>({
apiKey: '', apiKey: '',
prefix: '',
baseUrl: '', baseUrl: '',
headers: {}, headers: {},
excludedModels: [], excludedModels: [],
@@ -211,6 +235,7 @@ export function AiProvidersPage() {
ProviderKeyConfig & { modelEntries: ModelEntry[]; excludedText: string } ProviderKeyConfig & { modelEntries: ModelEntry[]; excludedText: string }
>({ >({
apiKey: '', apiKey: '',
prefix: '',
baseUrl: '', baseUrl: '',
proxyUrl: '', proxyUrl: '',
headers: {}, headers: {},
@@ -221,6 +246,7 @@ export function AiProvidersPage() {
}); });
const [openaiForm, setOpenaiForm] = useState<OpenAIFormState>({ const [openaiForm, setOpenaiForm] = useState<OpenAIFormState>({
name: '', name: '',
prefix: '',
baseUrl: '', baseUrl: '',
headers: [], headers: [],
apiKeyEntries: [buildApiKeyEntry()], apiKeyEntries: [buildApiKeyEntry()],
@@ -265,13 +291,23 @@ export function AiProvidersPage() {
[openaiForm.modelEntries] [openaiForm.modelEntries]
); );
// 加载 key 统计 // 加载 key 统计和 usage 明细API 层已有60秒超时
const loadKeyStats = useCallback(async () => { const loadKeyStats = useCallback(async () => {
// 防止重复请求
if (loadingKeyStatsRef.current) return;
loadingKeyStatsRef.current = true;
try { try {
const stats = await usageApi.getKeyStats(); const usageResponse = await usageApi.getUsage();
const usageData = usageResponse?.usage ?? usageResponse;
const stats = await usageApi.getKeyStats(usageData);
setKeyStats(stats); setKeyStats(stats);
// 收集 usage 明细用于状态栏
const details = collectUsageDetails(usageData);
setUsageDetails(details);
} catch { } catch {
// 静默失败 // 静默失败
} finally {
loadingKeyStatsRef.current = false;
} }
}, []); }, []);
@@ -303,6 +339,9 @@ export function AiProvidersPage() {
loadKeyStats(); loadKeyStats();
}, [loadKeyStats]); }, [loadKeyStats]);
// 定时刷新状态数据每240秒
useInterval(loadKeyStats, 240_000);
useEffect(() => { useEffect(() => {
if (config?.geminiApiKeys) setGeminiKeys(config.geminiApiKeys); if (config?.geminiApiKeys) setGeminiKeys(config.geminiApiKeys);
if (config?.codexApiKeys) setCodexConfigs(config.codexApiKeys); if (config?.codexApiKeys) setCodexConfigs(config.codexApiKeys);
@@ -319,6 +358,7 @@ export function AiProvidersPage() {
setModal(null); setModal(null);
setGeminiForm({ setGeminiForm({
apiKey: '', apiKey: '',
prefix: '',
baseUrl: '', baseUrl: '',
headers: {}, headers: {},
excludedModels: [], excludedModels: [],
@@ -326,6 +366,7 @@ export function AiProvidersPage() {
}); });
setProviderForm({ setProviderForm({
apiKey: '', apiKey: '',
prefix: '',
baseUrl: '', baseUrl: '',
proxyUrl: '', proxyUrl: '',
headers: {}, headers: {},
@@ -336,6 +377,7 @@ export function AiProvidersPage() {
}); });
setOpenaiForm({ setOpenaiForm({
name: '', name: '',
prefix: '',
baseUrl: '', baseUrl: '',
headers: [], headers: [],
apiKeyEntries: [buildApiKeyEntry()], apiKeyEntries: [buildApiKeyEntry()],
@@ -412,6 +454,7 @@ export function AiProvidersPage() {
const modelEntries = modelsToEntries(entry.models); const modelEntries = modelsToEntries(entry.models);
setOpenaiForm({ setOpenaiForm({
name: entry.name, name: entry.name,
prefix: entry.prefix ?? '',
baseUrl: entry.baseUrl, baseUrl: entry.baseUrl,
headers: headersToEntries(entry.headers), headers: headersToEntries(entry.headers),
testModel: entry.testModel, testModel: entry.testModel,
@@ -454,7 +497,7 @@ export function AiProvidersPage() {
.find((entry) => entry.apiKey?.trim()) .find((entry) => entry.apiKey?.trim())
?.apiKey?.trim(); ?.apiKey?.trim();
const hasAuthHeader = Boolean(headers.Authorization || headers['authorization']); const hasAuthHeader = Boolean(headers.Authorization || headers['authorization']);
const list = await modelsApi.fetchModels( const list = await modelsApi.fetchModelsViaApiCall(
baseUrl, baseUrl,
hasAuthHeader ? undefined : firstKey, hasAuthHeader ? undefined : firstKey,
headers headers
@@ -463,7 +506,7 @@ export function AiProvidersPage() {
} catch (err: any) { } catch (err: any) {
if (allowFallback) { if (allowFallback) {
try { try {
const list = await modelsApi.fetchModels(baseUrl); const list = await modelsApi.fetchModelsViaApiCall(baseUrl);
setOpenaiDiscoveryModels(list); setOpenaiDiscoveryModels(list);
return; return;
} catch (fallbackErr: any) { } catch (fallbackErr: any) {
@@ -616,48 +659,40 @@ export function AiProvidersPage() {
setOpenaiTestStatus('loading'); setOpenaiTestStatus('loading');
setOpenaiTestMessage(t('ai_providers.openai_test_running')); setOpenaiTestMessage(t('ai_providers.openai_test_running'));
const controller = new AbortController();
const timeoutId = window.setTimeout(() => controller.abort(), OPENAI_TEST_TIMEOUT_MS);
try { try {
const response = await fetch(endpoint, { const result = await apiCallApi.request(
{
method: 'POST', method: 'POST',
headers, url: endpoint,
signal: controller.signal, header: Object.keys(headers).length ? headers : undefined,
body: JSON.stringify({ data: JSON.stringify({
model: modelName, model: modelName,
messages: [{ role: 'user', content: 'Hi' }], messages: [{ role: 'user', content: 'Hi' }],
stream: false, stream: false,
max_tokens: 5, max_tokens: 5,
}), }),
}); },
const rawText = await response.text(); { timeout: OPENAI_TEST_TIMEOUT_MS }
);
if (!response.ok) { if (result.statusCode < 200 || result.statusCode >= 300) {
let errorMessage = `${response.status} ${response.statusText}`; throw new Error(getApiCallErrorMessage(result));
try {
const parsed = rawText ? JSON.parse(rawText) : null;
errorMessage = parsed?.error?.message || parsed?.message || errorMessage;
} catch {
if (rawText) {
errorMessage = rawText;
}
}
throw new Error(errorMessage);
} }
setOpenaiTestStatus('success'); setOpenaiTestStatus('success');
setOpenaiTestMessage(t('ai_providers.openai_test_success')); setOpenaiTestMessage(t('ai_providers.openai_test_success'));
} catch (err: any) { } catch (err: any) {
setOpenaiTestStatus('error'); setOpenaiTestStatus('error');
if (err?.name === 'AbortError') { const isTimeout =
err?.code === 'ECONNABORTED' ||
String(err?.message || '').toLowerCase().includes('timeout');
if (isTimeout) {
setOpenaiTestMessage( setOpenaiTestMessage(
t('ai_providers.openai_test_timeout', { seconds: OPENAI_TEST_TIMEOUT_MS / 1000 }) t('ai_providers.openai_test_timeout', { seconds: OPENAI_TEST_TIMEOUT_MS / 1000 })
); );
} else { } else {
setOpenaiTestMessage(`${t('ai_providers.openai_test_failed')}: ${err?.message || ''}`); setOpenaiTestMessage(`${t('ai_providers.openai_test_failed')}: ${err?.message || ''}`);
} }
} finally {
window.clearTimeout(timeoutId);
} }
}; };
@@ -701,9 +736,6 @@ export function AiProvidersPage() {
await ampcodeApi.clearUpstreamUrl(); await ampcodeApi.clearUpstreamUrl();
} }
await ampcodeApi.updateRestrictManagementToLocalhost(
ampcodeForm.restrictManagementToLocalhost
);
await ampcodeApi.updateForceModelMappings(ampcodeForm.forceModelMappings); await ampcodeApi.updateForceModelMappings(ampcodeForm.forceModelMappings);
if (ampcodeLoaded || ampcodeMappingsDirty) { if (ampcodeLoaded || ampcodeMappingsDirty) {
@@ -720,12 +752,18 @@ export function AiProvidersPage() {
const previous = config?.ampcode ?? {}; const previous = config?.ampcode ?? {};
const next: AmpcodeConfig = { const next: AmpcodeConfig = {
...previous,
upstreamUrl: upstreamUrl || undefined, upstreamUrl: upstreamUrl || undefined,
restrictManagementToLocalhost: ampcodeForm.restrictManagementToLocalhost,
forceModelMappings: ampcodeForm.forceModelMappings, forceModelMappings: ampcodeForm.forceModelMappings,
}; };
if (previous.upstreamApiKey) {
next.upstreamApiKey = previous.upstreamApiKey;
}
if (Array.isArray(previous.modelMappings)) {
next.modelMappings = previous.modelMappings;
}
if (overrideKey) { if (overrideKey) {
next.upstreamApiKey = overrideKey; next.upstreamApiKey = overrideKey;
} }
@@ -756,6 +794,7 @@ export function AiProvidersPage() {
try { try {
const payload: GeminiKeyConfig = { const payload: GeminiKeyConfig = {
apiKey: geminiForm.apiKey.trim(), apiKey: geminiForm.apiKey.trim(),
prefix: geminiForm.prefix?.trim() || undefined,
baseUrl: geminiForm.baseUrl?.trim() || undefined, baseUrl: geminiForm.baseUrl?.trim() || undefined,
headers: buildHeaderObject(headersToEntries(geminiForm.headers as any)), headers: buildHeaderObject(headersToEntries(geminiForm.headers as any)),
excludedModels: parseExcludedModels(geminiForm.excludedText), excludedModels: parseExcludedModels(geminiForm.excludedText),
@@ -887,9 +926,10 @@ export function AiProvidersPage() {
}; };
const saveProvider = async (type: 'codex' | 'claude') => { const saveProvider = async (type: 'codex' | 'claude') => {
const baseUrl = (providerForm.baseUrl ?? '').trim(); const trimmedBaseUrl = (providerForm.baseUrl ?? '').trim();
if (!baseUrl) { const baseUrl = trimmedBaseUrl || undefined;
showNotification(t('codex_base_url_required'), 'error'); if (type === 'codex' && !baseUrl) {
showNotification(t('notification.codex_base_url_required'), 'error');
return; return;
} }
@@ -899,6 +939,7 @@ export function AiProvidersPage() {
const payload: ProviderKeyConfig = { const payload: ProviderKeyConfig = {
apiKey: providerForm.apiKey.trim(), apiKey: providerForm.apiKey.trim(),
prefix: providerForm.prefix?.trim() || undefined,
baseUrl, baseUrl,
proxyUrl: providerForm.proxyUrl?.trim() || undefined, proxyUrl: providerForm.proxyUrl?.trim() || undefined,
headers: buildHeaderObject(headersToEntries(providerForm.headers as any)), headers: buildHeaderObject(headersToEntries(providerForm.headers as any)),
@@ -969,6 +1010,7 @@ export function AiProvidersPage() {
try { try {
const payload: OpenAIProviderConfig = { const payload: OpenAIProviderConfig = {
name: openaiForm.name.trim(), name: openaiForm.name.trim(),
prefix: openaiForm.prefix?.trim() || undefined,
baseUrl: openaiForm.baseUrl.trim(), baseUrl: openaiForm.baseUrl.trim(),
headers: buildHeaderObject(openaiForm.headers), headers: buildHeaderObject(openaiForm.headers),
apiKeyEntries: openaiForm.apiKeyEntries.map((entry) => ({ apiKeyEntries: openaiForm.apiKeyEntries.map((entry) => ({
@@ -1071,6 +1113,108 @@ export function AiProvidersPage() {
); );
}; };
// 预计算所有 apiKey 的状态栏数据(避免每次渲染重复计算)
const statusBarCache = useMemo(() => {
const cache = new Map<string, ReturnType<typeof calculateStatusBarData>>();
// 收集所有需要计算的 apiKey
const allApiKeys = new Set<string>();
geminiKeys.forEach((k) => k.apiKey && allApiKeys.add(k.apiKey));
codexConfigs.forEach((k) => k.apiKey && allApiKeys.add(k.apiKey));
claudeConfigs.forEach((k) => k.apiKey && allApiKeys.add(k.apiKey));
openaiProviders.forEach((p) => {
(p.apiKeyEntries || []).forEach((e) => e.apiKey && allApiKeys.add(e.apiKey));
});
// 预计算每个 apiKey 的状态数据
allApiKeys.forEach((apiKey) => {
cache.set(apiKey, calculateStatusBarData(usageDetails, apiKey));
});
return cache;
}, [usageDetails, geminiKeys, codexConfigs, claudeConfigs, openaiProviders]);
// 预计算 OpenAI 提供商的汇总状态栏数据
const openaiStatusBarCache = useMemo(() => {
const cache = new Map<string, ReturnType<typeof calculateStatusBarData>>();
openaiProviders.forEach((provider) => {
const allKeys = (provider.apiKeyEntries || []).map((e) => e.apiKey).filter(Boolean);
const filteredDetails = usageDetails.filter((detail) => allKeys.includes(detail.source));
cache.set(provider.name, calculateStatusBarData(filteredDetails));
});
return cache;
}, [usageDetails, openaiProviders]);
// 渲染状态监测栏
const renderStatusBar = (apiKey: string) => {
const statusData = statusBarCache.get(apiKey) || calculateStatusBarData([], apiKey);
const hasData = statusData.totalSuccess + statusData.totalFailure > 0;
const rateClass = !hasData
? ''
: statusData.successRate >= 90
? styles.statusRateHigh
: statusData.successRate >= 50
? styles.statusRateMedium
: styles.statusRateLow;
return (
<div className={styles.statusBar}>
<div className={styles.statusBlocks}>
{statusData.blocks.map((state, idx) => {
const blockClass =
state === 'success'
? styles.statusBlockSuccess
: state === 'failure'
? styles.statusBlockFailure
: state === 'mixed'
? styles.statusBlockMixed
: styles.statusBlockIdle;
return <div key={idx} className={`${styles.statusBlock} ${blockClass}`} />;
})}
</div>
<span className={`${styles.statusRate} ${rateClass}`}>
{hasData ? `${statusData.successRate.toFixed(1)}%` : '--'}
</span>
</div>
);
};
// 渲染 OpenAI 提供商的状态栏(汇总多个 apiKey
const renderOpenAIStatusBar = (providerName: string) => {
const statusData = openaiStatusBarCache.get(providerName) || calculateStatusBarData([]);
const hasData = statusData.totalSuccess + statusData.totalFailure > 0;
const rateClass = !hasData
? ''
: statusData.successRate >= 90
? styles.statusRateHigh
: statusData.successRate >= 50
? styles.statusRateMedium
: styles.statusRateLow;
return (
<div className={styles.statusBar}>
<div className={styles.statusBlocks}>
{statusData.blocks.map((state, idx) => {
const blockClass =
state === 'success'
? styles.statusBlockSuccess
: state === 'failure'
? styles.statusBlockFailure
: state === 'mixed'
? styles.statusBlockMixed
: styles.statusBlockIdle;
return <div key={idx} className={`${styles.statusBlock} ${blockClass}`} />;
})}
</div>
<span className={`${styles.statusRate} ${rateClass}`}>
{hasData ? `${statusData.successRate.toFixed(1)}%` : '--'}
</span>
</div>
);
};
const renderList = <T,>( const renderList = <T,>(
items: T[], items: T[],
keyField: (item: T) => string, keyField: (item: T) => string,
@@ -1078,6 +1222,8 @@ export function AiProvidersPage() {
onEdit: (index: number) => void, onEdit: (index: number) => void,
onDelete: (item: T) => void, onDelete: (item: T) => void,
addLabel: string, addLabel: string,
emptyTitle: string,
emptyDescription: string,
deleteLabel?: string, deleteLabel?: string,
options?: { options?: {
getRowDisabled?: (item: T, index: number) => boolean; getRowDisabled?: (item: T, index: number) => boolean;
@@ -1091,8 +1237,8 @@ export function AiProvidersPage() {
if (!items.length) { if (!items.length) {
return ( return (
<EmptyState <EmptyState
title={t('common.info')} title={emptyTitle}
description={t('ai_providers.gemini_empty_desc')} description={emptyDescription}
action={ action={
<Button onClick={() => onEdit(-1)} disabled={disableControls}> <Button onClick={() => onEdit(-1)} disabled={disableControls}>
{addLabel} {addLabel}
@@ -1146,7 +1292,12 @@ export function AiProvidersPage() {
{error && <div className="error-box">{error}</div>} {error && <div className="error-box">{error}</div>}
<Card <Card
title={t('ai_providers.gemini_title')} title={
<span className={styles.cardTitle}>
<img src={iconGemini} alt="" className={styles.cardTitleIcon} />
{t('ai_providers.gemini_title')}
</span>
}
extra={ extra={
<Button <Button
size="sm" size="sm"
@@ -1175,6 +1326,12 @@ export function AiProvidersPage() {
<span className={styles.fieldLabel}>{t('common.api_key')}:</span> <span className={styles.fieldLabel}>{t('common.api_key')}:</span>
<span className={styles.fieldValue}>{maskApiKey(item.apiKey)}</span> <span className={styles.fieldValue}>{maskApiKey(item.apiKey)}</span>
</div> </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 行 */} {/* Base URL 行 */}
{item.baseUrl && ( {item.baseUrl && (
<div className={styles.fieldRow}> <div className={styles.fieldRow}>
@@ -1224,12 +1381,16 @@ export function AiProvidersPage() {
{t('stats.failure')}: {stats.failure} {t('stats.failure')}: {stats.failure}
</span> </span>
</div> </div>
{/* 状态监测栏 */}
{renderStatusBar(item.apiKey)}
</Fragment> </Fragment>
); );
}, },
(index) => openGeminiModal(index), (index) => openGeminiModal(index),
(item) => deleteGemini(item.apiKey), (item) => deleteGemini(item.apiKey),
t('ai_providers.gemini_add_button'), t('ai_providers.gemini_add_button'),
t('ai_providers.gemini_empty_title'),
t('ai_providers.gemini_empty_desc'),
undefined, undefined,
{ {
getRowDisabled: (item) => hasDisableAllModelsRule(item.excludedModels), getRowDisabled: (item) => hasDisableAllModelsRule(item.excludedModels),
@@ -1246,7 +1407,12 @@ export function AiProvidersPage() {
</Card> </Card>
<Card <Card
title={t('ai_providers.codex_title')} title={
<span className={styles.cardTitle}>
<img src={theme === 'dark' ? iconOpenaiDark : iconOpenaiLight} alt="" className={styles.cardTitleIcon} />
{t('ai_providers.codex_title')}
</span>
}
extra={ extra={
<Button <Button
size="sm" size="sm"
@@ -1273,6 +1439,12 @@ export function AiProvidersPage() {
<span className={styles.fieldLabel}>{t('common.api_key')}:</span> <span className={styles.fieldLabel}>{t('common.api_key')}:</span>
<span className={styles.fieldValue}>{maskApiKey(item.apiKey)}</span> <span className={styles.fieldValue}>{maskApiKey(item.apiKey)}</span>
</div> </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 行 */} {/* Base URL 行 */}
{item.baseUrl && ( {item.baseUrl && (
<div className={styles.fieldRow}> <div className={styles.fieldRow}>
@@ -1329,12 +1501,16 @@ export function AiProvidersPage() {
{t('stats.failure')}: {stats.failure} {t('stats.failure')}: {stats.failure}
</span> </span>
</div> </div>
{/* 状态监测栏 */}
{renderStatusBar(item.apiKey)}
</Fragment> </Fragment>
); );
}, },
(index) => openProviderModal('codex', index), (index) => openProviderModal('codex', index),
(item) => deleteProviderEntry('codex', item.apiKey), (item) => deleteProviderEntry('codex', item.apiKey),
t('ai_providers.codex_add_button'), t('ai_providers.codex_add_button'),
t('ai_providers.codex_empty_title'),
t('ai_providers.codex_empty_desc'),
undefined, undefined,
{ {
getRowDisabled: (item) => hasDisableAllModelsRule(item.excludedModels), getRowDisabled: (item) => hasDisableAllModelsRule(item.excludedModels),
@@ -1351,7 +1527,12 @@ export function AiProvidersPage() {
</Card> </Card>
<Card <Card
title={t('ai_providers.claude_title')} title={
<span className={styles.cardTitle}>
<img src={iconClaude} alt="" className={styles.cardTitleIcon} />
{t('ai_providers.claude_title')}
</span>
}
extra={ extra={
<Button <Button
size="sm" size="sm"
@@ -1378,6 +1559,12 @@ export function AiProvidersPage() {
<span className={styles.fieldLabel}>{t('common.api_key')}:</span> <span className={styles.fieldLabel}>{t('common.api_key')}:</span>
<span className={styles.fieldValue}>{maskApiKey(item.apiKey)}</span> <span className={styles.fieldValue}>{maskApiKey(item.apiKey)}</span>
</div> </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 行 */} {/* Base URL 行 */}
{item.baseUrl && ( {item.baseUrl && (
<div className={styles.fieldRow}> <div className={styles.fieldRow}>
@@ -1450,12 +1637,16 @@ export function AiProvidersPage() {
{t('stats.failure')}: {stats.failure} {t('stats.failure')}: {stats.failure}
</span> </span>
</div> </div>
{/* 状态监测栏 */}
{renderStatusBar(item.apiKey)}
</Fragment> </Fragment>
); );
}, },
(index) => openProviderModal('claude', index), (index) => openProviderModal('claude', index),
(item) => deleteProviderEntry('claude', item.apiKey), (item) => deleteProviderEntry('claude', item.apiKey),
t('ai_providers.claude_add_button'), t('ai_providers.claude_add_button'),
t('ai_providers.claude_empty_title'),
t('ai_providers.claude_empty_desc'),
undefined, undefined,
{ {
getRowDisabled: (item) => hasDisableAllModelsRule(item.excludedModels), getRowDisabled: (item) => hasDisableAllModelsRule(item.excludedModels),
@@ -1472,7 +1663,12 @@ export function AiProvidersPage() {
</Card> </Card>
<Card <Card
title={t('ai_providers.ampcode_title')} title={
<span className={styles.cardTitle}>
<img src={iconAmp} alt="" className={styles.cardTitleIcon} />
{t('ai_providers.ampcode_title')}
</span>
}
extra={ extra={
<Button <Button
size="sm" size="sm"
@@ -1505,16 +1701,6 @@ export function AiProvidersPage() {
: t('common.not_set')} : t('common.not_set')}
</span> </span>
</div> </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}> <div className={styles.fieldRow}>
<span className={styles.fieldLabel}> <span className={styles.fieldLabel}>
{t('ai_providers.ampcode_force_model_mappings_label')}: {t('ai_providers.ampcode_force_model_mappings_label')}:
@@ -1555,7 +1741,12 @@ export function AiProvidersPage() {
</Card> </Card>
<Card <Card
title={t('ai_providers.openai_title')} title={
<span className={styles.cardTitle}>
<img src={theme === 'dark' ? iconOpenaiDark : iconOpenaiLight} alt="" className={styles.cardTitleIcon} />
{t('ai_providers.openai_title')}
</span>
}
extra={ extra={
<Button <Button
size="sm" size="sm"
@@ -1576,6 +1767,12 @@ export function AiProvidersPage() {
return ( return (
<Fragment> <Fragment>
<div className="item-title">{item.name}</div> <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 行 */} {/* Base URL 行 */}
<div className={styles.fieldRow}> <div className={styles.fieldRow}>
<span className={styles.fieldLabel}>{t('common.base_url')}:</span> <span className={styles.fieldLabel}>{t('common.base_url')}:</span>
@@ -1663,12 +1860,16 @@ export function AiProvidersPage() {
{t('stats.failure')}: {stats.failure} {t('stats.failure')}: {stats.failure}
</span> </span>
</div> </div>
{/* 状态监测栏(汇总) */}
{renderOpenAIStatusBar(item.name)}
</Fragment> </Fragment>
); );
}, },
(index) => openOpenaiModal(index), (index) => openOpenaiModal(index),
(item) => deleteOpenai(item.name), (item) => deleteOpenai(item.name),
t('ai_providers.openai_add_button') t('ai_providers.openai_add_button'),
t('ai_providers.openai_empty_title'),
t('ai_providers.openai_empty_desc')
)} )}
</Card> </Card>
@@ -1739,18 +1940,6 @@ export function AiProvidersPage() {
</Button> </Button>
</div> </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"> <div className="form-group">
<ToggleSwitch <ToggleSwitch
label={t('ai_providers.ampcode_force_model_mappings_label')} label={t('ai_providers.ampcode_force_model_mappings_label')}
@@ -1806,6 +1995,13 @@ export function AiProvidersPage() {
value={geminiForm.apiKey} value={geminiForm.apiKey}
onChange={(e) => setGeminiForm((prev) => ({ ...prev, apiKey: e.target.value }))} 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 <Input
label={t('ai_providers.gemini_base_url_label')} label={t('ai_providers.gemini_base_url_label')}
placeholder={t('ai_providers.gemini_base_url_placeholder')} placeholder={t('ai_providers.gemini_base_url_placeholder')}
@@ -1870,6 +2066,13 @@ export function AiProvidersPage() {
value={providerForm.apiKey} value={providerForm.apiKey}
onChange={(e) => setProviderForm((prev) => ({ ...prev, apiKey: e.target.value }))} 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 <Input
label={ label={
modal?.type === 'codex' modal?.type === 'codex'
@@ -1897,6 +2100,7 @@ export function AiProvidersPage() {
keyPlaceholder={t('common.custom_headers_key_placeholder')} keyPlaceholder={t('common.custom_headers_key_placeholder')}
valuePlaceholder={t('common.custom_headers_value_placeholder')} valuePlaceholder={t('common.custom_headers_value_placeholder')}
/> />
{modal?.type === 'claude' && (
<div className="form-group"> <div className="form-group">
<label>{t('ai_providers.claude_models_label')}</label> <label>{t('ai_providers.claude_models_label')}</label>
<ModelInputList <ModelInputList
@@ -1910,6 +2114,7 @@ export function AiProvidersPage() {
disabled={saving} disabled={saving}
/> />
</div> </div>
)}
<div className="form-group"> <div className="form-group">
<label>{t('ai_providers.excluded_models_label')}</label> <label>{t('ai_providers.excluded_models_label')}</label>
<textarea <textarea
@@ -1950,6 +2155,13 @@ export function AiProvidersPage() {
value={openaiForm.name} value={openaiForm.name}
onChange={(e) => setOpenaiForm((prev) => ({ ...prev, name: e.target.value }))} 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 <Input
label={t('ai_providers.openai_add_modal_url_label')} label={t('ai_providers.openai_add_modal_url_label')}
value={openaiForm.baseUrl} value={openaiForm.baseUrl}

View File

@@ -162,6 +162,272 @@
} }
} }
.antigravityGrid {
display: grid;
gap: $spacing-md;
grid-template-columns: repeat(3, minmax(0, 1fr));
@include tablet {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
@include mobile {
grid-template-columns: 1fr;
}
}
.codexGrid {
display: grid;
gap: $spacing-md;
grid-template-columns: repeat(3, minmax(0, 1fr));
@include tablet {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
@include mobile {
grid-template-columns: 1fr;
}
}
.geminiCliGrid {
display: grid;
gap: $spacing-md;
grid-template-columns: repeat(3, minmax(0, 1fr));
@include tablet {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
@include mobile {
grid-template-columns: 1fr;
}
}
.antigravityControls {
display: flex;
gap: $spacing-md;
flex-wrap: wrap;
align-items: flex-end;
margin-bottom: $spacing-md;
}
.antigravityControl {
display: flex;
flex-direction: column;
gap: 4px;
label {
font-size: 12px;
color: var(--text-secondary);
font-weight: 500;
white-space: nowrap;
}
}
.codexControls {
display: flex;
gap: $spacing-md;
flex-wrap: wrap;
align-items: flex-end;
margin-bottom: $spacing-md;
}
.codexControl {
display: flex;
flex-direction: column;
gap: 4px;
label {
font-size: 12px;
color: var(--text-secondary);
font-weight: 500;
white-space: nowrap;
}
}
.geminiCliControls {
display: flex;
gap: $spacing-md;
flex-wrap: wrap;
align-items: flex-end;
margin-bottom: $spacing-md;
}
.geminiCliControl {
display: flex;
flex-direction: column;
gap: 4px;
label {
font-size: 12px;
color: var(--text-secondary);
font-weight: 500;
white-space: nowrap;
}
}
.antigravityCard {
background-image: linear-gradient(
180deg,
rgba(224, 247, 250, 0.12),
rgba(224, 247, 250, 0)
);
}
.codexCard {
background-image: linear-gradient(
180deg,
rgba(255, 243, 224, 0.18),
rgba(255, 243, 224, 0)
);
}
.geminiCliCard {
background-image: linear-gradient(
180deg,
rgba(231, 239, 255, 0.2),
rgba(231, 239, 255, 0)
);
}
.quotaSection {
display: flex;
flex-direction: column;
gap: $spacing-sm;
padding-top: $spacing-sm;
margin-top: $spacing-xs;
border-top: 1px dashed var(--border-color);
}
.quotaRow {
display: flex;
flex-direction: column;
gap: $spacing-xs;
}
.quotaRowHeader {
display: flex;
align-items: center;
justify-content: space-between;
gap: $spacing-sm;
min-width: 0;
@include mobile {
flex-direction: column;
align-items: flex-start;
}
}
.quotaModel {
font-size: 13px;
font-weight: 600;
color: var(--text-primary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
flex: 1;
min-width: 0;
@include mobile {
white-space: normal;
}
}
.quotaBar {
height: 8px;
background-color: var(--bg-tertiary);
border-radius: 999px;
overflow: hidden;
}
.quotaBarFill {
height: 100%;
background-color: var(--success-color, #22c55e);
transition: width 0.2s ease;
}
.quotaBarFillHigh {
background-color: var(--success-color, #22c55e);
}
.quotaBarFillMedium {
background-color: var(--warning-color, #f59e0b);
}
.quotaBarFillLow {
background-color: var(--danger-color, #ef4444);
}
.quotaMeta {
display: flex;
align-items: center;
gap: 8px;
font-size: 12px;
color: var(--text-secondary);
white-space: nowrap;
@include mobile {
justify-content: flex-start;
}
}
.quotaPercent {
font-weight: 600;
color: var(--text-primary);
}
.quotaReset {
color: var(--text-tertiary);
}
.quotaAmount {
color: var(--text-secondary);
}
.quotaMessage {
font-size: 12px;
color: var(--text-tertiary);
text-align: center;
padding: $spacing-sm 0;
}
.quotaError {
font-size: 12px;
color: var(--danger-color);
background-color: rgba(239, 68, 68, 0.08);
border: 1px solid var(--danger-color);
border-radius: $radius-sm;
padding: $spacing-xs $spacing-sm;
}
.quotaWarning {
font-size: 12px;
color: var(--warning-color, #f59e0b);
background-color: rgba(245, 158, 11, 0.12);
border: 1px solid var(--warning-color, #f59e0b);
border-radius: $radius-sm;
padding: $spacing-xs $spacing-sm;
}
.codexPlan {
display: flex;
align-items: center;
gap: 6px;
font-size: 12px;
color: var(--text-secondary);
}
.codexPlanLabel {
color: var(--text-tertiary);
}
.codexPlanValue {
font-weight: 600;
color: var(--text-primary);
text-transform: capitalize;
}
// 单个认证文件卡片 // 单个认证文件卡片
.fileCard { .fileCard {
background-color: var(--bg-primary); background-color: var(--bg-primary);
@@ -250,6 +516,78 @@
border-color: var(--failure-badge-border, #fca5a5); border-color: var(--failure-badge-border, #fca5a5);
} }
// 状态监测栏
.statusBar {
display: flex;
align-items: center;
gap: 8px;
padding: 4px 0;
max-width: 280px;
}
.statusBlocks {
display: flex;
gap: 2px;
flex: 1;
min-width: 180px;
}
.statusBlock {
flex: 1;
height: 8px;
border-radius: 2px;
min-width: 6px;
transition: transform 0.15s ease, opacity 0.15s ease;
&:hover {
transform: scaleY(1.5);
opacity: 0.85;
}
}
.statusBlockSuccess {
background-color: var(--success-color, #22c55e);
}
.statusBlockFailure {
background-color: var(--danger-color, #ef4444);
}
.statusBlockMixed {
background-color: var(--warning-color, #f59e0b);
}
.statusBlockIdle {
background-color: var(--border-secondary, #e5e7eb);
}
.statusRate {
display: flex;
align-items: center;
gap: 4px;
font-size: 12px;
font-weight: 600;
white-space: nowrap;
padding: 4px 8px;
border-radius: 6px;
background: var(--bg-tertiary);
}
.statusRateHigh {
color: var(--success-badge-text, #065f46);
background: var(--success-badge-bg, #d1fae5);
}
.statusRateMedium {
color: var(--warning-text, #92400e);
background: var(--warning-bg, #fef3c7);
}
.statusRateLow {
color: var(--failure-badge-text, #991b1b);
background: var(--failure-badge-bg, #fee2e2);
}
.cardActions { .cardActions {
display: flex; display: flex;
gap: $spacing-xs; gap: $spacing-xs;
@@ -350,6 +688,60 @@
flex-shrink: 0; flex-shrink: 0;
} }
// OAuth 排除列表表单:提供商快捷标签
.providerField {
display: flex;
flex-direction: column;
gap: $spacing-xs;
:global(.form-group) {
margin-bottom: 0;
}
}
.providerTagList {
display: flex;
flex-wrap: wrap;
gap: $spacing-xs;
}
.providerTag {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 4px 10px;
border-radius: $radius-full;
border: 1px solid var(--border-color);
background-color: var(--bg-secondary);
color: var(--text-secondary);
font-size: 12px;
cursor: pointer;
transition: all $transition-fast;
&:hover {
border-color: var(--primary-color);
color: var(--text-primary);
background-color: var(--bg-hover);
}
&:disabled {
opacity: 0.6;
cursor: not-allowed;
}
}
.providerTagActive {
background-color: var(--primary-color);
border-color: var(--primary-color);
color: #fff;
&:hover {
background-color: var(--primary-color);
border-color: var(--primary-color);
color: #fff;
}
}
// 详情弹窗 // 详情弹窗
.detailContent { .detailContent {
max-height: 400px; max-height: 400px;

View File

@@ -1,5 +1,6 @@
import { useEffect, useMemo, useRef, useState, useCallback } from 'react'; import { useEffect, useMemo, useRef, useState, useCallback } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useInterval } from '@/hooks/useInterval';
import { Card } from '@/components/ui/Card'; import { Card } from '@/components/ui/Card';
import { Button } from '@/components/ui/Button'; import { Button } from '@/components/ui/Button';
import { LoadingSpinner } from '@/components/ui/LoadingSpinner'; import { LoadingSpinner } from '@/components/ui/LoadingSpinner';
@@ -11,12 +12,14 @@ import { useAuthStore, useNotificationStore, useThemeStore } from '@/stores';
import { authFilesApi, usageApi } from '@/services/api'; import { authFilesApi, usageApi } from '@/services/api';
import { apiClient } from '@/services/api/client'; import { apiClient } from '@/services/api/client';
import type { AuthFileItem } from '@/types'; import type { AuthFileItem } from '@/types';
import type { KeyStats, KeyStatBucket } from '@/utils/usage'; import type { KeyStats, KeyStatBucket, UsageDetail } from '@/utils/usage';
import { collectUsageDetails, calculateStatusBarData } from '@/utils/usage';
import { formatFileSize } from '@/utils/format'; import { formatFileSize } from '@/utils/format';
import styles from './AuthFilesPage.module.scss'; import styles from './AuthFilesPage.module.scss';
type ThemeColors = { bg: string; text: string; border?: string }; type ThemeColors = { bg: string; text: string; border?: string };
type TypeColorSet = { light: ThemeColors; dark?: ThemeColors }; type TypeColorSet = { light: ThemeColors; dark?: ThemeColors };
type ResolvedTheme = 'light' | 'dark';
// 标签类型颜色配置(对齐重构前 styles.css 的 file-type-badge 颜色) // 标签类型颜色配置(对齐重构前 styles.css 的 file-type-badge 颜色)
const TYPE_COLORS: Record<string, TypeColorSet> = { const TYPE_COLORS: Record<string, TypeColorSet> = {
@@ -62,11 +65,24 @@ const TYPE_COLORS: Record<string, TypeColorSet> = {
} }
}; };
const OAUTH_PROVIDER_PRESETS = [
'gemini',
'gemini-cli',
'vertex',
'aistudio',
'antigravity',
'claude',
'codex',
'qwen',
'iflow'
];
const OAUTH_PROVIDER_EXCLUDES = new Set(['all', 'unknown', 'empty']);
interface ExcludedFormState { interface ExcludedFormState {
provider: string; provider: string;
modelsText: string; modelsText: string;
} }
// 标准化 auth_index 值(与 usage.ts 中的 normalizeAuthIndex 保持一致) // 标准化 auth_index 值(与 usage.ts 中的 normalizeAuthIndex 保持一致)
function normalizeAuthIndexValue(value: unknown): string | null { function normalizeAuthIndexValue(value: unknown): string | null {
if (typeof value === 'number' && Number.isFinite(value)) { if (typeof value === 'number' && Number.isFinite(value)) {
@@ -129,7 +145,7 @@ export function AuthFilesPage() {
const { t } = useTranslation(); const { t } = useTranslation();
const { showNotification } = useNotificationStore(); const { showNotification } = useNotificationStore();
const connectionStatus = useAuthStore((state) => state.connectionStatus); const connectionStatus = useAuthStore((state) => state.connectionStatus);
const theme = useThemeStore((state) => state.theme); const resolvedTheme: ResolvedTheme = useThemeStore((state) => state.resolvedTheme);
const [files, setFiles] = useState<AuthFileItem[]>([]); const [files, setFiles] = useState<AuthFileItem[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
@@ -142,6 +158,7 @@ export function AuthFilesPage() {
const [deleting, setDeleting] = useState<string | null>(null); const [deleting, setDeleting] = useState<string | null>(null);
const [deletingAll, setDeletingAll] = useState(false); const [deletingAll, setDeletingAll] = useState(false);
const [keyStats, setKeyStats] = useState<KeyStats>({ bySource: {}, byAuthIndex: {} }); const [keyStats, setKeyStats] = useState<KeyStats>({ bySource: {}, byAuthIndex: {} });
const [usageDetails, setUsageDetails] = useState<UsageDetail[]>([]);
// 详情弹窗相关 // 详情弹窗相关
const [detailModalOpen, setDetailModalOpen] = useState(false); const [detailModalOpen, setDetailModalOpen] = useState(false);
@@ -163,6 +180,7 @@ export function AuthFilesPage() {
const [savingExcluded, setSavingExcluded] = useState(false); const [savingExcluded, setSavingExcluded] = useState(false);
const fileInputRef = useRef<HTMLInputElement | null>(null); const fileInputRef = useRef<HTMLInputElement | null>(null);
const loadingKeyStatsRef = useRef(false);
const excludedUnsupportedRef = useRef(false); const excludedUnsupportedRef = useRef(false);
const disableControls = connectionStatus !== 'connected'; const disableControls = connectionStatus !== 'connected';
@@ -194,13 +212,23 @@ export function AuthFilesPage() {
} }
}, [t]); }, [t]);
// 加载 key 统计 // 加载 key 统计和 usage 明细API 层已有60秒超时
const loadKeyStats = useCallback(async () => { const loadKeyStats = useCallback(async () => {
// 防止重复请求
if (loadingKeyStatsRef.current) return;
loadingKeyStatsRef.current = true;
try { try {
const stats = await usageApi.getKeyStats(); const usageResponse = await usageApi.getUsage();
const usageData = usageResponse?.usage ?? usageResponse;
const stats = await usageApi.getKeyStats(usageData);
setKeyStats(stats); setKeyStats(stats);
// 收集 usage 明细用于状态栏
const details = collectUsageDetails(usageData);
setUsageDetails(details);
} catch { } catch {
// 静默失败 // 静默失败
} finally {
loadingKeyStatsRef.current = false;
} }
}, []); }, []);
@@ -236,6 +264,9 @@ export function AuthFilesPage() {
loadExcluded(); loadExcluded();
}, [loadFiles, loadKeyStats, loadExcluded]); }, [loadFiles, loadKeyStats, loadExcluded]);
// 定时刷新状态数据每240秒
useInterval(loadKeyStats, 240_000);
// 提取所有存在的类型 // 提取所有存在的类型
const existingTypes = useMemo(() => { const existingTypes = useMemo(() => {
const types = new Set<string>(['all']); const types = new Set<string>(['all']);
@@ -247,6 +278,45 @@ export function AuthFilesPage() {
return Array.from(types); return Array.from(types);
}, [files]); }, [files]);
const excludedProviderLookup = useMemo(() => {
const lookup = new Map<string, string>();
Object.keys(excluded).forEach((provider) => {
const key = provider.trim().toLowerCase();
if (key && !lookup.has(key)) {
lookup.set(key, provider);
}
});
return lookup;
}, [excluded]);
const providerOptions = useMemo(() => {
const extraProviders = new Set<string>();
Object.keys(excluded).forEach((provider) => {
extraProviders.add(provider);
});
files.forEach((file) => {
if (typeof file.type === 'string') {
extraProviders.add(file.type);
}
if (typeof file.provider === 'string') {
extraProviders.add(file.provider);
}
});
const normalizedExtras = Array.from(extraProviders)
.map((value) => value.trim())
.filter((value) => value && !OAUTH_PROVIDER_EXCLUDES.has(value.toLowerCase()));
const baseSet = new Set(OAUTH_PROVIDER_PRESETS.map((value) => value.toLowerCase()));
const extraList = normalizedExtras
.filter((value) => !baseSet.has(value.toLowerCase()))
.sort((a, b) => a.localeCompare(b));
return [...OAUTH_PROVIDER_PRESETS, ...extraList];
}, [excluded, files]);
// 过滤和搜索 // 过滤和搜索
const filtered = useMemo(() => { const filtered = useMemo(() => {
return files.filter((item) => { return files.filter((item) => {
@@ -488,14 +558,19 @@ export function AuthFilesPage() {
// 获取类型颜色 // 获取类型颜色
const getTypeColor = (type: string): ThemeColors => { const getTypeColor = (type: string): ThemeColors => {
const set = TYPE_COLORS[type] || TYPE_COLORS.unknown; const set = TYPE_COLORS[type] || TYPE_COLORS.unknown;
return theme === 'dark' && set.dark ? set.dark : set.light; return resolvedTheme === 'dark' && set.dark ? set.dark : set.light;
}; };
// OAuth 排除相关方法 // OAuth 排除相关方法
const openExcludedModal = (provider?: string) => { const openExcludedModal = (provider?: string) => {
const models = provider ? excluded[provider] : []; const normalizedProvider = (provider || '').trim();
const fallbackProvider = normalizedProvider || (filter !== 'all' ? String(filter) : '');
const lookupKey = fallbackProvider
? excludedProviderLookup.get(fallbackProvider.toLowerCase())
: undefined;
const models = lookupKey ? excluded[lookupKey] : [];
setExcludedForm({ setExcludedForm({
provider: provider || '', provider: lookupKey || fallbackProvider,
modelsText: Array.isArray(models) ? models.join('\n') : '' modelsText: Array.isArray(models) ? models.join('\n') : ''
}); });
setExcludedModalOpen(true); setExcludedModalOpen(true);
@@ -547,7 +622,7 @@ export function AuthFilesPage() {
{existingTypes.map((type) => { {existingTypes.map((type) => {
const isActive = filter === type; const isActive = filter === type;
const color = type === 'all' ? { bg: 'var(--bg-tertiary)', text: 'var(--text-primary)' } : getTypeColor(type); const color = type === 'all' ? { bg: 'var(--bg-tertiary)', text: 'var(--text-primary)' } : getTypeColor(type);
const activeTextColor = theme === 'dark' ? '#111827' : '#fff'; const activeTextColor = resolvedTheme === 'dark' ? '#111827' : '#fff';
return ( return (
<button <button
key={type} key={type}
@@ -569,6 +644,65 @@ export function AuthFilesPage() {
</div> </div>
); );
// 预计算所有认证文件的状态栏数据(避免每次渲染重复计算)
const statusBarCache = useMemo(() => {
const cache = new Map<string, ReturnType<typeof calculateStatusBarData>>();
files.forEach((file) => {
const rawAuthIndex = file['auth_index'] ?? file.authIndex;
const authIndexKey = normalizeAuthIndexValue(rawAuthIndex);
if (authIndexKey) {
// 过滤出属于该认证文件的 usage 明细
const filteredDetails = usageDetails.filter((detail) => {
const detailAuthIndex = normalizeAuthIndexValue(detail.auth_index);
return detailAuthIndex !== null && detailAuthIndex === authIndexKey;
});
cache.set(authIndexKey, calculateStatusBarData(filteredDetails));
}
});
return cache;
}, [usageDetails, files]);
// 渲染状态监测栏
const renderStatusBar = (item: AuthFileItem) => {
// 认证文件使用 authIndex 来匹配 usage 数据
const rawAuthIndex = item['auth_index'] ?? item.authIndex;
const authIndexKey = normalizeAuthIndexValue(rawAuthIndex);
const statusData = (authIndexKey && statusBarCache.get(authIndexKey)) || calculateStatusBarData([]);
const hasData = statusData.totalSuccess + statusData.totalFailure > 0;
const rateClass = !hasData
? ''
: statusData.successRate >= 90
? styles.statusRateHigh
: statusData.successRate >= 50
? styles.statusRateMedium
: styles.statusRateLow;
return (
<div className={styles.statusBar}>
<div className={styles.statusBlocks}>
{statusData.blocks.map((state, idx) => {
const blockClass =
state === 'success'
? styles.statusBlockSuccess
: state === 'failure'
? styles.statusBlockFailure
: state === 'mixed'
? styles.statusBlockMixed
: styles.statusBlockIdle;
return <div key={idx} className={`${styles.statusBlock} ${blockClass}`} />;
})}
</div>
<span className={`${styles.statusRate} ${rateClass}`}>
{hasData ? `${statusData.successRate.toFixed(1)}%` : '--'}
</span>
</div>
);
};
// 渲染单个认证文件卡片 // 渲染单个认证文件卡片
const renderFileCard = (item: AuthFileItem) => { const renderFileCard = (item: AuthFileItem) => {
const fileStats = resolveAuthFileStats(item, keyStats); const fileStats = resolveAuthFileStats(item, keyStats);
@@ -605,6 +739,9 @@ export function AuthFilesPage() {
</span> </span>
</div> </div>
{/* 状态监测栏 */}
{renderStatusBar(item)}
<div className={styles.cardActions}> <div className={styles.cardActions}>
{isRuntimeOnly ? ( {isRuntimeOnly ? (
<div className={styles.virtualBadge}>{t('auth_files.type_virtual') || '虚拟认证文件'}</div> <div className={styles.virtualBadge}>{t('auth_files.type_virtual') || '虚拟认证文件'}</div>
@@ -931,12 +1068,41 @@ export function AuthFilesPage() {
</> </>
} }
> >
<div className={styles.providerField}>
<Input <Input
id="oauth-excluded-provider"
list="oauth-excluded-provider-options"
label={t('oauth_excluded.provider_label')} label={t('oauth_excluded.provider_label')}
hint={t('oauth_excluded.provider_hint')}
placeholder={t('oauth_excluded.provider_placeholder')} placeholder={t('oauth_excluded.provider_placeholder')}
value={excludedForm.provider} value={excludedForm.provider}
onChange={(e) => setExcludedForm((prev) => ({ ...prev, provider: e.target.value }))} onChange={(e) => setExcludedForm((prev) => ({ ...prev, provider: e.target.value }))}
/> />
<datalist id="oauth-excluded-provider-options">
{providerOptions.map((provider) => (
<option key={provider} value={provider} />
))}
</datalist>
{providerOptions.length > 0 && (
<div className={styles.providerTagList}>
{providerOptions.map((provider) => {
const isActive =
excludedForm.provider.trim().toLowerCase() === provider.toLowerCase();
return (
<button
key={provider}
type="button"
className={`${styles.providerTag} ${isActive ? styles.providerTagActive : ''}`}
onClick={() => setExcludedForm((prev) => ({ ...prev, provider }))}
disabled={savingExcluded}
>
{getTypeLabel(provider)}
</button>
);
})}
</div>
)}
</div>
<div className={styles.formGroup}> <div className={styles.formGroup}>
<label>{t('oauth_excluded.models_label')}</label> <label>{t('oauth_excluded.models_label')}</label>
<textarea <textarea

View File

@@ -2,6 +2,10 @@
.container { .container {
width: 100%; width: 100%;
min-height: 100%;
display: flex;
flex-direction: column;
overflow-y: auto;
} }
.pageTitle { .pageTitle {
@@ -21,6 +25,8 @@
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: $spacing-lg; gap: $spacing-lg;
flex: 1;
min-height: 0;
} }
.searchInputWrapper { .searchInputWrapper {
@@ -127,7 +133,8 @@
.editorWrapper { .editorWrapper {
width: 100%; width: 100%;
height: 500px; flex: 1;
min-height: 800px;
border: 1px solid var(--border-color); border: 1px solid var(--border-color);
border-radius: $radius-lg; border-radius: $radius-lg;
overflow: hidden; overflow: hidden;
@@ -159,6 +166,9 @@
.cm-scroller { .cm-scroller {
overflow: auto; overflow: auto;
padding-top: calc(var(--floating-controls-height, 0px) + #{$spacing-md}); padding-top: calc(var(--floating-controls-height, 0px) + #{$spacing-md});
-webkit-overflow-scrolling: touch;
touch-action: pan-x pan-y;
overscroll-behavior: contain;
} }
.cm-gutters { .cm-gutters {
@@ -206,6 +216,14 @@
} }
} }
.configCard {
display: flex;
flex-direction: column;
height: 1120px;
flex-shrink: 0;
overflow: visible;
}
.actions { .actions {
display: flex; display: flex;
gap: $spacing-sm; gap: $spacing-sm;
@@ -219,3 +237,27 @@
} }
} }
} }
@media (max-height: 820px) {
.pageTitle {
font-size: 24px;
margin-bottom: $spacing-sm;
}
.description {
margin-bottom: $spacing-lg;
}
.content {
gap: $spacing-md;
}
.configCard {
height: 880px;
padding: $spacing-md;
}
.editorWrapper {
min-height: 600px;
}
}

View File

@@ -16,7 +16,7 @@ export function ConfigPage() {
const { t } = useTranslation(); const { t } = useTranslation();
const { showNotification } = useNotificationStore(); const { showNotification } = useNotificationStore();
const connectionStatus = useAuthStore((state) => state.connectionStatus); const connectionStatus = useAuthStore((state) => state.connectionStatus);
const theme = useThemeStore((state) => state.theme); const resolvedTheme = useThemeStore((state) => state.resolvedTheme);
const [content, setContent] = useState(''); const [content, setContent] = useState('');
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
@@ -224,7 +224,7 @@ export function ConfigPage() {
<h1 className={styles.pageTitle}>{t('config_management.title')}</h1> <h1 className={styles.pageTitle}>{t('config_management.title')}</h1>
<p className={styles.description}>{t('config_management.description')}</p> <p className={styles.description}>{t('config_management.description')}</p>
<Card> <Card className={styles.configCard}>
<div className={styles.content}> <div className={styles.content}>
{/* Editor */} {/* Editor */}
{error && <div className="error-box">{error}</div>} {error && <div className="error-box">{error}</div>}
@@ -289,7 +289,7 @@ export function ConfigPage() {
value={content} value={content}
onChange={handleChange} onChange={handleChange}
extensions={extensions} extensions={extensions}
theme={theme === 'dark' ? 'dark' : 'light'} theme={resolvedTheme}
editable={!disableControls && !loading} editable={!disableControls && !loading}
placeholder={t('config_management.editor_placeholder')} placeholder={t('config_management.editor_placeholder')}
height="100%" height="100%"

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;
}
}

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

@@ -0,0 +1,319 @@
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.trim().replace(/^[vV]+/, '')}
</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,10 +1,10 @@
import { useEffect, useMemo, useState } from 'react'; 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 { useTranslation } from 'react-i18next';
import { Button } from '@/components/ui/Button'; import { Button } from '@/components/ui/Button';
import { Input } from '@/components/ui/Input'; import { Input } from '@/components/ui/Input';
import { IconEye, IconEyeOff } from '@/components/ui/icons'; import { IconEye, IconEyeOff } from '@/components/ui/icons';
import { useAuthStore, useNotificationStore } from '@/stores'; import { useAuthStore, useLanguageStore, useNotificationStore } from '@/stores';
import { detectApiBaseFromLocation, normalizeApiBase } from '@/utils/connection'; import { detectApiBaseFromLocation, normalizeApiBase } from '@/utils/connection';
export function LoginPage() { export function LoginPage() {
@@ -12,21 +12,26 @@ export function LoginPage() {
const navigate = useNavigate(); const navigate = useNavigate();
const location = useLocation(); const location = useLocation();
const { showNotification } = useNotificationStore(); const { showNotification } = useNotificationStore();
const language = useLanguageStore((state) => state.language);
const toggleLanguage = useLanguageStore((state) => state.toggleLanguage);
const isAuthenticated = useAuthStore((state) => state.isAuthenticated); const isAuthenticated = useAuthStore((state) => state.isAuthenticated);
const login = useAuthStore((state) => state.login); const login = useAuthStore((state) => state.login);
const restoreSession = useAuthStore((state) => state.restoreSession); const restoreSession = useAuthStore((state) => state.restoreSession);
const storedBase = useAuthStore((state) => state.apiBase); const storedBase = useAuthStore((state) => state.apiBase);
const storedKey = useAuthStore((state) => state.managementKey); const storedKey = useAuthStore((state) => state.managementKey);
const storedRememberPassword = useAuthStore((state) => state.rememberPassword);
const [apiBase, setApiBase] = useState(''); const [apiBase, setApiBase] = useState('');
const [managementKey, setManagementKey] = useState(''); const [managementKey, setManagementKey] = useState('');
const [showCustomBase, setShowCustomBase] = useState(false); const [showCustomBase, setShowCustomBase] = useState(false);
const [showKey, setShowKey] = useState(false); const [showKey, setShowKey] = useState(false);
const [rememberPassword, setRememberPassword] = useState(false);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [autoLoading, setAutoLoading] = useState(true); const [autoLoading, setAutoLoading] = useState(true);
const [error, setError] = useState(''); const [error, setError] = useState('');
const detectedBase = useMemo(() => detectApiBaseFromLocation(), []); const detectedBase = useMemo(() => detectApiBaseFromLocation(), []);
const nextLanguageLabel = language === 'zh-CN' ? t('language.english') : t('language.chinese');
useEffect(() => { useEffect(() => {
const init = async () => { const init = async () => {
@@ -35,6 +40,7 @@ export function LoginPage() {
if (!autoLoggedIn) { if (!autoLoggedIn) {
setApiBase(storedBase || detectedBase); setApiBase(storedBase || detectedBase);
setManagementKey(storedKey || ''); setManagementKey(storedKey || '');
setRememberPassword(storedRememberPassword || Boolean(storedKey));
} }
} finally { } finally {
setAutoLoading(false); setAutoLoading(false);
@@ -42,18 +48,12 @@ export function LoginPage() {
}; };
init(); init();
}, [detectedBase, restoreSession, storedBase, storedKey]); }, [detectedBase, restoreSession, storedBase, storedKey, storedRememberPassword]);
useEffect(() => {
if (isAuthenticated) { if (isAuthenticated) {
const redirect = (location.state as any)?.from?.pathname || '/'; const redirect = (location.state as any)?.from?.pathname || '/';
navigate(redirect, { replace: true }); return <Navigate to={redirect} replace />;
} }
}, [isAuthenticated, navigate, location.state]);
const handleUseCurrent = () => {
setApiBase(detectedBase);
};
const handleSubmit = async () => { const handleSubmit = async () => {
if (!managementKey.trim()) { if (!managementKey.trim()) {
@@ -65,7 +65,11 @@ export function LoginPage() {
setLoading(true); setLoading(true);
setError(''); setError('');
try { try {
await login({ apiBase: baseToUse, managementKey: managementKey.trim() }); await login({
apiBase: baseToUse,
managementKey: managementKey.trim(),
rememberPassword
});
showNotification(t('common.connected_status'), 'success'); showNotification(t('common.connected_status'), 'success');
navigate('/', { replace: true }); navigate('/', { replace: true });
} catch (err: any) { } catch (err: any) {
@@ -81,7 +85,20 @@ export function LoginPage() {
<div className="login-page"> <div className="login-page">
<div className="login-card"> <div className="login-card">
<div className="login-header"> <div className="login-header">
<div className="login-title-row">
<div className="title">{t('title.login')}</div> <div className="title">{t('title.login')}</div>
<Button
type="button"
variant="ghost"
size="sm"
className="login-language-btn"
onClick={toggleLanguage}
title={t('language.switch')}
aria-label={t('language.switch')}
>
{nextLanguageLabel}
</Button>
</div>
<div className="subtitle">{t('login.subtitle')}</div> <div className="subtitle">{t('login.subtitle')}</div>
</div> </div>
@@ -138,14 +155,19 @@ export function LoginPage() {
} }
/> />
<div style={{ display: 'flex', gap: 12, alignItems: 'center' }}> <div className="toggle-advanced">
<Button variant="secondary" onClick={handleUseCurrent}> <input
{t('login.use_current_address')} id="remember-password-toggle"
</Button> type="checkbox"
checked={rememberPassword}
onChange={(e) => setRememberPassword(e.target.checked)}
/>
<label htmlFor="remember-password-toggle">{t('login.remember_password_label')}</label>
</div>
<Button fullWidth onClick={handleSubmit} loading={loading}> <Button fullWidth onClick={handleSubmit} loading={loading}>
{loading ? t('login.submitting') : t('login.submit_button')} {loading ? t('login.submitting') : t('login.submit_button')}
</Button> </Button>
</div>
{error && <div className="error-box">{error}</div>} {error && <div className="error-box">{error}</div>}

View File

@@ -2,19 +2,80 @@
.container { .container {
width: 100%; width: 100%;
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
@include mobile {
min-height: auto;
overflow: visible;
}
} }
.pageTitle { .pageTitle {
font-size: 28px; font-size: 28px;
font-weight: 700; font-weight: 700;
color: var(--text-primary); 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 { .content {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: $spacing-lg; gap: $spacing-lg;
flex: 1;
min-height: 0;
@include mobile {
gap: $spacing-md;
min-height: auto;
}
}
.logCard {
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
overflow: hidden;
@include mobile {
flex: 0 0 auto;
min-height: auto;
overflow: visible;
}
} }
.toolbar { .toolbar {
@@ -22,9 +83,12 @@
align-items: center; align-items: center;
gap: $spacing-sm; gap: $spacing-sm;
flex-wrap: wrap; flex-wrap: wrap;
margin-left: auto;
@include mobile { @include mobile {
align-items: flex-start; align-items: flex-start;
margin-left: 0;
width: 100%;
} }
} }
@@ -38,6 +102,11 @@
:global(.form-group) { :global(.form-group) {
margin: 0; margin: 0;
} }
@include mobile {
gap: $spacing-sm;
margin-bottom: $spacing-sm;
}
} }
.searchWrapper { .searchWrapper {
@@ -112,9 +181,33 @@
background: var(--bg-secondary); background: var(--bg-secondary);
border: 1px solid var(--border-color); border: 1px solid var(--border-color);
border-radius: $radius-md; border-radius: $radius-md;
max-height: 620px; flex: 1 1 auto;
min-height: 280px;
max-height: calc(100vh - 320px);
overflow: auto; overflow: auto;
position: relative; position: relative;
-webkit-overflow-scrolling: touch;
touch-action: pan-y;
overscroll-behavior: contain;
@include tablet {
min-height: 240px;
max-height: calc(100vh - 300px);
}
@include mobile {
min-height: 360px;
max-height: 480px;
flex: 0 0 auto;
overflow: auto;
}
}
.errorPanel {
height: 480px;
overflow: auto;
-webkit-overflow-scrolling: touch;
overscroll-behavior: contain;
} }
.loadMoreBanner { .loadMoreBanner {
@@ -130,6 +223,17 @@
background: var(--bg-primary); background: var(--bg-primary);
color: var(--text-secondary); color: var(--text-secondary);
font-size: 12px; font-size: 12px;
@include mobile {
flex-direction: column;
align-items: flex-start;
justify-content: flex-start;
gap: $spacing-xs;
> span {
width: 100%;
}
}
} }
.loadMoreCount { .loadMoreCount {
@@ -137,6 +241,22 @@
white-space: nowrap; white-space: nowrap;
} }
.loadMoreStats {
display: flex;
align-items: center;
gap: $spacing-md;
@include mobile {
width: 100%;
flex-wrap: wrap;
gap: $spacing-sm;
> span {
white-space: nowrap;
}
}
}
.logList { .logList {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -161,9 +281,18 @@
background: rgba(59, 130, 246, 0.06); background: rgba(59, 130, 246, 0.06);
} }
@include tablet {
grid-template-columns: 140px 1fr;
gap: $spacing-sm;
padding: 8px 10px;
font-size: 12px;
}
@include mobile { @include mobile {
grid-template-columns: 1fr; grid-template-columns: 1fr;
gap: $spacing-xs; gap: $spacing-xs;
padding: 8px 10px;
font-size: 11.5px;
} }
} }
@@ -187,15 +316,8 @@
.rowMain { .rowMain {
display: flex; display: flex;
flex-direction: column;
gap: 4px;
min-width: 0;
}
.rowMeta {
display: flex;
align-items: center;
flex-wrap: wrap; flex-wrap: wrap;
align-items: baseline;
gap: 6px; gap: 6px;
min-width: 0; min-width: 0;
} }
@@ -236,6 +358,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 { .statusBadge {
font-variant-numeric: tabular-nums; font-variant-numeric: tabular-nums;
} }
@@ -303,11 +434,101 @@
@include mobile { @include mobile {
max-width: 100%; max-width: 100%;
flex-basis: 100%;
} }
} }
.message { .message {
color: var(--text-secondary); color: var(--text-secondary);
white-space: pre-wrap;
word-break: break-word; word-break: break-word;
@include mobile {
flex-basis: 100%;
}
}
@media (max-height: 820px) {
.pageTitle {
font-size: 24px;
margin-bottom: $spacing-md;
}
.tabBar {
margin-bottom: $spacing-md;
}
.tabItem {
padding: 10px 16px;
}
.content {
gap: $spacing-md;
}
.logCard {
padding: $spacing-md;
}
.logPanel {
min-height: 200px;
max-height: calc(100vh - 280px);
}
.logRow {
padding: 8px 10px;
font-size: 12px;
}
.errorPanel {
height: 360px;
}
}
@media (max-height: 600px) {
.pageTitle {
font-size: 20px;
margin-bottom: $spacing-sm;
}
.tabBar {
margin-bottom: $spacing-sm;
}
.tabItem {
padding: 8px 12px;
font-size: 13px;
}
.content {
gap: $spacing-sm;
}
.filters {
margin-bottom: $spacing-sm;
gap: $spacing-sm;
}
.logCard {
padding: $spacing-sm;
}
.logPanel {
min-height: 160px;
max-height: calc(100vh - 220px);
}
.logRow {
padding: 6px 8px;
font-size: 11px;
grid-template-columns: 130px 1fr;
gap: $spacing-xs;
}
.loadMoreBanner {
padding: 6px 10px;
}
.errorPanel {
height: 280px;
}
} }

View File

@@ -1,9 +1,11 @@
import { useDeferredValue, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; import { useDeferredValue, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import type { PointerEvent as ReactPointerEvent } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Card } from '@/components/ui/Card'; import { Card } from '@/components/ui/Card';
import { Button } from '@/components/ui/Button'; import { Button } from '@/components/ui/Button';
import { EmptyState } from '@/components/ui/EmptyState'; import { EmptyState } from '@/components/ui/EmptyState';
import { Input } from '@/components/ui/Input'; import { Input } from '@/components/ui/Input';
import { Modal } from '@/components/ui/Modal';
import { ToggleSwitch } from '@/components/ui/ToggleSwitch'; import { ToggleSwitch } from '@/components/ui/ToggleSwitch';
import { import {
IconDownload, IconDownload,
@@ -14,7 +16,7 @@ import {
IconTrash2, IconTrash2,
IconX, IconX,
} from '@/components/ui/icons'; } from '@/components/ui/icons';
import { useNotificationStore, useAuthStore } from '@/stores'; import { useAuthStore, useConfigStore, useNotificationStore } from '@/stores';
import { logsApi } from '@/services/api/logs'; import { logsApi } from '@/services/api/logs';
import { MANAGEMENT_API_PREFIX } from '@/utils/constants'; import { MANAGEMENT_API_PREFIX } from '@/utils/constants';
import { formatUnixTimestamp } from '@/utils/format'; import { formatUnixTimestamp } from '@/utils/format';
@@ -38,18 +40,23 @@ const INITIAL_DISPLAY_LINES = 100;
const LOAD_MORE_LINES = 200; const LOAD_MORE_LINES = 200;
const MAX_BUFFER_LINES = 10000; const MAX_BUFFER_LINES = 10000;
const LOAD_MORE_THRESHOLD_PX = 72; const LOAD_MORE_THRESHOLD_PX = 72;
const LONG_PRESS_MS = 650;
const LONG_PRESS_MOVE_THRESHOLD = 10;
const HTTP_METHODS = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS', 'HEAD'] as const; const HTTP_METHODS = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS', 'HEAD'] as const;
type HttpMethod = (typeof HTTP_METHODS)[number]; type HttpMethod = (typeof HTTP_METHODS)[number];
const HTTP_METHOD_REGEX = new RegExp(`\\b(${HTTP_METHODS.join('|')})\\b`); 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_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_SOURCE_REGEX = /^\[([^\]]+)\]/;
const LOG_LATENCY_REGEX = /\b(\d+(?:\.\d+)?)(?:\s*)(µs|us|ms|s)\b/i; 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_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_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 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[] = [ const HTTP_STATUS_PATTERNS: RegExp[] = [
/\|\s*([1-5]\d{2})\s*\|/, /\|\s*([1-5]\d{2})\s*\|/,
@@ -88,11 +95,19 @@ const extractIp = (text: string): string | undefined => {
return candidate; 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 = { type ParsedLogLine = {
raw: string; raw: string;
timestamp?: string; timestamp?: string;
level?: LogLevel; level?: LogLevel;
source?: string; source?: string;
requestId?: string;
statusCode?: number; statusCode?: number;
latency?: string; latency?: string;
ip?: string; ip?: string;
@@ -145,6 +160,16 @@ const parseLogLine = (raw: string): ParsedLogLine => {
remaining = remaining.slice(tsMatch[0].length).trim(); 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; let level: LogLevel | undefined;
const lvlMatch = remaining.match(LOG_LEVEL_REGEX); const lvlMatch = remaining.match(LOG_LEVEL_REGEX);
if (lvlMatch) { if (lvlMatch) {
@@ -173,10 +198,40 @@ const parseLogLine = (raw: string): ParsedLogLine => {
.filter(Boolean); .filter(Boolean);
const consumed = new Set<number>(); 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 // status code
const statusIndex = segments.findIndex((segment) => /^\d{3}\b/.test(segment)); const statusIndex = segments.findIndex((segment) => /^\d{3}$/.test(segment));
if (statusIndex >= 0) { if (statusIndex >= 0) {
const match = segments[statusIndex].match(/^(\d{3})\b/); const match = segments[statusIndex].match(/^(\d{3})$/);
if (match) { if (match) {
const code = Number.parseInt(match[1], 10); const code = Number.parseInt(match[1], 10);
if (code >= 100 && code <= 599) { if (code >= 100 && code <= 599) {
@@ -218,6 +273,16 @@ const parseLogLine = (raw: string): ParsedLogLine => {
consumed.add(methodIndex); 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(' | '); message = segments.filter((_, index) => !consumed.has(index)).join(' | ');
} else { } else {
statusCode = detectHttpStatusCode(remaining); statusCode = detectHttpStatusCode(remaining);
@@ -234,11 +299,23 @@ const parseLogLine = (raw: string): ParsedLogLine => {
if (!level) level = inferLogLevel(raw); 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 { return {
raw, raw,
timestamp, timestamp,
level, level,
source, source,
requestId,
statusCode, statusCode,
latency, latency,
ip, ip,
@@ -282,24 +359,37 @@ const copyToClipboard = async (text: string) => {
} }
}; };
type TabType = 'logs' | 'errors';
export function LogsPage() { export function LogsPage() {
const { t } = useTranslation(); const { t } = useTranslation();
const { showNotification } = useNotificationStore(); const { showNotification } = useNotificationStore();
const connectionStatus = useAuthStore((state) => state.connectionStatus); const connectionStatus = useAuthStore((state) => state.connectionStatus);
const requestLogEnabled = useConfigStore((state) => state.config?.requestLog ?? false);
const [activeTab, setActiveTab] = useState<TabType>('logs');
const [logState, setLogState] = useState<LogState>({ buffer: [], visibleFrom: 0 }); const [logState, setLogState] = useState<LogState>({ buffer: [], visibleFrom: 0 });
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState(''); const [error, setError] = useState('');
const [autoRefresh, setAutoRefresh] = useState(false); const [autoRefresh, setAutoRefresh] = useState(false);
const [searchQuery, setSearchQuery] = useState(''); const [searchQuery, setSearchQuery] = useState('');
const deferredSearchQuery = useDeferredValue(searchQuery); const deferredSearchQuery = useDeferredValue(searchQuery);
const [hideManagementLogs, setHideManagementLogs] = useState(false); const [hideManagementLogs, setHideManagementLogs] = useState(true);
const [errorLogs, setErrorLogs] = useState<ErrorLogItem[]>([]); const [errorLogs, setErrorLogs] = useState<ErrorLogItem[]>([]);
const [loadingErrors, setLoadingErrors] = useState(false); const [loadingErrors, setLoadingErrors] = useState(false);
const [errorLogsError, setErrorLogsError] = useState('');
const [requestLogId, setRequestLogId] = useState<string | null>(null);
const [requestLogDownloading, setRequestLogDownloading] = useState(false);
const logViewerRef = useRef<HTMLDivElement | null>(null); const logViewerRef = useRef<HTMLDivElement | null>(null);
const pendingScrollToBottomRef = useRef(false); const pendingScrollToBottomRef = useRef(false);
const pendingPrependScrollRef = useRef<{ scrollHeight: number; scrollTop: number } | null>(null); const pendingPrependScrollRef = useRef<{ scrollHeight: number; scrollTop: number } | null>(null);
const longPressRef = useRef<{
timer: number | null;
startX: number;
startY: number;
fired: boolean;
} | null>(null);
// 保存最新时间戳用于增量获取 // 保存最新时间戳用于增量获取
const latestTimestampRef = useRef<number>(0); const latestTimestampRef = useRef<number>(0);
@@ -412,14 +502,18 @@ export function LogsPage() {
} }
setLoadingErrors(true); setLoadingErrors(true);
setErrorLogsError('');
try { try {
const res = await logsApi.fetchErrorLogs(); const res = await logsApi.fetchErrorLogs();
// API 返回 { files: [...] } // API 返回 { files: [...] }
setErrorLogs(Array.isArray(res.files) ? res.files : []); setErrorLogs(Array.isArray(res.files) ? res.files : []);
} catch (err: unknown) { } catch (err: unknown) {
console.error('Failed to load error logs:', err); console.error('Failed to load error logs:', err);
// 静默失败,不影响主日志显示
setErrorLogs([]); setErrorLogs([]);
const message = getErrorMessage(err);
setErrorLogsError(
message ? `${t('logs.error_logs_load_error')}: ${message}` : t('logs.error_logs_load_error')
);
} finally { } finally {
setLoadingErrors(false); setLoadingErrors(false);
} }
@@ -449,11 +543,17 @@ export function LogsPage() {
if (connectionStatus === 'connected') { if (connectionStatus === 'connected') {
latestTimestampRef.current = 0; latestTimestampRef.current = 0;
loadLogs(false); loadLogs(false);
loadErrorLogs();
} }
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [connectionStatus]); }, [connectionStatus]);
useEffect(() => {
if (activeTab !== 'errors') return;
if (connectionStatus !== 'connected') return;
void loadErrorLogs();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [activeTab, connectionStatus, requestLogEnabled]);
useEffect(() => { useEffect(() => {
if (!autoRefresh || connectionStatus !== 'connected') { if (!autoRefresh || connectionStatus !== 'connected') {
return; return;
@@ -559,13 +659,147 @@ export function LogsPage() {
} }
}; };
const clearLongPressTimer = () => {
if (longPressRef.current?.timer) {
window.clearTimeout(longPressRef.current.timer);
longPressRef.current.timer = null;
}
};
const startLongPress = (event: ReactPointerEvent<HTMLDivElement>, id?: string) => {
if (!requestLogEnabled) return;
if (!id) return;
if (requestLogId) return;
clearLongPressTimer();
longPressRef.current = {
timer: window.setTimeout(() => {
setRequestLogId(id);
if (longPressRef.current) {
longPressRef.current.fired = true;
longPressRef.current.timer = null;
}
}, LONG_PRESS_MS),
startX: event.clientX,
startY: event.clientY,
fired: false,
};
};
const cancelLongPress = () => {
clearLongPressTimer();
longPressRef.current = null;
};
const handleLongPressMove = (event: ReactPointerEvent<HTMLDivElement>) => {
const current = longPressRef.current;
if (!current || current.timer === null || current.fired) return;
const deltaX = Math.abs(event.clientX - current.startX);
const deltaY = Math.abs(event.clientY - current.startY);
if (deltaX > LONG_PRESS_MOVE_THRESHOLD || deltaY > LONG_PRESS_MOVE_THRESHOLD) {
cancelLongPress();
}
};
const closeRequestLogModal = () => {
if (requestLogDownloading) return;
setRequestLogId(null);
};
const downloadRequestLog = async (id: string) => {
setRequestLogDownloading(true);
try {
const response = await logsApi.downloadRequestLogById(id);
const blob = new Blob([response.data], { type: 'text/plain' });
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `request-${id}.log`;
a.click();
window.URL.revokeObjectURL(url);
showNotification(t('logs.request_log_download_success'), 'success');
setRequestLogId(null);
} catch (err: unknown) {
const message = getErrorMessage(err);
showNotification(
`${t('notification.download_failed')}${message ? `: ${message}` : ''}`,
'error'
);
} finally {
setRequestLogDownloading(false);
}
};
useEffect(() => {
return () => {
if (longPressRef.current?.timer) {
window.clearTimeout(longPressRef.current.timer);
longPressRef.current.timer = null;
}
};
}, []);
return ( return (
<div className={styles.container}> <div className={styles.container}>
<h1 className={styles.pageTitle}>{t('logs.title')}</h1> <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}> <div className={styles.content}>
<Card {activeTab === 'logs' && (
title={t('logs.log_content')} <Card className={styles.logCard}>
extra={ {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>
<ToggleSwitch
checked={hideManagementLogs}
onChange={setHideManagementLogs}
label={
<span className={styles.switchLabel}>
<IconEyeOff size={16} />
{t('logs.hide_management_logs', { prefix: MANAGEMENT_API_PREFIX })}
</span>
}
/>
<div className={styles.toolbar}> <div className={styles.toolbar}>
<Button <Button
variant="secondary" variant="secondary"
@@ -615,56 +849,6 @@ export function LogsPage() {
</span> </span>
</Button> </Button>
</div> </div>
}
>
{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>
<ToggleSwitch
checked={hideManagementLogs}
onChange={setHideManagementLogs}
label={
<span className={styles.switchLabel}>
<IconEyeOff size={16} />
{t('logs.hide_management_logs', { prefix: MANAGEMENT_API_PREFIX })}
</span>
}
/>
<div className={styles.filterStats}>
<span>
{parsedVisibleLines.length} {t('logs.lines')}
</span>
{removedCount > 0 && (
<span className={styles.removedCount}>
{t('logs.removed')} {removedCount}
</span>
)}
</div>
</div> </div>
{loading ? ( {loading ? (
@@ -674,10 +858,20 @@ export function LogsPage() {
{canLoadMore && ( {canLoadMore && (
<div className={styles.loadMoreBanner}> <div className={styles.loadMoreBanner}>
<span>{t('logs.load_more_hint')}</span> <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}> <span className={styles.loadMoreCount}>
{t('logs.hidden_lines', { count: logState.visibleFrom })} {t('logs.hidden_lines', { count: logState.visibleFrom })}
</span> </span>
</div> </div>
</div>
)} )}
<div className={styles.logList}> <div className={styles.logList}>
{parsedVisibleLines.map((line, index) => { {parsedVisibleLines.map((line, index) => {
@@ -692,13 +886,17 @@ export function LogsPage() {
onDoubleClick={() => { onDoubleClick={() => {
void copyLogLine(line.raw); void copyLogLine(line.raw);
}} }}
onPointerDown={(event) => startLongPress(event, line.requestId)}
onPointerUp={cancelLongPress}
onPointerLeave={cancelLongPress}
onPointerCancel={cancelLongPress}
onPointerMove={handleLongPressMove}
title={t('logs.double_click_copy_hint', { title={t('logs.double_click_copy_hint', {
defaultValue: 'Double-click to copy', defaultValue: 'Double-click to copy',
})} })}
> >
<div className={styles.timestamp}>{line.timestamp || ''}</div> <div className={styles.timestamp}>{line.timestamp || ''}</div>
<div className={styles.rowMain}> <div className={styles.rowMain}>
<div className={styles.rowMeta}>
{line.level && ( {line.level && (
<span <span
className={[ className={[
@@ -724,6 +922,15 @@ export function LogsPage() {
</span> </span>
)} )}
{line.requestId && (
<span
className={[styles.badge, styles.requestIdBadge].join(' ')}
title={line.requestId}
>
{line.requestId}
</span>
)}
{typeof line.statusCode === 'number' && ( {typeof line.statusCode === 'number' && (
<span <span
className={[ className={[
@@ -750,13 +957,14 @@ export function LogsPage() {
{line.method} {line.method}
</span> </span>
)} )}
{line.path && ( {line.path && (
<span className={styles.path} title={line.path}> <span className={styles.path} title={line.path}>
{line.path} {line.path}
</span> </span>
)} )}
</div>
{line.message && <div className={styles.message}>{line.message}</div>} {line.message && <span className={styles.message}>{line.message}</span>}
</div> </div>
</div> </div>
); );
@@ -772,16 +980,37 @@ export function LogsPage() {
<EmptyState title={t('logs.empty_title')} description={t('logs.empty_desc')} /> <EmptyState title={t('logs.empty_title')} description={t('logs.empty_desc')} />
)} )}
</Card> </Card>
)}
{activeTab === 'errors' && (
<Card <Card
title={t('logs.error_logs_modal_title')}
extra={ extra={
<Button variant="secondary" size="sm" onClick={loadErrorLogs} loading={loadingErrors}> <Button
variant="secondary"
size="sm"
onClick={loadErrorLogs}
loading={loadingErrors}
disabled={disableControls}
>
{t('common.refresh')} {t('common.refresh')}
</Button> </Button>
} }
> >
{errorLogs.length === 0 ? ( <div className="stack">
<div className="hint">{t('logs.error_logs_description')}</div>
{requestLogEnabled && (
<div>
<div className="status-badge warning">{t('logs.error_logs_request_log_enabled')}</div>
</div>
)}
{errorLogsError && <div className="error-box">{errorLogsError}</div>}
<div className={styles.errorPanel}>
{loadingErrors ? (
<div className="hint">{t('common.loading')}</div>
) : errorLogs.length === 0 ? (
<div className="hint">{t('logs.error_logs_empty')}</div> <div className="hint">{t('logs.error_logs_empty')}</div>
) : ( ) : (
<div className="item-list"> <div className="item-list">
@@ -799,6 +1028,7 @@ export function LogsPage() {
variant="secondary" variant="secondary"
size="sm" size="sm"
onClick={() => downloadErrorLog(item.name)} onClick={() => downloadErrorLog(item.name)}
disabled={disableControls}
> >
{t('logs.error_logs_download')} {t('logs.error_logs_download')}
</Button> </Button>
@@ -807,8 +1037,37 @@ export function LogsPage() {
))} ))}
</div> </div>
)} )}
</Card>
</div> </div>
</div> </div>
</Card>
)}
</div>
<Modal
open={Boolean(requestLogId)}
onClose={closeRequestLogModal}
title={t('logs.request_log_download_title')}
footer={
<>
<Button variant="secondary" onClick={closeRequestLogModal} disabled={requestLogDownloading}>
{t('common.cancel')}
</Button>
<Button
onClick={() => {
if (requestLogId) {
void downloadRequestLog(requestLogId);
}
}}
loading={requestLogDownloading}
disabled={!requestLogId}
>
{t('common.confirm')}
</Button>
</>
}
>
{requestLogId ? t('logs.request_log_download_confirm', { id: requestLogId }) : null}
</Modal>
</div>
); );
} }

View File

@@ -4,6 +4,17 @@
width: 100%; width: 100%;
} }
.cardTitle {
display: flex;
align-items: center;
gap: $spacing-sm;
}
.cardTitleIcon {
width: 24px;
height: 24px;
}
.pageTitle { .pageTitle {
font-size: 28px; font-size: 28px;
font-weight: 700; font-weight: 700;
@@ -59,3 +70,69 @@
color: #3b82f6; 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;
}
.filePicker {
display: flex;
align-items: center;
gap: $spacing-sm;
flex-wrap: wrap;
}
.fileName {
flex: 1;
min-width: 220px;
padding: 10px 12px;
border: 1px solid var(--border-color);
border-radius: $radius-md;
background: var(--bg-primary);
color: var(--text-primary);
font-size: 14px;
}
.fileNamePlaceholder {
color: var(--text-secondary);
}

View File

@@ -1,12 +1,20 @@
import { useEffect, useRef, useState, useMemo } from 'react'; import { useEffect, useRef, useState, type ChangeEvent } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Card } from '@/components/ui/Card'; import { Card } from '@/components/ui/Card';
import { Button } from '@/components/ui/Button'; import { Button } from '@/components/ui/Button';
import { Input } from '@/components/ui/Input'; import { Input } from '@/components/ui/Input';
import { useNotificationStore } from '@/stores'; import { useNotificationStore, useThemeStore } from '@/stores';
import { oauthApi, type OAuthProvider, type IFlowCookieAuthResponse } from '@/services/api/oauth'; import { oauthApi, type OAuthProvider, type IFlowCookieAuthResponse } from '@/services/api/oauth';
import { isLocalhost } from '@/utils/connection'; import { vertexApi, type VertexImportResponse } from '@/services/api/vertex';
import styles from './OAuthPage.module.scss'; import styles from './OAuthPage.module.scss';
import iconOpenaiLight from '@/assets/icons/openai-light.svg';
import iconOpenaiDark from '@/assets/icons/openai-dark.svg';
import iconClaude from '@/assets/icons/claude.svg';
import iconAntigravity from '@/assets/icons/antigravity.svg';
import iconGemini from '@/assets/icons/gemini.svg';
import iconQwen from '@/assets/icons/qwen.svg';
import iconIflow from '@/assets/icons/iflow.svg';
import iconVertex from '@/assets/icons/vertex.svg';
interface ProviderState { interface ProviderState {
url?: string; url?: string;
@@ -14,6 +22,12 @@ interface ProviderState {
status?: 'idle' | 'waiting' | 'success' | 'error'; status?: 'idle' | 'waiting' | 'success' | 'error';
error?: string; error?: string;
polling?: boolean; polling?: boolean;
projectId?: string;
projectIdError?: string;
callbackUrl?: string;
callbackSubmitting?: boolean;
callbackStatus?: 'success' | 'error';
callbackError?: string;
} }
interface IFlowCookieState { interface IFlowCookieState {
@@ -24,24 +38,53 @@ interface IFlowCookieState {
errorType?: 'error' | 'warning'; errorType?: 'error' | 'warning';
} }
const PROVIDERS: { id: OAuthProvider; titleKey: string; hintKey: string; urlLabelKey: string }[] = [ interface VertexImportResult {
{ id: 'codex', titleKey: 'auth_login.codex_oauth_title', hintKey: 'auth_login.codex_oauth_hint', urlLabelKey: 'auth_login.codex_oauth_url_label' }, projectId?: string;
{ id: 'anthropic', titleKey: 'auth_login.anthropic_oauth_title', hintKey: 'auth_login.anthropic_oauth_hint', urlLabelKey: 'auth_login.anthropic_oauth_url_label' }, email?: string;
{ id: 'antigravity', titleKey: 'auth_login.antigravity_oauth_title', hintKey: 'auth_login.antigravity_oauth_hint', urlLabelKey: 'auth_login.antigravity_oauth_url_label' }, location?: string;
{ id: 'gemini-cli', titleKey: 'auth_login.gemini_cli_oauth_title', hintKey: 'auth_login.gemini_cli_oauth_hint', urlLabelKey: 'auth_login.gemini_cli_oauth_url_label' }, authFile?: string;
{ id: 'qwen', titleKey: 'auth_login.qwen_oauth_title', hintKey: 'auth_login.qwen_oauth_hint', urlLabelKey: 'auth_login.qwen_oauth_url_label' }, }
{ id: 'iflow', titleKey: 'auth_login.iflow_oauth_title', hintKey: 'auth_login.iflow_oauth_hint', urlLabelKey: 'auth_login.iflow_oauth_url_label' }
interface VertexImportState {
file?: File;
fileName: string;
location: string;
loading: boolean;
error?: string;
result?: VertexImportResult;
}
const PROVIDERS: { id: OAuthProvider; titleKey: string; hintKey: string; urlLabelKey: string; icon: string | { light: string; dark: string } }[] = [
{ id: 'codex', titleKey: 'auth_login.codex_oauth_title', hintKey: 'auth_login.codex_oauth_hint', urlLabelKey: 'auth_login.codex_oauth_url_label', icon: { light: iconOpenaiLight, dark: iconOpenaiDark } },
{ id: 'anthropic', titleKey: 'auth_login.anthropic_oauth_title', hintKey: 'auth_login.anthropic_oauth_hint', urlLabelKey: 'auth_login.anthropic_oauth_url_label', icon: iconClaude },
{ id: 'antigravity', titleKey: 'auth_login.antigravity_oauth_title', hintKey: 'auth_login.antigravity_oauth_hint', urlLabelKey: 'auth_login.antigravity_oauth_url_label', icon: iconAntigravity },
{ id: 'gemini-cli', titleKey: 'auth_login.gemini_cli_oauth_title', hintKey: 'auth_login.gemini_cli_oauth_hint', urlLabelKey: 'auth_login.gemini_cli_oauth_url_label', icon: iconGemini },
{ id: 'qwen', titleKey: 'auth_login.qwen_oauth_title', hintKey: 'auth_login.qwen_oauth_hint', urlLabelKey: 'auth_login.qwen_oauth_url_label', icon: iconQwen },
{ id: 'iflow', titleKey: 'auth_login.iflow_oauth_title', hintKey: 'auth_login.iflow_oauth_hint', urlLabelKey: 'auth_login.iflow_oauth_url_label', icon: iconIflow }
]; ];
const CALLBACK_SUPPORTED: OAuthProvider[] = ['codex', 'anthropic', 'antigravity', 'gemini-cli', 'iflow'];
const getProviderI18nPrefix = (provider: OAuthProvider) => provider.replace('-', '_');
const getAuthKey = (provider: OAuthProvider, suffix: string) =>
`auth_login.${getProviderI18nPrefix(provider)}_${suffix}`;
const getIcon = (icon: string | { light: string; dark: string }, theme: 'light' | 'dark') => {
return typeof icon === 'string' ? icon : icon[theme];
};
export function OAuthPage() { export function OAuthPage() {
const { t } = useTranslation(); const { t } = useTranslation();
const { showNotification } = useNotificationStore(); const { showNotification } = useNotificationStore();
const resolvedTheme = useThemeStore((state) => state.resolvedTheme);
const [states, setStates] = useState<Record<OAuthProvider, ProviderState>>({} as Record<OAuthProvider, ProviderState>); const [states, setStates] = useState<Record<OAuthProvider, ProviderState>>({} as Record<OAuthProvider, ProviderState>);
const [iflowCookie, setIflowCookie] = useState<IFlowCookieState>({ cookie: '', loading: false }); const [iflowCookie, setIflowCookie] = useState<IFlowCookieState>({ cookie: '', loading: false });
const [vertexState, setVertexState] = useState<VertexImportState>({
fileName: '',
location: '',
loading: false
});
const timers = useRef<Record<string, number>>({}); const timers = useRef<Record<string, number>>({});
const vertexFileInputRef = useRef<HTMLInputElement | null>(null);
// 检测是否为本地访问
const isLocal = useMemo(() => isLocalhost(window.location.hostname), []);
useEffect(() => { useEffect(() => {
return () => { return () => {
@@ -49,6 +92,13 @@ export function OAuthPage() {
}; };
}, []); }, []);
const updateProviderState = (provider: OAuthProvider, next: Partial<ProviderState>) => {
setStates((prev) => ({
...prev,
[provider]: { ...(prev[provider] ?? {}), ...next }
}));
};
const startPolling = (provider: OAuthProvider, state: string) => { const startPolling = (provider: OAuthProvider, state: string) => {
if (timers.current[provider]) { if (timers.current[provider]) {
clearInterval(timers.current[provider]); clearInterval(timers.current[provider]);
@@ -57,27 +107,21 @@ export function OAuthPage() {
try { try {
const res = await oauthApi.getAuthStatus(state); const res = await oauthApi.getAuthStatus(state);
if (res.status === 'ok') { if (res.status === 'ok') {
setStates((prev) => ({ updateProviderState(provider, { status: 'success', polling: false });
...prev, showNotification(t(getAuthKey(provider, 'oauth_status_success')), 'success');
[provider]: { ...prev[provider], status: 'success', polling: false }
}));
showNotification(t('auth_login.codex_oauth_status_success'), 'success');
window.clearInterval(timer); window.clearInterval(timer);
delete timers.current[provider]; delete timers.current[provider];
} else if (res.status === 'error') { } else if (res.status === 'error') {
setStates((prev) => ({ updateProviderState(provider, { status: 'error', error: res.error, polling: false });
...prev, showNotification(
[provider]: { ...prev[provider], status: 'error', error: res.error, polling: false } `${t(getAuthKey(provider, 'oauth_status_error'))} ${res.error || ''}`,
})); 'error'
showNotification(`${t('auth_login.codex_oauth_status_error')} ${res.error || ''}`, 'error'); );
window.clearInterval(timer); window.clearInterval(timer);
delete timers.current[provider]; delete timers.current[provider];
} }
} catch (err: any) { } catch (err: any) {
setStates((prev) => ({ updateProviderState(provider, { status: 'error', error: err?.message, polling: false });
...prev,
[provider]: { ...prev[provider], status: 'error', error: err?.message, polling: false }
}));
window.clearInterval(timer); window.clearInterval(timer);
delete timers.current[provider]; delete timers.current[provider];
} }
@@ -86,25 +130,36 @@ export function OAuthPage() {
}; };
const startAuth = async (provider: OAuthProvider) => { const startAuth = async (provider: OAuthProvider) => {
setStates((prev) => ({ const projectId = provider === 'gemini-cli' ? (states[provider]?.projectId || '').trim() : undefined;
...prev, if (provider === 'gemini-cli' && !projectId) {
[provider]: { ...prev[provider], status: 'waiting', polling: true, error: undefined } 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 { try {
const res = await oauthApi.startAuth(provider); const res = await oauthApi.startAuth(
setStates((prev) => ({ provider,
...prev, provider === 'gemini-cli' ? { projectId: projectId! } : undefined
[provider]: { ...prev[provider], url: res.url, state: res.state, status: 'waiting', polling: true } );
})); updateProviderState(provider, { url: res.url, state: res.state, status: 'waiting', polling: true });
if (res.state) { if (res.state) {
startPolling(provider, res.state); startPolling(provider, res.state);
} }
} catch (err: any) { } catch (err: any) {
setStates((prev) => ({ updateProviderState(provider, { status: 'error', error: err?.message, polling: false });
...prev, showNotification(`${t(getAuthKey(provider, 'oauth_start_error'))} ${err?.message || ''}`, 'error');
[provider]: { ...prev[provider], status: 'error', error: err?.message, polling: false }
}));
showNotification(`${t('auth_login.codex_oauth_start_error')} ${err?.message || ''}`, 'error');
} }
}; };
@@ -118,6 +173,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 submitIflowCookie = async () => {
const cookie = iflowCookie.cookie.trim(); const cookie = iflowCookie.cookie.trim();
if (!cookie) { if (!cookie) {
@@ -157,6 +246,64 @@ export function OAuthPage() {
} }
}; };
const handleVertexFilePick = () => {
vertexFileInputRef.current?.click();
};
const handleVertexFileChange = (event: ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (!file) return;
if (!file.name.endsWith('.json')) {
showNotification(t('vertex_import.file_required'), 'warning');
event.target.value = '';
return;
}
setVertexState((prev) => ({
...prev,
file,
fileName: file.name,
error: undefined,
result: undefined
}));
event.target.value = '';
};
const handleVertexImport = async () => {
if (!vertexState.file) {
const message = t('vertex_import.file_required');
setVertexState((prev) => ({ ...prev, error: message }));
showNotification(message, 'warning');
return;
}
const location = vertexState.location.trim();
setVertexState((prev) => ({ ...prev, loading: true, error: undefined, result: undefined }));
try {
const res: VertexImportResponse = await vertexApi.importCredential(
vertexState.file,
location || undefined
);
const result: VertexImportResult = {
projectId: res.project_id,
email: res.email,
location: res.location,
authFile: res['auth-file'] ?? res.auth_file
};
setVertexState((prev) => ({ ...prev, loading: false, result }));
showNotification(t('vertex_import.success'), 'success');
} catch (err: any) {
const message = err?.message || '';
setVertexState((prev) => ({
...prev,
loading: false,
error: message || t('notification.upload_failed')
}));
const notification = message
? `${t('notification.upload_failed')}: ${message}`
: t('notification.upload_failed');
showNotification(notification, 'error');
}
};
return ( return (
<div className={styles.container}> <div className={styles.container}>
<h1 className={styles.pageTitle}>{t('nav.oauth', { defaultValue: 'OAuth' })}</h1> <h1 className={styles.pageTitle}>{t('nav.oauth', { defaultValue: 'OAuth' })}</h1>
@@ -164,56 +311,104 @@ export function OAuthPage() {
<div className={styles.content}> <div className={styles.content}>
{PROVIDERS.map((provider) => { {PROVIDERS.map((provider) => {
const state = states[provider.id] || {}; const state = states[provider.id] || {};
// 非本地访问时禁用所有 OAuth 登录方式 const canSubmitCallback = CALLBACK_SUPPORTED.includes(provider.id) && Boolean(state.url);
const isDisabled = !isLocal;
return ( return (
<div <div key={provider.id}>
key={provider.id}
style={isDisabled ? { opacity: 0.6, pointerEvents: 'none' } : undefined}
>
<Card <Card
title={t(provider.titleKey)} title={
<span className={styles.cardTitle}>
<img
src={getIcon(provider.icon, resolvedTheme)}
alt=""
className={styles.cardTitleIcon}
/>
{t(provider.titleKey)}
</span>
}
extra={ extra={
<Button <Button onClick={() => startAuth(provider.id)} loading={state.polling}>
onClick={() => startAuth(provider.id)}
loading={state.polling}
disabled={isDisabled}
>
{t('common.login')} {t('common.login')}
</Button> </Button>
} }
> >
<div className="hint">{t(provider.hintKey)}</div> <div className="hint">{t(provider.hintKey)}</div>
{isDisabled && ( {provider.id === 'gemini-cli' && (
<div className="status-badge warning" style={{ marginTop: 8 }}> <Input
{t('auth_login.remote_access_disabled')} label={t('auth_login.gemini_cli_project_id_label')}
</div> 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 && ( {state.url && (
<div className="connection-box"> <div className={`connection-box ${styles.authUrlBox}`}>
<div className="label">{t(provider.urlLabelKey)}</div> <div className={styles.authUrlLabel}>{t(provider.urlLabelKey)}</div>
<div className="value">{state.url}</div> <div className={styles.authUrlValue}>{state.url}</div>
<div className="item-actions" style={{ marginTop: 8 }}> <div className={styles.authUrlActions}>
<Button variant="secondary" size="sm" onClick={() => copyLink(state.url!)}> <Button variant="secondary" size="sm" onClick={() => copyLink(state.url!)}>
{t('auth_login.codex_copy_link')} {t(getAuthKey(provider.id, 'copy_link'))}
</Button> </Button>
<Button <Button
variant="secondary" variant="secondary"
size="sm" size="sm"
onClick={() => window.open(state.url, '_blank', 'noopener,noreferrer')} onClick={() => window.open(state.url, '_blank', 'noopener,noreferrer')}
> >
{t('auth_login.codex_open_link')} {t(getAuthKey(provider.id, 'open_link'))}
</Button> </Button>
</div> </div>
</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 }}> <div className="status-badge" style={{ marginTop: 8 }}>
{state.status === 'success' {state.status === 'success'
? t('auth_login.codex_oauth_status_success') ? t(getAuthKey(provider.id, 'oauth_status_success'))
: state.status === 'error' : state.status === 'error'
? `${t('auth_login.codex_oauth_status_error')} ${state.error || ''}` ? `${t(getAuthKey(provider.id, 'oauth_status_error'))} ${state.error || ''}`
: t('auth_login.codex_oauth_status_waiting')} : t(getAuthKey(provider.id, 'oauth_status_waiting'))}
</div> </div>
)} )}
</Card> </Card>
@@ -221,9 +416,102 @@ export function OAuthPage() {
); );
})} })}
{/* Vertex JSON 登录 */}
<Card
title={
<span className={styles.cardTitle}>
<img src={iconVertex} alt="" className={styles.cardTitleIcon} />
{t('vertex_import.title')}
</span>
}
extra={
<Button onClick={handleVertexImport} loading={vertexState.loading}>
{t('vertex_import.import_button')}
</Button>
}
>
<div className="hint">{t('vertex_import.description')}</div>
<Input
label={t('vertex_import.location_label')}
hint={t('vertex_import.location_hint')}
value={vertexState.location}
onChange={(e) =>
setVertexState((prev) => ({
...prev,
location: e.target.value
}))
}
placeholder={t('vertex_import.location_placeholder')}
/>
<div className="form-group">
<label>{t('vertex_import.file_label')}</label>
<div className={styles.filePicker}>
<Button variant="secondary" size="sm" onClick={handleVertexFilePick}>
{t('vertex_import.choose_file')}
</Button>
<div
className={`${styles.fileName} ${
vertexState.fileName ? '' : styles.fileNamePlaceholder
}`.trim()}
>
{vertexState.fileName || t('vertex_import.file_placeholder')}
</div>
</div>
<div className="hint">{t('vertex_import.file_hint')}</div>
<input
ref={vertexFileInputRef}
type="file"
accept=".json,application/json"
style={{ display: 'none' }}
onChange={handleVertexFileChange}
/>
</div>
{vertexState.error && (
<div className="status-badge error" style={{ marginTop: 8 }}>
{vertexState.error}
</div>
)}
{vertexState.result && (
<div className="connection-box" style={{ marginTop: 12 }}>
<div className="label">{t('vertex_import.result_title')}</div>
<div className="key-value-list">
{vertexState.result.projectId && (
<div className="key-value-item">
<span className="key">{t('vertex_import.result_project')}</span>
<span className="value">{vertexState.result.projectId}</span>
</div>
)}
{vertexState.result.email && (
<div className="key-value-item">
<span className="key">{t('vertex_import.result_email')}</span>
<span className="value">{vertexState.result.email}</span>
</div>
)}
{vertexState.result.location && (
<div className="key-value-item">
<span className="key">{t('vertex_import.result_location')}</span>
<span className="value">{vertexState.result.location}</span>
</div>
)}
{vertexState.result.authFile && (
<div className="key-value-item">
<span className="key">{t('vertex_import.result_file')}</span>
<span className="value">{vertexState.result.authFile}</span>
</div>
)}
</div>
</div>
)}
</Card>
{/* iFlow Cookie 登录 */} {/* iFlow Cookie 登录 */}
<Card <Card
title={t('auth_login.iflow_cookie_title')} title={
<span className={styles.cardTitle}>
<img src={iconIflow} alt="" className={styles.cardTitleIcon} />
{t('auth_login.iflow_cookie_title')}
</span>
}
extra={ extra={
<Button onClick={submitIflowCookie} loading={iflowCookie.loading}> <Button onClick={submitIflowCookie} loading={iflowCookie.loading}>
{t('auth_login.iflow_cookie_button')} {t('auth_login.iflow_cookie_button')}

View File

@@ -0,0 +1,333 @@
@use '../styles/variables' as *;
@use '../styles/mixins' as *;
.container {
display: flex;
flex-direction: column;
gap: $spacing-lg;
}
.pageHeader {
display: flex;
flex-direction: column;
gap: $spacing-sm;
}
.pageTitle {
font-size: 28px;
font-weight: 700;
color: var(--text-primary);
margin: 0;
}
.description {
font-size: 14px;
color: var(--text-secondary);
margin: 0;
}
.headerActions {
display: flex;
gap: $spacing-sm;
flex-wrap: wrap;
}
.errorBox {
padding: $spacing-md;
background-color: rgba(239, 68, 68, 0.1);
border: 1px solid var(--danger-color);
border-radius: $radius-md;
color: var(--danger-color);
font-size: 14px;
}
.pageSizeSelect {
padding: 8px 12px;
border: 1px solid var(--border-color);
border-radius: $radius-md;
background-color: var(--bg-primary);
color: var(--text-primary);
font-size: 14px;
cursor: pointer;
height: 38px;
box-sizing: border-box;
&:focus {
outline: none;
border-color: var(--primary-color);
}
}
.statsInfo {
padding: 8px 12px;
background-color: var(--bg-secondary);
border-radius: $radius-md;
font-size: 13px;
color: var(--text-secondary);
white-space: nowrap;
height: 38px;
box-sizing: border-box;
display: flex;
align-items: center;
}
.antigravityGrid,
.codexGrid,
.geminiCliGrid {
display: grid;
gap: $spacing-md;
grid-template-columns: repeat(3, minmax(0, 1fr));
@include tablet {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
@include mobile {
grid-template-columns: 1fr;
}
}
.antigravityControls,
.codexControls,
.geminiCliControls {
display: flex;
gap: $spacing-md;
flex-wrap: wrap;
align-items: flex-end;
margin-bottom: $spacing-md;
}
.antigravityControl,
.codexControl,
.geminiCliControl {
display: flex;
flex-direction: column;
gap: 4px;
label {
font-size: 12px;
color: var(--text-secondary);
font-weight: 500;
white-space: nowrap;
}
}
.antigravityCard {
background-image: linear-gradient(
180deg,
rgba(224, 247, 250, 0.12),
rgba(224, 247, 250, 0)
);
}
.codexCard {
background-image: linear-gradient(
180deg,
rgba(255, 243, 224, 0.18),
rgba(255, 243, 224, 0)
);
}
.geminiCliCard {
background-image: linear-gradient(
180deg,
rgba(231, 239, 255, 0.2),
rgba(231, 239, 255, 0)
);
}
.quotaSection {
display: flex;
flex-direction: column;
gap: $spacing-sm;
padding-top: $spacing-sm;
margin-top: $spacing-xs;
border-top: 1px dashed var(--border-color);
}
.quotaRow {
display: flex;
flex-direction: column;
gap: $spacing-xs;
}
.quotaRowHeader {
display: flex;
align-items: center;
justify-content: space-between;
gap: $spacing-sm;
min-width: 0;
@include mobile {
flex-direction: column;
align-items: flex-start;
}
}
.quotaModel {
font-size: 13px;
font-weight: 600;
color: var(--text-primary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
flex: 1;
min-width: 0;
@include mobile {
white-space: normal;
}
}
.quotaBar {
height: 8px;
background-color: var(--bg-tertiary);
border-radius: 999px;
overflow: hidden;
}
.quotaBarFill {
height: 100%;
background-color: var(--success-color, #22c55e);
transition: width 0.2s ease;
}
.quotaBarFillHigh {
background-color: var(--success-color, #22c55e);
}
.quotaBarFillMedium {
background-color: var(--warning-color, #f59e0b);
}
.quotaBarFillLow {
background-color: var(--danger-color, #ef4444);
}
.quotaMeta {
display: flex;
align-items: center;
gap: 8px;
font-size: 12px;
color: var(--text-secondary);
white-space: nowrap;
@include mobile {
justify-content: flex-start;
}
}
.quotaPercent {
font-weight: 600;
color: var(--text-primary);
}
.quotaReset {
color: var(--text-tertiary);
}
.quotaAmount {
color: var(--text-secondary);
}
.quotaMessage {
font-size: 12px;
color: var(--text-tertiary);
text-align: center;
padding: $spacing-sm 0;
}
.quotaError {
font-size: 12px;
color: var(--danger-color);
background-color: rgba(239, 68, 68, 0.08);
border: 1px solid var(--danger-color);
border-radius: $radius-sm;
padding: $spacing-xs $spacing-sm;
}
.quotaWarning {
font-size: 12px;
color: var(--warning-color, #f59e0b);
background-color: rgba(245, 158, 11, 0.12);
border: 1px solid var(--warning-color, #f59e0b);
border-radius: $radius-sm;
padding: $spacing-xs $spacing-sm;
}
.codexPlan {
display: flex;
align-items: center;
gap: 6px;
font-size: 12px;
color: var(--text-secondary);
}
.codexPlanLabel {
color: var(--text-tertiary);
}
.codexPlanValue {
font-weight: 600;
color: var(--text-primary);
text-transform: capitalize;
}
.fileCard {
background-color: var(--bg-primary);
border: 1px solid var(--border-color);
border-radius: $radius-lg;
padding: $spacing-md;
display: flex;
flex-direction: column;
gap: $spacing-sm;
transition: transform $transition-fast, box-shadow $transition-fast, border-color $transition-fast;
&:hover {
transform: translateY(-2px);
box-shadow: $shadow-md;
border-color: rgba(37, 99, 235, 0.2);
}
}
.cardHeader {
display: flex;
align-items: center;
gap: $spacing-sm;
min-height: 28px;
}
.typeBadge {
padding: 4px 10px;
border-radius: 12px;
font-size: 12px;
font-weight: 600;
white-space: nowrap;
flex-shrink: 0;
}
.fileName {
font-size: 14px;
font-weight: 600;
color: var(--text-primary);
word-break: break-all;
line-height: 1.4;
}
.pagination {
display: flex;
justify-content: center;
align-items: center;
gap: $spacing-md;
margin-top: $spacing-lg;
padding-top: $spacing-md;
border-top: 1px solid var(--border-color);
}
.pageInfo {
font-size: 13px;
color: var(--text-secondary);
padding: $spacing-xs $spacing-md;
background-color: var(--bg-secondary);
border-radius: $radius-md;
}

1966
src/pages/QuotaPage.tsx Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -16,7 +16,6 @@ type PendingKey =
| 'switchProject' | 'switchProject'
| 'switchPreview' | 'switchPreview'
| 'usage' | 'usage'
| 'requestLog'
| 'loggingToFile' | 'loggingToFile'
| 'wsAuth'; | 'wsAuth';
@@ -70,7 +69,7 @@ export function SettingsPage() {
const toggleSetting = async ( const toggleSetting = async (
section: PendingKey, 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, value: boolean,
updater: (val: boolean) => Promise<any>, updater: (val: boolean) => Promise<any>,
successMessage: string successMessage: string
@@ -81,8 +80,6 @@ export function SettingsPage() {
return config?.debug ?? false; return config?.debug ?? false;
case 'usage-statistics-enabled': case 'usage-statistics-enabled':
return config?.usageStatisticsEnabled ?? false; return config?.usageStatisticsEnabled ?? false;
case 'request-log':
return config?.requestLog ?? false;
case 'logging-to-file': case 'logging-to-file':
return config?.loggingToFile ?? false; return config?.loggingToFile ?? false;
case 'ws-auth': 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 <ToggleSwitch
label={t('basic_settings.logging_to_file_enable')} label={t('basic_settings.logging_to_file_enable')}
checked={config?.loggingToFile ?? false} checked={config?.loggingToFile ?? false}

View File

@@ -34,6 +34,12 @@
margin: 0 0 $spacing-md 0; margin: 0 0 $spacing-md 0;
} }
.clearLoginActions {
display: flex;
justify-content: flex-end;
align-items: center;
}
.infoGrid { .infoGrid {
display: grid; display: grid;
gap: $spacing-sm; gap: $spacing-sm;

View File

@@ -6,6 +6,7 @@ import { IconGithub, IconBookOpen, IconExternalLink, IconCode } from '@/componen
import { useAuthStore, useConfigStore, useNotificationStore, useModelsStore } from '@/stores'; import { useAuthStore, useConfigStore, useNotificationStore, useModelsStore } from '@/stores';
import { apiKeysApi } from '@/services/api/apiKeys'; import { apiKeysApi } from '@/services/api/apiKeys';
import { classifyModels } from '@/utils/models'; import { classifyModels } from '@/utils/models';
import { STORAGE_KEY_AUTH } from '@/utils/constants';
import styles from './SystemPage.module.scss'; import styles from './SystemPage.module.scss';
export function SystemPage() { export function SystemPage() {
@@ -104,6 +105,15 @@ export function SystemPage() {
} }
}; };
const handleClearLoginStorage = () => {
if (!window.confirm(t('system_info.clear_login_confirm'))) return;
auth.logout();
if (typeof localStorage === 'undefined') return;
const keysToRemove = [STORAGE_KEY_AUTH, 'isLoggedIn', 'apiBase', 'apiUrl', 'managementKey'];
keysToRemove.forEach((key) => localStorage.removeItem(key));
showNotification(t('notification.login_storage_cleared'), 'success');
};
useEffect(() => { useEffect(() => {
fetchConfig().catch(() => { fetchConfig().catch(() => {
// ignore // ignore
@@ -248,6 +258,15 @@ export function SystemPage() {
</div> </div>
)} )}
</Card> </Card>
<Card title={t('system_info.clear_login_title')}>
<p className={styles.sectionDescription}>{t('system_info.clear_login_desc')}</p>
<div className={styles.clearLoginActions}>
<Button variant="danger" onClick={handleClearLoginStorage}>
{t('system_info.clear_login_button')}
</Button>
</div>
</Card>
</div> </div>
</div> </div>
); );

View File

@@ -18,6 +18,13 @@
gap: 10px; gap: 10px;
} }
.headerActions {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.pageTitle { .pageTitle {
font-size: 28px; font-size: 28px;
font-weight: 700; font-weight: 700;
@@ -66,11 +73,12 @@
border: 1px solid var(--border-color); border: 1px solid var(--border-color);
background: var(--bg-primary); background: var(--bg-primary);
box-shadow: var(--shadow-lg); box-shadow: var(--shadow-lg);
}
:global(.loading-spinner) { .loadingOverlaySpinner {
border-color: rgba(59, 130, 246, 0.25); border-color: rgba(59, 130, 246, 0.25);
border-top-color: var(--primary-color); border-top-color: var(--primary-color);
} box-shadow: 0 0 10px rgba(59, 130, 246, 0.25);
} }
.loadingOverlayText { .loadingOverlayText {

View File

@@ -1,4 +1,4 @@
import { useEffect, useState, useCallback, useMemo, type CSSProperties } from 'react'; import { useEffect, useState, useCallback, useMemo, useRef, type CSSProperties } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { import {
Chart as ChartJS, Chart as ChartJS,
@@ -19,7 +19,7 @@ import { Input } from '@/components/ui/Input';
import { LoadingSpinner } from '@/components/ui/LoadingSpinner'; import { LoadingSpinner } from '@/components/ui/LoadingSpinner';
import { IconDiamond, IconDollarSign, IconSatellite, IconTimer, IconTrendingUp } from '@/components/ui/icons'; import { IconDiamond, IconDollarSign, IconSatellite, IconTimer, IconTrendingUp } from '@/components/ui/icons';
import { useMediaQuery } from '@/hooks/useMediaQuery'; import { useMediaQuery } from '@/hooks/useMediaQuery';
import { useThemeStore } from '@/stores'; import { useNotificationStore, useThemeStore } from '@/stores';
import { usageApi } from '@/services/api/usage'; import { usageApi } from '@/services/api/usage';
import { import {
formatTokensInMillions, formatTokensInMillions,
@@ -63,14 +63,18 @@ interface UsagePayload {
export function UsagePage() { export function UsagePage() {
const { t } = useTranslation(); const { t } = useTranslation();
const { showNotification } = useNotificationStore();
const isMobile = useMediaQuery('(max-width: 768px)'); const isMobile = useMediaQuery('(max-width: 768px)');
const theme = useThemeStore((state) => state.theme); const resolvedTheme = useThemeStore((state) => state.resolvedTheme);
const isDark = theme === 'dark'; const isDark = resolvedTheme === 'dark';
const [usage, setUsage] = useState<UsagePayload | null>(null); const [usage, setUsage] = useState<UsagePayload | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState(''); const [error, setError] = useState('');
const [modelPrices, setModelPrices] = useState<Record<string, ModelPrice>>({}); const [modelPrices, setModelPrices] = useState<Record<string, ModelPrice>>({});
const [exporting, setExporting] = useState(false);
const [importing, setImporting] = useState(false);
const importInputRef = useRef<HTMLInputElement | null>(null);
// Model price form state // Model price form state
const [selectedModel, setSelectedModel] = useState(''); const [selectedModel, setSelectedModel] = useState('');
@@ -107,6 +111,77 @@ export function UsagePage() {
setModelPrices(loadModelPrices()); setModelPrices(loadModelPrices());
}, [loadUsage]); }, [loadUsage]);
const handleExport = async () => {
setExporting(true);
try {
const data = await usageApi.exportUsage();
const exportedAt =
typeof data?.exported_at === 'string' ? new Date(data.exported_at) : new Date();
const safeTimestamp = Number.isNaN(exportedAt.getTime())
? new Date().toISOString()
: exportedAt.toISOString();
const filename = `usage-export-${safeTimestamp.replace(/[:.]/g, '-')}.json`;
const blob = new Blob([JSON.stringify(data ?? {}, null, 2)], { type: 'application/json' });
const url = window.URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = filename;
link.click();
window.URL.revokeObjectURL(url);
showNotification(t('usage_stats.export_success'), 'success');
} catch (err: unknown) {
const message = err instanceof Error ? err.message : '';
showNotification(
`${t('notification.download_failed')}${message ? `: ${message}` : ''}`,
'error'
);
} finally {
setExporting(false);
}
};
const handleImportClick = () => {
importInputRef.current?.click();
};
const handleImportChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
event.target.value = '';
if (!file) return;
setImporting(true);
try {
const text = await file.text();
let payload: unknown;
try {
payload = JSON.parse(text);
} catch {
showNotification(t('usage_stats.import_invalid'), 'error');
return;
}
const result = await usageApi.importUsage(payload);
showNotification(
t('usage_stats.import_success', {
added: result?.added ?? 0,
skipped: result?.skipped ?? 0,
total: result?.total_requests ?? 0,
failed: result?.failed_requests ?? 0
}),
'success'
);
await loadUsage();
} catch (err: unknown) {
const message = err instanceof Error ? err.message : '';
showNotification(
`${t('notification.upload_failed')}${message ? `: ${message}` : ''}`,
'error'
);
} finally {
setImporting(false);
}
};
// Calculate derived data // Calculate derived data
const tokenBreakdown = usage ? calculateTokenBreakdown(usage) : { cachedTokens: 0, reasoningTokens: 0 }; const tokenBreakdown = usage ? calculateTokenBreakdown(usage) : { cachedTokens: 0, reasoningTokens: 0 };
const rateStats = usage const rateStats = usage
@@ -520,21 +595,48 @@ export function UsagePage() {
{loading && !usage && ( {loading && !usage && (
<div className={styles.loadingOverlay} aria-busy="true"> <div className={styles.loadingOverlay} aria-busy="true">
<div className={styles.loadingOverlayContent}> <div className={styles.loadingOverlayContent}>
<LoadingSpinner size={28} /> <LoadingSpinner size={28} className={styles.loadingOverlaySpinner} />
<span className={styles.loadingOverlayText}>{t('common.loading')}</span> <span className={styles.loadingOverlayText}>{t('common.loading')}</span>
</div> </div>
</div> </div>
)} )}
<div className={styles.header}> <div className={styles.header}>
<h1 className={styles.pageTitle}>{t('usage_stats.title')}</h1> <h1 className={styles.pageTitle}>{t('usage_stats.title')}</h1>
<div className={styles.headerActions}>
<Button
variant="secondary"
size="sm"
onClick={handleExport}
loading={exporting}
disabled={loading || importing}
>
{t('usage_stats.export')}
</Button>
<Button
variant="secondary"
size="sm"
onClick={handleImportClick}
loading={importing}
disabled={loading || exporting}
>
{t('usage_stats.import')}
</Button>
<Button <Button
variant="secondary" variant="secondary"
size="sm" size="sm"
onClick={loadUsage} onClick={loadUsage}
disabled={loading} disabled={loading || exporting || importing}
> >
{loading ? t('common.loading') : t('usage_stats.refresh')} {loading ? t('common.loading') : t('usage_stats.refresh')}
</Button> </Button>
<input
ref={importInputRef}
type="file"
accept=".json,application/json"
style={{ display: 'none' }}
onChange={handleImportChange}
/>
</div>
</div> </div>
{error && <div className={styles.errorBox}>{error}</div>} {error && <div className={styles.errorBox}>{error}</div>}

View File

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

View File

@@ -0,0 +1,86 @@
/**
* Generic API call helper (proxied via management API).
*/
import type { AxiosRequestConfig } from 'axios';
import { apiClient } from './client';
export interface ApiCallRequest {
authIndex?: string;
method: string;
url: string;
header?: Record<string, string>;
data?: string;
}
export interface ApiCallResult<T = any> {
statusCode: number;
header: Record<string, string[]>;
bodyText: string;
body: T | null;
}
const normalizeBody = (input: unknown): { bodyText: string; body: any | null } => {
if (input === undefined || input === null) {
return { bodyText: '', body: null };
}
if (typeof input === 'string') {
const text = input;
const trimmed = text.trim();
if (!trimmed) {
return { bodyText: text, body: null };
}
try {
return { bodyText: text, body: JSON.parse(trimmed) };
} catch {
return { bodyText: text, body: text };
}
}
try {
return { bodyText: JSON.stringify(input), body: input };
} catch {
return { bodyText: String(input), body: input };
}
};
export const getApiCallErrorMessage = (result: ApiCallResult): string => {
const status = result.statusCode;
const body = result.body;
const bodyText = result.bodyText;
let message = '';
if (body && typeof body === 'object') {
message = body?.error?.message || body?.error || body?.message || '';
} else if (typeof body === 'string') {
message = body;
}
if (!message && bodyText) {
message = bodyText;
}
if (status && message) return `${status} ${message}`.trim();
if (status) return `HTTP ${status}`;
return message || 'Request failed';
};
export const apiCallApi = {
request: async (
payload: ApiCallRequest,
config?: AxiosRequestConfig
): Promise<ApiCallResult> => {
const response = await apiClient.post('/api-call', payload, config);
const statusCode = Number(response?.status_code ?? response?.statusCode ?? 0);
const header = (response?.header ?? response?.headers ?? {}) as Record<string, string[]>;
const { bodyText, body } = normalizeBody(response?.body);
return {
statusCode,
header,
bodyText,
body
};
}
};

View File

@@ -62,12 +62,37 @@ class ApiClient {
return `${normalized}${MANAGEMENT_API_PREFIX}`; return `${normalized}${MANAGEMENT_API_PREFIX}`;
} }
private readHeader(headers: Record<string, any>, keys: string[]): string | null { private readHeader(headers: Record<string, any> | undefined, keys: string[]): string | null {
if (!headers) return null;
const normalizeValue = (value: unknown): string | null => {
if (value === undefined || value === null) return null;
if (Array.isArray(value)) {
const first = value.find((entry) => entry !== undefined && entry !== null && String(entry).trim());
return first !== undefined ? String(first) : null;
}
const text = String(value);
return text ? text : null;
};
const headerGetter = (headers as { get?: (name: string) => any }).get;
if (typeof headerGetter === 'function') {
for (const key of keys) {
const match = normalizeValue(headerGetter.call(headers, key));
if (match) return match;
}
}
const entries =
typeof (headers as { entries?: () => Iterable<[string, any]> }).entries === 'function'
? Array.from((headers as { entries: () => Iterable<[string, any]> }).entries())
: Object.entries(headers);
const normalized = Object.fromEntries( const normalized = Object.fromEntries(
Object.entries(headers || {}).map(([key, value]) => [key.toLowerCase(), value as string | undefined]) entries.map(([key, value]) => [String(key).toLowerCase(), value])
); );
for (const key of keys) { for (const key of keys) {
const match = normalized[key.toLowerCase()]; const match = normalizeValue(normalized[key.toLowerCase()]);
if (match) return match; if (match) return match;
} }
return null; return null;
@@ -82,6 +107,10 @@ class ApiClient {
(config) => { (config) => {
// 设置 baseURL // 设置 baseURL
config.baseURL = this.apiBase; 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) { if (this.managementKey) {

View File

@@ -1,4 +1,5 @@
export * from './client'; export * from './client';
export * from './apiCall';
export * from './config'; export * from './config';
export * from './configFile'; export * from './configFile';
export * from './apiKeys'; export * from './apiKeys';
@@ -11,3 +12,4 @@ export * from './logs';
export * from './version'; export * from './version';
export * from './models'; export * from './models';
export * from './transformers'; export * from './transformers';
export * from './vertex';

View File

@@ -3,6 +3,7 @@
*/ */
import { apiClient } from './client'; import { apiClient } from './client';
import { LOGS_TIMEOUT_MS } from '@/utils/constants';
export interface LogsQuery { export interface LogsQuery {
after?: number; after?: number;
@@ -25,14 +26,23 @@ export interface ErrorLogsResponse {
} }
export const logsApi = { export const logsApi = {
fetchLogs: (params: LogsQuery = {}): Promise<LogsResponse> => apiClient.get('/logs', { params }), fetchLogs: (params: LogsQuery = {}): Promise<LogsResponse> =>
apiClient.get('/logs', { params, timeout: LOGS_TIMEOUT_MS }),
clearLogs: () => apiClient.delete('/logs'), clearLogs: () => apiClient.delete('/logs'),
fetchErrorLogs: (): Promise<ErrorLogsResponse> => apiClient.get('/request-error-logs'), fetchErrorLogs: (): Promise<ErrorLogsResponse> =>
apiClient.get('/request-error-logs', { timeout: LOGS_TIMEOUT_MS }),
downloadErrorLog: (filename: string) => downloadErrorLog: (filename: string) =>
apiClient.getRaw(`/request-error-logs/${encodeURIComponent(filename)}`, { apiClient.getRaw(`/request-error-logs/${encodeURIComponent(filename)}`, {
responseType: 'blob', responseType: 'blob',
timeout: LOGS_TIMEOUT_MS
}),
downloadRequestLogById: (id: string) =>
apiClient.getRaw(`/request-log-by-id/${encodeURIComponent(id)}`, {
responseType: 'blob',
timeout: LOGS_TIMEOUT_MS
}), }),
}; };

View File

@@ -4,6 +4,7 @@
import axios from 'axios'; import axios from 'axios';
import { normalizeModelList } from '@/utils/models'; import { normalizeModelList } from '@/utils/models';
import { apiCallApi, getApiCallErrorMessage } from './apiCall';
const normalizeBaseUrl = (baseUrl: string): string => { const normalizeBaseUrl = (baseUrl: string): string => {
let normalized = String(baseUrl || '').trim(); let normalized = String(baseUrl || '').trim();
@@ -39,5 +40,35 @@ export const modelsApi = {
}); });
const payload = response.data?.data ?? response.data?.models ?? response.data; const payload = response.data?.data ?? response.data?.models ?? response.data;
return normalizeModelList(payload, { dedupe: true }); return normalizeModelList(payload, { dedupe: true });
},
async fetchModelsViaApiCall(
baseUrl: string,
apiKey?: string,
headers: Record<string, string> = {}
) {
const endpoint = buildModelsEndpoint(baseUrl);
if (!endpoint) {
throw new Error('Invalid base url');
}
const resolvedHeaders = { ...headers };
const hasAuthHeader = Boolean(resolvedHeaders.Authorization || resolvedHeaders.authorization);
if (apiKey && !hasAuthHeader) {
resolvedHeaders.Authorization = `Bearer ${apiKey}`;
}
const result = await apiCallApi.request({
method: 'GET',
url: endpoint,
header: Object.keys(resolvedHeaders).length ? resolvedHeaders : undefined
});
if (result.statusCode < 200 || result.statusCode >= 300) {
throw new Error(getApiCallErrorMessage(result));
}
const payload = result.body ?? result.bodyText;
return normalizeModelList(payload, { dedupe: true });
} }
}; };

View File

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

View File

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

View File

@@ -70,6 +70,12 @@ const normalizeExcludedModels = (input: any): string[] => {
return normalized; 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 => { const normalizeApiKeyEntry = (entry: any): ApiKeyEntry | null => {
if (!entry) return null; if (!entry) return null;
const apiKey = entry['api-key'] ?? entry.apiKey ?? entry.key ?? (typeof entry === 'string' ? entry : ''); 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; if (!trimmed) return null;
const config: ProviderKeyConfig = { apiKey: trimmed }; 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 baseUrl = item['base-url'] ?? item.baseUrl;
const proxyUrl = item['proxy-url'] ?? item.proxyUrl; const proxyUrl = item['proxy-url'] ?? item.proxyUrl;
if (baseUrl) config.baseUrl = String(baseUrl); if (baseUrl) config.baseUrl = String(baseUrl);
@@ -118,6 +126,8 @@ const normalizeGeminiKeyConfig = (item: any): GeminiKeyConfig | null => {
if (!trimmed) return null; if (!trimmed) return null;
const config: GeminiKeyConfig = { apiKey: trimmed }; 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']; const baseUrl = item['base-url'] ?? item.baseUrl ?? item['base_url'];
if (baseUrl) config.baseUrl = String(baseUrl); if (baseUrl) config.baseUrl = String(baseUrl);
const headers = normalizeHeaders(item.headers); const headers = normalizeHeaders(item.headers);
@@ -155,6 +165,8 @@ const normalizeOpenAIProvider = (provider: any): OpenAIProviderConfig | null =>
apiKeyEntries apiKeyEntries
}; };
const prefix = normalizePrefix(provider.prefix ?? provider['prefix']);
if (prefix) result.prefix = prefix;
if (headers) result.headers = headers; if (headers) result.headers = headers;
if (models.length) result.models = models; if (models.length) result.models = models;
if (priority !== undefined) result.priority = Number(priority); 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']; const upstreamApiKey = source['upstream-api-key'] ?? source.upstreamApiKey ?? source['upstream_api_key'];
if (upstreamApiKey) config.upstreamApiKey = String(upstreamApiKey); 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( const forceModelMappings = normalizeBoolean(
source['force-model-mappings'] ?? source.forceModelMappings ?? source['force_model_mappings'] source['force-model-mappings'] ?? source.forceModelMappings ?? source['force_model_mappings']
); );

View File

@@ -7,12 +7,38 @@ import { computeKeyStats, KeyStats } from '@/utils/usage';
const USAGE_TIMEOUT_MS = 60 * 1000; const USAGE_TIMEOUT_MS = 60 * 1000;
export interface UsageExportPayload {
version?: number;
exported_at?: string;
usage?: Record<string, unknown>;
[key: string]: unknown;
}
export interface UsageImportResponse {
added?: number;
skipped?: number;
total_requests?: number;
failed_requests?: number;
[key: string]: unknown;
}
export const usageApi = { export const usageApi = {
/** /**
* 获取使用统计原始数据 * 获取使用统计原始数据
*/ */
getUsage: () => apiClient.get('/usage', { timeout: USAGE_TIMEOUT_MS }), getUsage: () => apiClient.get('/usage', { timeout: USAGE_TIMEOUT_MS }),
/**
* 导出使用统计快照
*/
exportUsage: () => apiClient.get<UsageExportPayload>('/usage/export', { timeout: USAGE_TIMEOUT_MS }),
/**
* 导入使用统计快照
*/
importUsage: (payload: unknown) =>
apiClient.post<UsageImportResponse>('/usage/import', payload, { timeout: USAGE_TIMEOUT_MS }),
/** /**
* 计算密钥成功/失败统计,必要时会先获取 usage 数据 * 计算密钥成功/失败统计,必要时会先获取 usage 数据
*/ */

View File

@@ -0,0 +1,25 @@
/**
* Vertex credential import API
*/
import { apiClient } from './client';
export interface VertexImportResponse {
status: 'ok';
project_id?: string;
email?: string;
location?: string;
'auth-file'?: string;
auth_file?: string;
}
export const vertexApi = {
importCredential: (file: File, location?: string) => {
const formData = new FormData();
formData.append('file', file);
if (location) {
formData.append('location', location);
}
return apiClient.postForm<VertexImportResponse>('/vertex/import', formData);
}
};

View File

@@ -8,3 +8,4 @@ export { useLanguageStore } from './useLanguageStore';
export { useAuthStore } from './useAuthStore'; export { useAuthStore } from './useAuthStore';
export { useConfigStore } from './useConfigStore'; export { useConfigStore } from './useConfigStore';
export { useModelsStore } from './useModelsStore'; export { useModelsStore } from './useModelsStore';
export { useQuotaStore } from './useQuotaStore';

View File

@@ -34,6 +34,7 @@ export const useAuthStore = create<AuthStoreState>()(
isAuthenticated: false, isAuthenticated: false,
apiBase: '', apiBase: '',
managementKey: '', managementKey: '',
rememberPassword: false,
serverVersion: null, serverVersion: null,
serverBuildDate: null, serverBuildDate: null,
connectionStatus: 'disconnected', connectionStatus: 'disconnected',
@@ -52,16 +53,25 @@ export const useAuthStore = create<AuthStoreState>()(
secureStorage.getItem<string>('apiUrl', { encrypt: true }); secureStorage.getItem<string>('apiUrl', { encrypt: true });
const legacyKey = secureStorage.getItem<string>('managementKey'); const legacyKey = secureStorage.getItem<string>('managementKey');
const { apiBase, managementKey } = get(); const { apiBase, managementKey, rememberPassword } = get();
const resolvedBase = normalizeApiBase(apiBase || legacyBase || detectApiBaseFromLocation()); const resolvedBase = normalizeApiBase(apiBase || legacyBase || detectApiBaseFromLocation());
const resolvedKey = managementKey || legacyKey || ''; const resolvedKey = managementKey || legacyKey || '';
const resolvedRememberPassword = rememberPassword || Boolean(managementKey) || Boolean(legacyKey);
set({ apiBase: resolvedBase, managementKey: resolvedKey }); set({
apiBase: resolvedBase,
managementKey: resolvedKey,
rememberPassword: resolvedRememberPassword
});
apiClient.setConfig({ apiBase: resolvedBase, managementKey: resolvedKey }); apiClient.setConfig({ apiBase: resolvedBase, managementKey: resolvedKey });
if (wasLoggedIn && resolvedBase && resolvedKey) { if (wasLoggedIn && resolvedBase && resolvedKey) {
try { try {
await get().login({ apiBase: resolvedBase, managementKey: resolvedKey }); await get().login({
apiBase: resolvedBase,
managementKey: resolvedKey,
rememberPassword: resolvedRememberPassword
});
return true; return true;
} catch (error) { } catch (error) {
console.warn('Auto login failed:', error); console.warn('Auto login failed:', error);
@@ -79,6 +89,7 @@ export const useAuthStore = create<AuthStoreState>()(
login: async (credentials) => { login: async (credentials) => {
const apiBase = normalizeApiBase(credentials.apiBase); const apiBase = normalizeApiBase(credentials.apiBase);
const managementKey = credentials.managementKey.trim(); const managementKey = credentials.managementKey.trim();
const rememberPassword = credentials.rememberPassword ?? get().rememberPassword ?? false;
try { try {
set({ connectionStatus: 'connecting' }); set({ connectionStatus: 'connecting' });
@@ -97,10 +108,15 @@ export const useAuthStore = create<AuthStoreState>()(
isAuthenticated: true, isAuthenticated: true,
apiBase, apiBase,
managementKey, managementKey,
rememberPassword,
connectionStatus: 'connected', connectionStatus: 'connected',
connectionError: null connectionError: null
}); });
if (rememberPassword) {
localStorage.setItem('isLoggedIn', 'true'); localStorage.setItem('isLoggedIn', 'true');
} else {
localStorage.removeItem('isLoggedIn');
}
} catch (error: any) { } catch (error: any) {
set({ set({
connectionStatus: 'error', connectionStatus: 'error',
@@ -185,7 +201,8 @@ export const useAuthStore = create<AuthStoreState>()(
})), })),
partialize: (state) => ({ partialize: (state) => ({
apiBase: state.apiBase, apiBase: state.apiBase,
managementKey: state.managementKey, ...(state.rememberPassword ? { managementKey: state.managementKey } : {}),
rememberPassword: state.rememberPassword,
serverVersion: state.serverVersion, serverVersion: state.serverVersion,
serverBuildDate: state.serverBuildDate serverBuildDate: state.serverBuildDate
}) })

View File

@@ -8,6 +8,7 @@ import { persist } from 'zustand/middleware';
import type { Language } from '@/types'; import type { Language } from '@/types';
import { STORAGE_KEY_LANGUAGE } from '@/utils/constants'; import { STORAGE_KEY_LANGUAGE } from '@/utils/constants';
import i18n from '@/i18n'; import i18n from '@/i18n';
import { getInitialLanguage } from '@/utils/language';
interface LanguageState { interface LanguageState {
language: Language; language: Language;
@@ -18,7 +19,7 @@ interface LanguageState {
export const useLanguageStore = create<LanguageState>()( export const useLanguageStore = create<LanguageState>()(
persist( persist(
(set, get) => ({ (set, get) => ({
language: 'zh-CN', language: getInitialLanguage(),
setLanguage: (language) => { setLanguage: (language) => {
// 切换 i18next 语言 // 切换 i18next 语言

View File

@@ -0,0 +1,49 @@
/**
* Quota cache that survives route switches.
*/
import { create } from 'zustand';
import type { AntigravityQuotaState, CodexQuotaState, GeminiCliQuotaState } from '@/types';
type QuotaUpdater<T> = T | ((prev: T) => T);
interface QuotaStoreState {
antigravityQuota: Record<string, AntigravityQuotaState>;
codexQuota: Record<string, CodexQuotaState>;
geminiCliQuota: Record<string, GeminiCliQuotaState>;
setAntigravityQuota: (updater: QuotaUpdater<Record<string, AntigravityQuotaState>>) => void;
setCodexQuota: (updater: QuotaUpdater<Record<string, CodexQuotaState>>) => void;
setGeminiCliQuota: (updater: QuotaUpdater<Record<string, GeminiCliQuotaState>>) => void;
clearQuotaCache: () => void;
}
const resolveUpdater = <T,>(updater: QuotaUpdater<T>, prev: T): T => {
if (typeof updater === 'function') {
return (updater as (value: T) => T)(prev);
}
return updater;
};
export const useQuotaStore = create<QuotaStoreState>((set) => ({
antigravityQuota: {},
codexQuota: {},
geminiCliQuota: {},
setAntigravityQuota: (updater) =>
set((state) => ({
antigravityQuota: resolveUpdater(updater, state.antigravityQuota)
})),
setCodexQuota: (updater) =>
set((state) => ({
codexQuota: resolveUpdater(updater, state.codexQuota)
})),
setGeminiCliQuota: (updater) =>
set((state) => ({
geminiCliQuota: resolveUpdater(updater, state.geminiCliQuota)
})),
clearQuotaCache: () =>
set({
antigravityQuota: {},
codexQuota: {},
geminiCliQuota: {}
})
}));

View File

@@ -8,63 +8,79 @@ import { persist } from 'zustand/middleware';
import type { Theme } from '@/types'; import type { Theme } from '@/types';
import { STORAGE_KEY_THEME } from '@/utils/constants'; import { STORAGE_KEY_THEME } from '@/utils/constants';
type ResolvedTheme = 'light' | 'dark';
interface ThemeState { interface ThemeState {
theme: Theme; theme: Theme;
resolvedTheme: ResolvedTheme;
setTheme: (theme: Theme) => void; setTheme: (theme: Theme) => void;
toggleTheme: () => void; cycleTheme: () => void;
initializeTheme: () => void; initializeTheme: () => () => void;
} }
export const useThemeStore = create<ThemeState>()( const getSystemTheme = (): ResolvedTheme => {
persist( if (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) {
(set, get) => ({ return 'dark';
theme: 'light', }
return 'light';
};
setTheme: (theme) => { const applyTheme = (resolved: ResolvedTheme) => {
// 应用主题到 DOM if (resolved === 'dark') {
if (theme === 'dark') {
document.documentElement.setAttribute('data-theme', 'dark'); document.documentElement.setAttribute('data-theme', 'dark');
} else { } else {
document.documentElement.removeAttribute('data-theme'); document.documentElement.removeAttribute('data-theme');
} }
};
set({ theme }); export const useThemeStore = create<ThemeState>()(
persist(
(set, get) => ({
theme: 'auto',
resolvedTheme: 'light',
setTheme: (theme) => {
const resolved: ResolvedTheme = theme === 'auto' ? getSystemTheme() : theme;
applyTheme(resolved);
set({ theme, resolvedTheme: resolved });
}, },
toggleTheme: () => { cycleTheme: () => {
const { theme, setTheme } = get(); const { theme, setTheme } = get();
const newTheme: Theme = theme === 'light' ? 'dark' : 'light'; const order: Theme[] = ['light', 'dark', 'auto'];
setTheme(newTheme); const currentIndex = order.indexOf(theme);
const nextTheme = order[(currentIndex + 1) % order.length];
setTheme(nextTheme);
}, },
initializeTheme: () => { initializeTheme: () => {
const { theme, setTheme } = get(); const { theme, setTheme } = get();
// 检查系统偏好
if (
!localStorage.getItem(STORAGE_KEY_THEME) &&
window.matchMedia &&
window.matchMedia('(prefers-color-scheme: dark)').matches
) {
setTheme('dark');
return;
}
// 应用已保存的主题 // 应用已保存的主题
setTheme(theme); setTheme(theme);
// 监听系统主题变化(仅在用户未手动设置时 // 监听系统主题变化(仅在 auto 模式下生效
if (window.matchMedia) { if (!window.matchMedia) {
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', (e) => { return () => {};
if (!localStorage.getItem(STORAGE_KEY_THEME)) {
setTheme(e.matches ? 'dark' : 'light');
}
});
} }
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
const listener = () => {
const { theme: currentTheme } = get();
if (currentTheme === 'auto') {
const resolved = getSystemTheme();
applyTheme(resolved);
set({ resolvedTheme: resolved });
} }
};
mediaQuery.addEventListener('change', listener);
return () => mediaQuery.removeEventListener('change', listener);
},
}), }),
{ {
name: STORAGE_KEY_THEME name: STORAGE_KEY_THEME,
} }
) )
); );

View File

@@ -308,6 +308,12 @@ textarea {
} }
} }
.switch-label-left {
.label {
order: -1;
}
}
.pill { .pill {
padding: 4px 10px; padding: 4px 10px;
border-radius: $radius-full; border-radius: $radius-full;
@@ -344,6 +350,32 @@ textarea {
justify-content: center; justify-content: center;
z-index: $z-modal; z-index: $z-modal;
padding: $spacing-lg; padding: $spacing-lg;
&.modal-overlay-entering {
animation: modal-overlay-fade-in 0.25s ease-out forwards;
}
&.modal-overlay-closing {
animation: modal-overlay-fade-out 0.35s ease-in forwards;
}
}
@keyframes modal-overlay-fade-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes modal-overlay-fade-out {
from {
opacity: 1;
}
to {
opacity: 0;
}
} }
.modal { .modal {
@@ -355,34 +387,58 @@ textarea {
overflow: hidden; overflow: hidden;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
} position: relative;
// 关闭按钮中心位置: right 12px + 16px = 28px, top 12px + 16px = 28px
transform-origin: calc(100% - 28px) 28px;
.modal-header { &.modal-entering {
display: flex; animation: modal-scale-in 0.3s cubic-bezier(0.34, 1.56, 0.64, 1) forwards;
align-items: center;
justify-content: space-between;
padding: $spacing-md $spacing-lg;
border-bottom: 1px solid var(--border-color);
.modal-title {
font-weight: 700;
font-size: 18px;
color: var(--text-primary);
} }
.modal-close { &.modal-closing {
display: inline-flex; animation: modal-collapse-to-close 0.35s cubic-bezier(0.4, 0, 0.2, 1) forwards;
align-items: center; }
justify-content: center; }
@keyframes modal-scale-in {
from {
opacity: 0;
transform: scale(0.85) translateY(20px);
}
to {
opacity: 1;
transform: scale(1) translateY(0);
}
}
@keyframes modal-collapse-to-close {
0% {
opacity: 1;
transform: scale(1);
}
100% {
opacity: 0;
transform: scale(0);
}
}
.modal-close-floating {
position: absolute;
top: 12px;
right: 12px;
width: 32px; width: 32px;
height: 32px; height: 32px;
padding: 0; padding: 0;
background: transparent; background: var(--bg-secondary);
border: none; border: 1px solid var(--border-color);
color: var(--text-secondary); color: var(--text-secondary);
cursor: pointer; cursor: pointer;
border-radius: $radius-md; border-radius: $radius-full;
transition: color 0.15s ease, background-color 0.15s ease; display: inline-flex;
align-items: center;
justify-content: center;
transition: color 0.15s ease, background-color 0.15s ease, transform 0.15s ease;
z-index: 10;
svg { svg {
display: block; display: block;
@@ -390,8 +446,25 @@ textarea {
&:hover { &:hover {
color: var(--text-primary); color: var(--text-primary);
background: var(--bg-secondary); background: var(--bg-tertiary);
transform: scale(1.1);
} }
&:active {
transform: scale(0.95);
}
}
.modal-header {
display: flex;
align-items: center;
padding: $spacing-md $spacing-lg;
border-bottom: 1px solid var(--border-color);
.modal-title {
font-weight: 700;
font-size: 18px;
color: var(--text-primary);
} }
} }
@@ -410,6 +483,17 @@ textarea {
background: var(--bg-primary); 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 { .empty-state {
border: 1px dashed var(--border-color); border: 1px dashed var(--border-color);
border-radius: $radius-lg; border-radius: $radius-lg;

View File

@@ -12,6 +12,13 @@
overflow: hidden; overflow: hidden;
background: var(--bg-secondary); background: var(--bg-secondary);
color: var(--text-primary); color: var(--text-primary);
@media (max-width: $breakpoint-mobile) {
height: auto;
min-height: 100vh;
overflow: visible;
overflow-y: auto;
}
} }
.main-header { .main-header {
@@ -28,6 +35,9 @@
width: 100%; width: 100%;
@media (max-width: $breakpoint-mobile) { @media (max-width: $breakpoint-mobile) {
position: fixed;
left: 0;
right: 0;
padding: $spacing-sm $spacing-md; padding: $spacing-sm $spacing-md;
gap: $spacing-sm; gap: $spacing-sm;
} }
@@ -230,6 +240,17 @@
@supports (height: 100dvh) { @supports (height: 100dvh) {
height: calc(100dvh - var(--header-height)); height: calc(100dvh - var(--header-height));
} }
@media (max-width: $breakpoint-mobile) {
height: auto;
min-height: calc(100vh - var(--header-height));
overflow: visible;
padding-top: var(--header-height);
@supports (min-height: 100dvh) {
min-height: calc(100dvh - var(--header-height));
}
}
} }
.sidebar { .sidebar {
@@ -328,14 +349,37 @@
min-width: 0; min-width: 0;
overflow-y: auto; overflow-y: auto;
height: 100%; height: 100%;
&.content-logs {
overflow: hidden;
@media (max-width: $breakpoint-mobile) {
overflow: visible;
overflow-y: auto;
height: auto;
}
}
} }
.main-content { .main-content {
flex: 1; flex: 1 0 auto;
padding: $spacing-lg; padding: $spacing-lg;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: $spacing-lg; gap: $spacing-lg;
overflow-x: hidden;
&.main-content-logs {
flex: 1 1 auto;
min-height: 0;
overflow: hidden;
@media (max-width: $breakpoint-mobile) {
flex: 0 0 auto;
min-height: auto;
overflow: visible;
}
}
@media (max-width: $breakpoint-mobile) { @media (max-width: $breakpoint-mobile) {
padding: $spacing-md; padding: $spacing-md;
@@ -353,6 +397,13 @@
font-size: 14px; font-size: 14px;
flex-wrap: wrap; flex-wrap: wrap;
gap: $spacing-sm; gap: $spacing-sm;
.footer-version {
user-select: none;
-webkit-user-select: none;
-ms-user-select: none;
-webkit-touch-callout: none;
}
} }
.login-page { .login-page {
@@ -380,6 +431,7 @@
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: $spacing-sm; gap: $spacing-sm;
text-align: center;
.title { .title {
font-size: 22px; font-size: 22px;
@@ -392,6 +444,18 @@
} }
} }
.login-title-row {
display: flex;
align-items: center;
justify-content: center;
gap: $spacing-sm;
flex-wrap: wrap;
}
.login-language-btn {
white-space: nowrap;
}
.connection-box { .connection-box {
background: var(--bg-secondary); background: var(--bg-secondary);
border: 1px dashed var(--border-color); border: 1px dashed var(--border-color);

View File

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

View File

@@ -7,6 +7,7 @@
export interface LoginCredentials { export interface LoginCredentials {
apiBase: string; apiBase: string;
managementKey: string; managementKey: string;
rememberPassword?: boolean;
} }
// 认证状态 // 认证状态
@@ -14,6 +15,7 @@ export interface AuthState {
isAuthenticated: boolean; isAuthenticated: boolean;
apiBase: string; apiBase: string;
managementKey: string; managementKey: string;
rememberPassword: boolean;
serverVersion: string | null; serverVersion: string | null;
serverBuildDate: string | null; serverBuildDate: string | null;
} }

View File

@@ -2,7 +2,7 @@
* 通用类型定义 * 通用类型定义
*/ */
export type Theme = 'light' | 'dark'; export type Theme = 'light' | 'dark' | 'auto';
export type Language = 'zh-CN' | 'en'; export type Language = 'zh-CN' | 'en';

View File

@@ -12,3 +12,4 @@ export * from './authFile';
export * from './oauth'; export * from './oauth';
export * from './usage'; export * from './usage';
export * from './log'; export * from './log';
export * from './quota';

View File

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

50
src/types/quota.ts Normal file
View File

@@ -0,0 +1,50 @@
/**
* Quota management types.
*/
export interface AntigravityQuotaGroup {
id: string;
label: string;
models: string[];
remainingFraction: number;
resetTime?: string;
}
export interface AntigravityQuotaState {
status: 'idle' | 'loading' | 'success' | 'error';
groups: AntigravityQuotaGroup[];
error?: string;
errorStatus?: number;
}
export interface GeminiCliQuotaBucketState {
id: string;
label: string;
remainingFraction: number | null;
remainingAmount: number | null;
resetTime: string | undefined;
tokenType: string | null;
modelIds?: string[];
}
export interface GeminiCliQuotaState {
status: 'idle' | 'loading' | 'success' | 'error';
buckets: GeminiCliQuotaBucketState[];
error?: string;
errorStatus?: number;
}
export interface CodexQuotaWindow {
id: string;
label: string;
usedPercent: number | null;
resetLabel: string;
}
export interface CodexQuotaState {
status: 'idle' | 'loading' | 'success' | 'error';
windows: CodexQuotaWindow[];
planType?: string | null;
error?: string;
errorStatus?: number;
}

View File

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

42
src/utils/language.ts Normal file
View File

@@ -0,0 +1,42 @@
import type { Language } from '@/types';
import { STORAGE_KEY_LANGUAGE } from '@/utils/constants';
const parseStoredLanguage = (value: string): Language | null => {
try {
const parsed = JSON.parse(value);
const candidate = parsed?.state?.language ?? parsed?.language ?? parsed;
if (candidate === 'zh-CN' || candidate === 'en') {
return candidate;
}
} catch {
if (value === 'zh-CN' || value === 'en') {
return value;
}
}
return null;
};
const getStoredLanguage = (): Language | null => {
if (typeof window === 'undefined') {
return null;
}
try {
const stored = localStorage.getItem(STORAGE_KEY_LANGUAGE);
if (!stored) {
return null;
}
return parseStoredLanguage(stored);
} catch {
return null;
}
};
const getBrowserLanguage = (): Language => {
if (typeof navigator === 'undefined') {
return 'zh-CN';
}
const raw = navigator.languages?.[0] || navigator.language || 'zh-CN';
return raw.toLowerCase().startsWith('zh') ? 'zh-CN' : 'en';
};
export const getInitialLanguage = (): Language => getStoredLanguage() ?? getBrowserLanguage();

View File

@@ -754,6 +754,103 @@ export function buildChartData(
/** /**
* 依据 usage 数据计算密钥使用统计 * 依据 usage 数据计算密钥使用统计
*/ */
/**
* 状态栏单个格子的状态
*/
export type StatusBlockState = 'success' | 'failure' | 'mixed' | 'idle';
/**
* 状态栏数据
*/
export interface StatusBarData {
blocks: StatusBlockState[];
successRate: number;
totalSuccess: number;
totalFailure: number;
}
/**
* 计算状态栏数据最近1小时分为20个5分钟的时间块
* 注意20个块 × 5分钟 = 100分钟但我们只使用最近60分钟的数据
* 所以实际只有最后12个块可能有数据前8个块将始终为 idle
*/
export function calculateStatusBarData(
usageDetails: UsageDetail[],
sourceFilter?: string,
authIndexFilter?: number
): StatusBarData {
const BLOCK_COUNT = 20;
const BLOCK_DURATION_MS = 5 * 60 * 1000; // 5 minutes
const HOUR_MS = 60 * 60 * 1000;
const now = Date.now();
const hourAgo = now - HOUR_MS;
// Initialize blocks
const blockStats: Array<{ success: number; failure: number }> = Array.from(
{ length: BLOCK_COUNT },
() => ({ success: 0, failure: 0 })
);
let totalSuccess = 0;
let totalFailure = 0;
// Filter and bucket the usage details
usageDetails.forEach((detail) => {
const timestamp = Date.parse(detail.timestamp);
if (Number.isNaN(timestamp) || timestamp < hourAgo || timestamp > now) {
return;
}
// Apply filters if provided
if (sourceFilter !== undefined && detail.source !== sourceFilter) {
return;
}
if (authIndexFilter !== undefined && detail.auth_index !== authIndexFilter) {
return;
}
// Calculate which block this falls into (0 = oldest, 19 = newest)
const ageMs = now - timestamp;
const blockIndex = BLOCK_COUNT - 1 - Math.floor(ageMs / BLOCK_DURATION_MS);
if (blockIndex >= 0 && blockIndex < BLOCK_COUNT) {
if (detail.failed) {
blockStats[blockIndex].failure += 1;
totalFailure += 1;
} else {
blockStats[blockIndex].success += 1;
totalSuccess += 1;
}
}
});
// Convert stats to block states
const blocks: StatusBlockState[] = blockStats.map((stat) => {
if (stat.success === 0 && stat.failure === 0) {
return 'idle';
}
if (stat.failure === 0) {
return 'success';
}
if (stat.success === 0) {
return 'failure';
}
return 'mixed';
});
// Calculate success rate
const total = totalSuccess + totalFailure;
const successRate = total > 0 ? (totalSuccess / total) * 100 : 100;
return {
blocks,
successRate,
totalSuccess,
totalFailure
};
}
export function computeKeyStats(usageData: any, masker: (val: string) => string = maskApiKey): KeyStats { export function computeKeyStats(usageData: any, masker: (val: string) => string = maskApiKey): KeyStats {
if (!usageData) { if (!usageData) {
return { bySource: {}, byAuthIndex: {} }; return { bySource: {}, byAuthIndex: {} };

1
src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
/// <reference types="vite/client" />