Compare commits

...

98 Commits

Author SHA1 Message Date
LTbinglingfeng
2ae06a8860 perf(ui): smooth gsap page transitions 2026-01-02 20:26:41 +08:00
LTbinglingfeng
dc58a0701f fix(logs): parse latency durations with minutes 2026-01-02 20:11:16 +08:00
Supra4E8C
3446280987 refactor(quota,auth): change page size selector to number input with range 3-30 2026-01-02 17:27:35 +08:00
Supra4E8C
82bf1806ed refactor(quota): consolidate quota sections into config-driven components 2026-01-02 17:14:40 +08:00
Supra4E8C
47f0042bf0 refactor(usage): modularize UsagePage into separate section components 2026-01-02 16:19:04 +08:00
Supra4E8C
58154063ed refactor(quota): modularize QuotaPage into separate section components 2026-01-02 15:55:17 +08:00
Supra4E8C
cc467889d0 refactor(providers): modularize AiProvidersPage into separate provider components 2026-01-02 15:29:16 +08:00
Supra4E8C
469e5d2ed4 Merge branch 'dev' 2026-01-02 14:18:56 +08:00
LTbinglingfeng
6ce301d7e0 fix(transition): avoid HiDPI text blur from page transitions 2026-01-02 11:17:57 +08:00
LTbinglingfeng
8461de124f Merge branch 'dev' 2026-01-02 02:04:17 +08:00
Supra4E8C
276f416ec9 Merge pull request #41 from yanyuhualb/feature/make-project-id-optional
feat: make Gemini CLI project ID optional
2026-01-02 02:03:04 +08:00
LTbinglingfeng
583a844771 Merge branch 'dev' 2026-01-02 01:58:06 +08:00
LTbinglingfeng
62fa437285 fix(ui): use resolvedTheme for OpenAI icon switching in providers page 2026-01-02 01:54:22 +08:00
LTbinglingfeng
daab589c49 fix(ui): remove redundant add button from empty state in providers list 2026-01-02 01:42:43 +08:00
LTbinglingfeng
e18e9b25ce fix(transition): allow page container to shrink for proper layout 2026-01-02 01:37:17 +08:00
LTbinglingfeng
4cfb77dd44 fix(ui): center modals in viewport and lock background scroll 2026-01-02 01:15:04 +08:00
LTbinglingfeng
7cab1e8782 fix(oauth): remove iFlow provider from OAuth flow 2026-01-02 00:58:24 +08:00
LTbinglingfeng
079f37ec93 fix(quota): translate Codex window labels at render time 2026-01-02 00:41:35 +08:00
LTbinglingfeng
7ce97a616f fix(transition): preserve scroll position during page animations 2026-01-02 00:29:42 +08:00
LTbinglingfeng
946ed36af0 feat(router): add GSAP page transition animations 2026-01-02 00:01:25 +08:00
yanyuhualb
f139598526 feat: make Gemini CLI project ID optional
- Remove required validation for project ID field
- Update translations to indicate field is optional (zh-CN, en)
- Auto-select first available project when left empty
- Backend already supports empty project ID by fetching project list

This improves user experience by eliminating the need to manually enter project ID for users with only one or a preferred default project.
2025-12-31 23:06:44 +08:00
Supra4E8C
40ddd3c066 Merge pull request #40 from router-for-me/dev
feat(auth): add remember-password login and clear local auth data card
2025-12-31 20:48:25 +08:00
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
Supra4E8C
e44beb541f feat 2025-12-18 12:36:17 +08:00
Supra4E8C
aecd5875d6 feat(usage): add loading overlay and 60s API timeout 2025-12-18 01:03:12 +08:00
Supra4E8C
ec4b5ab46a feat: add quick links section to System page 2025-12-17 18:16:59 +08:00
Supra4E8C
cd6c142324 fix: fix log page timestamp display and optimize AuthFiles layout
- Add formatUnixTimestamp utility to auto-detect timestamp precision (s/ms/μs/ns)
  - Fix incorrect file modification time display in logs page
  - Remove fixed height constraint from AuthFilesPage model list
2025-12-17 18:03:25 +08:00
129 changed files with 12368 additions and 4398 deletions

View File

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

6
.gitignore vendored
View File

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

216
README.md
View File

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

View File

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

View File

@@ -1,5 +1,5 @@
<!doctype html>
<html lang="en">
<html lang="zh-CN">
<head>
<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" />

7
package-lock.json generated
View File

@@ -12,6 +12,7 @@
"@uiw/react-codemirror": "^4.25.3",
"axios": "^1.13.2",
"chart.js": "^4.5.1",
"gsap": "^3.14.2",
"i18next": "^25.7.1",
"react": "^19.2.1",
"react-chartjs-2": "^5.3.1",
@@ -3194,6 +3195,12 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/gsap": {
"version": "3.14.2",
"resolved": "https://registry.npmjs.org/gsap/-/gsap-3.14.2.tgz",
"integrity": "sha512-P8/mMxVLU7o4+55+1TCnQrPmgjPKnwkzkXOK1asnR9Jg2lna4tEY5qBJjMmAaOBDDZWtlRjBXjLa0w53G/uBLA==",
"license": "Standard 'no charge' license: https://gsap.com/standard-license."
},
"node_modules/has-flag": {
"version": "4.0.0",
"dev": true,

View File

@@ -16,6 +16,7 @@
"@uiw/react-codemirror": "^4.25.3",
"axios": "^1.13.2",
"chart.js": "^4.5.1",
"gsap": "^3.14.2",
"i18next": "^25.7.1",
"react": "^19.2.1",
"react-chartjs-2": "^5.3.1",

View File

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

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,39 @@
@use '@/styles/variables.scss' as *;
.page-transition {
position: relative;
flex: 1 1 auto;
display: flex;
flex-direction: column;
min-height: 0;
overflow: hidden;
&__layer {
display: flex;
flex-direction: column;
gap: $spacing-lg;
min-height: 0;
flex: 1;
// During animation, exit layer uses absolute positioning
&--exit {
position: absolute;
inset: 0;
z-index: 1;
overflow: hidden;
pointer-events: none;
}
}
&--animating &__layer {
will-change: transform, opacity;
backface-visibility: hidden;
transform-style: preserve-3d;
}
// When both layers exist, current layer also needs positioning
&--animating &__layer:not(&__layer--exit) {
position: relative;
z-index: 0;
}
}

View File

@@ -0,0 +1,171 @@
import { ReactNode, useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react';
import { useLocation, type Location } from 'react-router-dom';
import gsap from 'gsap';
import './PageTransition.scss';
interface PageTransitionProps {
render: (location: Location) => ReactNode;
getRouteOrder?: (pathname: string) => number | null;
scrollContainerRef?: React.RefObject<HTMLElement | null>;
}
const TRANSITION_DURATION = 0.5;
const EXIT_DURATION = 0.45;
const ENTER_DELAY = 0.08;
type LayerStatus = 'current' | 'exiting';
type Layer = {
key: string;
location: Location;
status: LayerStatus;
};
type TransitionDirection = 'forward' | 'backward';
export function PageTransition({
render,
getRouteOrder,
scrollContainerRef,
}: PageTransitionProps) {
const location = useLocation();
const currentLayerRef = useRef<HTMLDivElement>(null);
const exitingLayerRef = useRef<HTMLDivElement>(null);
const exitScrollOffsetRef = useRef(0);
const [isAnimating, setIsAnimating] = useState(false);
const [transitionDirection, setTransitionDirection] = useState<TransitionDirection>('forward');
const [layers, setLayers] = useState<Layer[]>(() => [
{
key: location.key,
location,
status: 'current',
},
]);
const currentLayerKey = layers[layers.length - 1]?.key ?? location.key;
const currentLayerPathname = layers[layers.length - 1]?.location.pathname;
const resolveScrollContainer = useCallback(() => {
if (scrollContainerRef?.current) return scrollContainerRef.current;
if (typeof document === 'undefined') return null;
return document.scrollingElement as HTMLElement | null;
}, [scrollContainerRef]);
useEffect(() => {
if (isAnimating) return;
if (location.key === currentLayerKey) return;
const scrollContainer = resolveScrollContainer();
exitScrollOffsetRef.current = scrollContainer?.scrollTop ?? 0;
const resolveOrderIndex = (pathname?: string) => {
if (!getRouteOrder || !pathname) return null;
const index = getRouteOrder(pathname);
return typeof index === 'number' && index >= 0 ? index : null;
};
const fromIndex = resolveOrderIndex(currentLayerPathname);
const toIndex = resolveOrderIndex(location.pathname);
const nextDirection: TransitionDirection =
fromIndex === null || toIndex === null || fromIndex === toIndex
? 'forward'
: toIndex > fromIndex
? 'forward'
: 'backward';
setTransitionDirection(nextDirection);
setLayers((prev) => {
const prevCurrent = prev[prev.length - 1];
return [
prevCurrent
? { ...prevCurrent, status: 'exiting' }
: { key: location.key, location, status: 'exiting' },
{ key: location.key, location, status: 'current' },
];
});
setIsAnimating(true);
}, [
isAnimating,
location,
currentLayerKey,
currentLayerPathname,
getRouteOrder,
resolveScrollContainer,
]);
// Run GSAP animation when animating starts
useLayoutEffect(() => {
if (!isAnimating) return;
if (!currentLayerRef.current) return;
const scrollContainer = resolveScrollContainer();
const scrollOffset = exitScrollOffsetRef.current;
if (scrollContainer && scrollOffset > 0) {
scrollContainer.scrollTo({ top: 0, left: 0, behavior: 'auto' });
}
const containerHeight = scrollContainer?.clientHeight ?? 0;
const viewportHeight = typeof window === 'undefined' ? 0 : window.innerHeight;
const travelDistance = Math.max(containerHeight, viewportHeight, 1);
const enterFromY = transitionDirection === 'forward' ? travelDistance : -travelDistance;
const exitToY = transitionDirection === 'forward' ? -travelDistance : travelDistance;
const exitBaseY = scrollOffset ? -scrollOffset : 0;
const tl = gsap.timeline({
onComplete: () => {
setLayers((prev) => prev.filter((layer) => layer.status !== 'exiting'));
setIsAnimating(false);
},
});
// Exit animation: fly out to top (slow-to-fast)
if (exitingLayerRef.current) {
gsap.set(exitingLayerRef.current, { y: exitBaseY });
tl.fromTo(
exitingLayerRef.current,
{ y: exitBaseY, opacity: 1 },
{
y: exitBaseY + exitToY,
opacity: 0,
duration: EXIT_DURATION,
ease: 'power2.in', // fast finish to clear screen
force3D: true,
},
0
);
}
// Enter animation: slide in from bottom (slow-to-fast)
tl.fromTo(
currentLayerRef.current,
{ y: enterFromY, opacity: 0 },
{
y: 0,
opacity: 1,
duration: TRANSITION_DURATION,
ease: 'power2.out', // smooth settle
clearProps: 'transform,opacity',
force3D: true,
},
ENTER_DELAY
);
return () => {
tl.kill();
gsap.killTweensOf([currentLayerRef.current, exitingLayerRef.current]);
};
}, [isAnimating, transitionDirection, resolveScrollContainer]);
return (
<div className={`page-transition${isAnimating ? ' page-transition--animating' : ''}`}>
{layers.map((layer) => (
<div
key={layer.key}
className={`page-transition__layer${
layer.status === 'exiting' ? ' page-transition__layer--exit' : ''
}`}
ref={layer.status === 'exiting' ? exitingLayerRef : currentLayerRef}
>
{render(layer.location)}
</div>
))}
</div>
);
}

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,54 @@
import { ReactNode, SVGProps, useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react';
import { NavLink, Outlet } from 'react-router-dom';
import {
ReactNode,
SVGProps,
useCallback,
useEffect,
useLayoutEffect,
useRef,
useState,
} from 'react';
import { NavLink, useLocation } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { Button } from '@/components/ui/Button';
import { Modal } from '@/components/ui/Modal';
import { ToggleSwitch } from '@/components/ui/ToggleSwitch';
import { PageTransition } from '@/components/common/PageTransition';
import { MainRoutes } from '@/router/MainRoutes';
import {
IconBot,
IconChartLine,
IconFileText,
IconInfo,
IconKey,
IconLayoutDashboard,
IconScrollText,
IconSettings,
IconShield,
IconSlidersHorizontal
IconSlidersHorizontal,
IconTimer,
} from '@/components/ui/icons';
import { INLINE_LOGO_JPEG } from '@/assets/logoInline';
import { useAuthStore, useConfigStore, useLanguageStore, useNotificationStore, useThemeStore } from '@/stores';
import { versionApi } from '@/services/api';
import {
useAuthStore,
useConfigStore,
useLanguageStore,
useNotificationStore,
useThemeStore,
} from '@/stores';
import { configApi, versionApi } from '@/services/api';
const sidebarIcons: Record<string, ReactNode> = {
dashboard: <IconLayoutDashboard size={18} />,
settings: <IconSlidersHorizontal size={18} />,
apiKeys: <IconKey size={18} />,
aiProviders: <IconBot size={18} />,
authFiles: <IconFileText size={18} />,
oauth: <IconShield size={18} />,
quota: <IconTimer size={18} />,
usage: <IconChartLine size={18} />,
config: <IconSettings size={18} />,
logs: <IconScrollText size={18} />,
system: <IconInfo size={18} />
system: <IconInfo size={18} />,
};
// Header action icons - smaller size for header buttons
@@ -40,7 +62,7 @@ const headerIconProps: SVGProps<SVGSVGElement> = {
strokeLinecap: 'round',
strokeLinejoin: 'round',
'aria-hidden': 'true',
focusable: 'false'
focusable: 'false',
};
const headerIcons = {
@@ -93,19 +115,38 @@ const headerIcons = {
<path d="m19.07 4.93-1.41 1.41" />
</svg>
),
moon: (
<svg {...headerIconProps}>
<path d="M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9z" />
</svg>
),
logout: (
<svg {...headerIconProps}>
<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="M21 12H9" />
</svg>
)
};
moon: (
<svg {...headerIconProps}>
<path d="M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9z" />
</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: (
<svg {...headerIconProps}>
<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="M21 12H9" />
</svg>
),
};
const parseVersionSegments = (version?: string | null) => {
if (!version) return null;
@@ -136,6 +177,7 @@ const compareVersions = (latest?: string | null, current?: string | null) => {
export function MainLayout() {
const { t, i18n } = useTranslation();
const { showNotification } = useNotificationStore();
const location = useLocation();
const apiBase = useAuthStore((state) => state.apiBase);
const serverVersion = useAuthStore((state) => state.serverVersion);
@@ -146,20 +188,32 @@ export function MainLayout() {
const config = useConfigStore((state) => state.config);
const fetchConfig = useConfigStore((state) => state.fetchConfig);
const clearCache = useConfigStore((state) => state.clearCache);
const updateConfigValue = useConfigStore((state) => state.updateConfigValue);
const theme = useThemeStore((state) => state.theme);
const toggleTheme = useThemeStore((state) => state.toggleTheme);
const cycleTheme = useThemeStore((state) => state.cycleTheme);
const toggleLanguage = useLanguageStore((state) => state.toggleLanguage);
const [sidebarOpen, setSidebarOpen] = useState(false);
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
const [checkingVersion, setCheckingVersion] = useState(false);
const [brandExpanded, setBrandExpanded] = useState(true);
const [requestLogModalOpen, setRequestLogModalOpen] = useState(false);
const [requestLogDraft, setRequestLogDraft] = useState(false);
const [requestLogTouched, setRequestLogTouched] = useState(false);
const [requestLogSaving, setRequestLogSaving] = useState(false);
const contentRef = useRef<HTMLDivElement | null>(null);
const brandCollapseTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const headerRef = useRef<HTMLElement | null>(null);
const versionTapCount = useRef(0);
const versionTapTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const fullBrandName = 'CLI Proxy API Management Center';
const abbrBrandName = t('title.abbr');
const requestLogEnabled = config?.requestLog ?? false;
const requestLogDirty = requestLogDraft !== requestLogEnabled;
const canEditRequestLog = connectionStatus === 'connected' && Boolean(config);
const isLogsPage = location.pathname.startsWith('/logs');
// 将顶栏高度写入 CSS 变量,确保侧栏/内容区计算一致,防止滚动时抖动
useLayoutEffect(() => {
@@ -173,7 +227,9 @@ export function MainLayout() {
updateHeaderHeight();
const resizeObserver =
typeof ResizeObserver !== 'undefined' && headerRef.current ? new ResizeObserver(updateHeaderHeight) : null;
typeof ResizeObserver !== 'undefined' && headerRef.current
? new ResizeObserver(updateHeaderHeight)
: null;
if (resizeObserver && headerRef.current) {
resizeObserver.observe(headerRef.current);
}
@@ -201,6 +257,20 @@ export function MainLayout() {
};
}, []);
useEffect(() => {
if (requestLogModalOpen && !requestLogTouched) {
setRequestLogDraft(requestLogEnabled);
}
}, [requestLogModalOpen, requestLogTouched, requestLogEnabled]);
useEffect(() => {
return () => {
if (versionTapTimer.current) {
clearTimeout(versionTapTimer.current);
}
};
}, []);
const handleBrandClick = useCallback(() => {
if (!brandExpanded) {
setBrandExpanded(true);
@@ -214,12 +284,67 @@ export function MainLayout() {
}
}, [brandExpanded]);
const openRequestLogModal = useCallback(() => {
setRequestLogTouched(false);
setRequestLogDraft(requestLogEnabled);
setRequestLogModalOpen(true);
}, [requestLogEnabled]);
const handleRequestLogClose = useCallback(() => {
setRequestLogModalOpen(false);
setRequestLogTouched(false);
}, []);
const handleVersionTap = useCallback(() => {
versionTapCount.current += 1;
if (versionTapTimer.current) {
clearTimeout(versionTapTimer.current);
}
versionTapTimer.current = setTimeout(() => {
versionTapCount.current = 0;
}, 1500);
if (versionTapCount.current >= 7) {
versionTapCount.current = 0;
if (versionTapTimer.current) {
clearTimeout(versionTapTimer.current);
versionTapTimer.current = null;
}
openRequestLogModal();
}
}, [openRequestLogModal]);
const handleRequestLogSave = async () => {
if (!canEditRequestLog) return;
if (!requestLogDirty) {
setRequestLogModalOpen(false);
return;
}
const previous = requestLogEnabled;
setRequestLogSaving(true);
updateConfigValue('request-log', requestLogDraft);
try {
await configApi.updateRequestLog(requestLogDraft);
clearCache('request-log');
showNotification(t('notification.request_log_updated'), 'success');
setRequestLogModalOpen(false);
} catch (error: any) {
updateConfigValue('request-log', previous);
showNotification(`${t('notification.update_failed')}: ${error?.message || ''}`, 'error');
} finally {
setRequestLogSaving(false);
}
};
useEffect(() => {
fetchConfig().catch(() => {
// ignore initial failure; login flow会提示
});
}, [fetchConfig]);
const statusClass =
connectionStatus === 'connected'
? 'success'
@@ -230,16 +355,32 @@ export function MainLayout() {
: 'muted';
const navItems = [
{ path: '/', label: t('nav.dashboard'), icon: sidebarIcons.dashboard },
{ path: '/settings', label: t('nav.basic_settings'), icon: sidebarIcons.settings },
{ path: '/api-keys', label: t('nav.api_keys'), icon: sidebarIcons.apiKeys },
{ path: '/ai-providers', label: t('nav.ai_providers'), icon: sidebarIcons.aiProviders },
{ path: '/auth-files', label: t('nav.auth_files'), icon: sidebarIcons.authFiles },
{ 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: '/config', label: t('nav.config_management'), icon: sidebarIcons.config },
...(config?.loggingToFile ? [{ path: '/logs', label: t('nav.logs'), icon: sidebarIcons.logs }] : []),
{ path: '/system', label: t('nav.system_info'), icon: sidebarIcons.system }
...(config?.loggingToFile
? [{ path: '/logs', label: t('nav.logs'), icon: sidebarIcons.logs }]
: []),
{ path: '/system', label: t('nav.system_info'), icon: sidebarIcons.system },
];
const navOrder = navItems.map((item) => item.path);
const getRouteOrder = (pathname: string) => {
const trimmedPath =
pathname.length > 1 && pathname.endsWith('/') ? pathname.slice(0, -1) : pathname;
const normalizedPath = trimmedPath === '/dashboard' ? '/' : trimmedPath;
const exactIndex = navOrder.indexOf(normalizedPath);
if (exactIndex !== -1) return exactIndex;
const nestedIndex = navOrder.findIndex(
(path) => path !== '/' && normalizedPath.startsWith(`${path}/`)
);
return nestedIndex === -1 ? null : nestedIndex;
};
const handleRefreshAll = async () => {
clearCache();
@@ -287,7 +428,11 @@ export function MainLayout() {
<button
className="sidebar-toggle-header"
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}
</button>
@@ -317,20 +462,40 @@ export function MainLayout() {
</div>
<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}
</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}
</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}
</Button>
<Button variant="ghost" size="sm" onClick={toggleLanguage} title={t('language.switch')}>
{headerIcons.language}
</Button>
<Button variant="ghost" size="sm" onClick={toggleTheme} title={t('theme.switch')}>
{theme === 'dark' ? headerIcons.sun : headerIcons.moon}
<Button variant="ghost" size="sm" onClick={cycleTheme} title={t('theme.switch')}>
{theme === 'auto'
? headerIcons.autoTheme
: theme === 'dark'
? headerIcons.moon
: headerIcons.sun}
</Button>
<Button variant="ghost" size="sm" onClick={logout} title={t('header.logout')}>
{headerIcons.logout}
@@ -340,7 +505,9 @@ export function MainLayout() {
</header>
<div className="main-body">
<aside className={`sidebar ${sidebarOpen ? 'open' : ''} ${sidebarCollapsed ? 'collapsed' : ''}`}>
<aside
className={`sidebar ${sidebarOpen ? 'open' : ''} ${sidebarCollapsed ? 'collapsed' : ''}`}
>
<div className="nav-section">
{navItems.map((item) => (
<NavLink
@@ -357,25 +524,65 @@ export function MainLayout() {
</div>
</aside>
<div className="content">
<main className="main-content">
<Outlet />
<div className={`content${isLogsPage ? ' content-logs' : ''}`} ref={contentRef}>
<main className={`main-content${isLogsPage ? ' main-content-logs' : ''}`}>
<PageTransition
render={(location) => <MainRoutes location={location} />}
getRouteOrder={getRouteOrder}
scrollContainerRef={contentRef}
/>
</main>
<footer className="footer">
<span>
{t('footer.api_version')}: {serverVersion || t('system_info.version_unknown')}
</span>
<span>
<span className="footer-version" onClick={handleVersionTap}>
{t('footer.version')}: {__APP_VERSION__ || t('system_info.version_unknown')}
</span>
<span>
{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>
</footer>
</div>
</div>
<Modal
open={requestLogModalOpen}
onClose={handleRequestLogClose}
title={t('basic_settings.request_log_title')}
footer={
<>
<Button variant="secondary" onClick={handleRequestLogClose} disabled={requestLogSaving}>
{t('common.cancel')}
</Button>
<Button
onClick={handleRequestLogSave}
loading={requestLogSaving}
disabled={!canEditRequestLog || !requestLogDirty}
>
{t('common.save')}
</Button>
</>
}
>
<div className="request-log-modal">
<div className="status-badge warning">{t('basic_settings.request_log_warning')}</div>
<ToggleSwitch
label={t('basic_settings.request_log_enable')}
labelPosition="left"
checked={requestLogDraft}
disabled={!canEditRequestLog || requestLogSaving}
onChange={(value) => {
setRequestLogDraft(value);
setRequestLogTouched(true);
}}
/>
</div>
</Modal>
</div>
);
}

View File

@@ -0,0 +1,264 @@
import { useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Button } from '@/components/ui/Button';
import { Input } from '@/components/ui/Input';
import { Modal } from '@/components/ui/Modal';
import { ModelInputList } from '@/components/ui/ModelInputList';
import { ToggleSwitch } from '@/components/ui/ToggleSwitch';
import { useConfigStore, useNotificationStore } from '@/stores';
import { ampcodeApi } from '@/services/api';
import type { AmpcodeConfig } from '@/types';
import { maskApiKey } from '@/utils/format';
import { buildAmpcodeFormState, entriesToAmpcodeMappings } from '../utils';
import type { AmpcodeFormState } from '../types';
interface AmpcodeModalProps {
isOpen: boolean;
disableControls: boolean;
onClose: () => void;
onBusyChange?: (busy: boolean) => void;
}
export function AmpcodeModal({ isOpen, disableControls, onClose, onBusyChange }: AmpcodeModalProps) {
const { t } = useTranslation();
const { showNotification } = useNotificationStore();
const config = useConfigStore((state) => state.config);
const updateConfigValue = useConfigStore((state) => state.updateConfigValue);
const clearCache = useConfigStore((state) => state.clearCache);
const [form, setForm] = useState<AmpcodeFormState>(() => buildAmpcodeFormState(null));
const [loading, setLoading] = useState(false);
const [loaded, setLoaded] = useState(false);
const [mappingsDirty, setMappingsDirty] = useState(false);
const [error, setError] = useState('');
const [saving, setSaving] = useState(false);
const initializedRef = useRef(false);
const getErrorMessage = (err: unknown) => {
if (err instanceof Error) return err.message;
if (typeof err === 'string') return err;
return '';
};
useEffect(() => {
onBusyChange?.(loading || saving);
}, [loading, saving, onBusyChange]);
useEffect(() => {
if (!isOpen) {
initializedRef.current = false;
setLoading(false);
setSaving(false);
setError('');
setLoaded(false);
setMappingsDirty(false);
setForm(buildAmpcodeFormState(null));
onBusyChange?.(false);
return;
}
if (initializedRef.current) return;
initializedRef.current = true;
setLoading(true);
setLoaded(false);
setMappingsDirty(false);
setError('');
setForm(buildAmpcodeFormState(config?.ampcode ?? null));
void (async () => {
try {
const ampcode = await ampcodeApi.getAmpcode();
setLoaded(true);
updateConfigValue('ampcode', ampcode);
clearCache('ampcode');
setForm(buildAmpcodeFormState(ampcode));
} catch (err: unknown) {
setError(getErrorMessage(err) || t('notification.refresh_failed'));
} finally {
setLoading(false);
}
})();
}, [clearCache, config?.ampcode, isOpen, onBusyChange, t, updateConfigValue]);
const clearAmpcodeUpstreamApiKey = async () => {
if (!window.confirm(t('ai_providers.ampcode_clear_upstream_api_key_confirm'))) return;
setSaving(true);
setError('');
try {
await ampcodeApi.clearUpstreamApiKey();
const previous = config?.ampcode ?? {};
const next: AmpcodeConfig = { ...previous };
delete next.upstreamApiKey;
updateConfigValue('ampcode', next);
clearCache('ampcode');
showNotification(t('notification.ampcode_upstream_api_key_cleared'), 'success');
} catch (err: unknown) {
const message = getErrorMessage(err);
setError(message);
showNotification(`${t('notification.update_failed')}: ${message}`, 'error');
} finally {
setSaving(false);
}
};
const saveAmpcode = async () => {
if (!loaded && mappingsDirty) {
const confirmed = window.confirm(t('ai_providers.ampcode_mappings_overwrite_confirm'));
if (!confirmed) return;
}
setSaving(true);
setError('');
try {
const upstreamUrl = form.upstreamUrl.trim();
const overrideKey = form.upstreamApiKey.trim();
const modelMappings = entriesToAmpcodeMappings(form.mappingEntries);
if (upstreamUrl) {
await ampcodeApi.updateUpstreamUrl(upstreamUrl);
} else {
await ampcodeApi.clearUpstreamUrl();
}
await ampcodeApi.updateForceModelMappings(form.forceModelMappings);
if (loaded || mappingsDirty) {
if (modelMappings.length) {
await ampcodeApi.saveModelMappings(modelMappings);
} else {
await ampcodeApi.clearModelMappings();
}
}
if (overrideKey) {
await ampcodeApi.updateUpstreamApiKey(overrideKey);
}
const previous = config?.ampcode ?? {};
const next: AmpcodeConfig = {
upstreamUrl: upstreamUrl || undefined,
forceModelMappings: form.forceModelMappings,
};
if (previous.upstreamApiKey) {
next.upstreamApiKey = previous.upstreamApiKey;
}
if (Array.isArray(previous.modelMappings)) {
next.modelMappings = previous.modelMappings;
}
if (overrideKey) {
next.upstreamApiKey = overrideKey;
}
if (loaded || mappingsDirty) {
if (modelMappings.length) {
next.modelMappings = modelMappings;
} else {
delete next.modelMappings;
}
}
updateConfigValue('ampcode', next);
clearCache('ampcode');
showNotification(t('notification.ampcode_updated'), 'success');
onClose();
} catch (err: unknown) {
const message = getErrorMessage(err);
setError(message);
showNotification(`${t('notification.update_failed')}: ${message}`, 'error');
} finally {
setSaving(false);
}
};
return (
<Modal
open={isOpen}
onClose={onClose}
title={t('ai_providers.ampcode_modal_title')}
footer={
<>
<Button variant="secondary" onClick={onClose} disabled={saving}>
{t('common.cancel')}
</Button>
<Button onClick={saveAmpcode} loading={saving} disabled={disableControls || loading}>
{t('common.save')}
</Button>
</>
}
>
{error && <div className="error-box">{error}</div>}
<Input
label={t('ai_providers.ampcode_upstream_url_label')}
placeholder={t('ai_providers.ampcode_upstream_url_placeholder')}
value={form.upstreamUrl}
onChange={(e) => setForm((prev) => ({ ...prev, upstreamUrl: e.target.value }))}
disabled={loading || saving}
hint={t('ai_providers.ampcode_upstream_url_hint')}
/>
<Input
label={t('ai_providers.ampcode_upstream_api_key_label')}
placeholder={t('ai_providers.ampcode_upstream_api_key_placeholder')}
type="password"
value={form.upstreamApiKey}
onChange={(e) => setForm((prev) => ({ ...prev, upstreamApiKey: e.target.value }))}
disabled={loading || saving}
hint={t('ai_providers.ampcode_upstream_api_key_hint')}
/>
<div
style={{
display: 'flex',
gap: 8,
alignItems: 'center',
marginTop: -8,
marginBottom: 12,
flexWrap: 'wrap',
}}
>
<div className="hint" style={{ margin: 0 }}>
{t('ai_providers.ampcode_upstream_api_key_current', {
key: config?.ampcode?.upstreamApiKey
? maskApiKey(config.ampcode.upstreamApiKey)
: t('common.not_set'),
})}
</div>
<Button
variant="danger"
size="sm"
onClick={clearAmpcodeUpstreamApiKey}
disabled={loading || saving || !config?.ampcode?.upstreamApiKey}
>
{t('ai_providers.ampcode_clear_upstream_api_key')}
</Button>
</div>
<div className="form-group">
<ToggleSwitch
label={t('ai_providers.ampcode_force_model_mappings_label')}
checked={form.forceModelMappings}
onChange={(value) => setForm((prev) => ({ ...prev, forceModelMappings: value }))}
disabled={loading || saving}
/>
<div className="hint">{t('ai_providers.ampcode_force_model_mappings_hint')}</div>
</div>
<div className="form-group">
<label>{t('ai_providers.ampcode_model_mappings_label')}</label>
<ModelInputList
entries={form.mappingEntries}
onChange={(entries) => {
setMappingsDirty(true);
setForm((prev) => ({ ...prev, mappingEntries: entries }));
}}
addLabel={t('ai_providers.ampcode_model_mappings_add_btn')}
namePlaceholder={t('ai_providers.ampcode_model_mappings_from_placeholder')}
aliasPlaceholder={t('ai_providers.ampcode_model_mappings_to_placeholder')}
disabled={loading || saving}
/>
<div className="hint">{t('ai_providers.ampcode_model_mappings_hint')}</div>
</div>
</Modal>
);
}

View File

@@ -0,0 +1,111 @@
import { Button } from '@/components/ui/Button';
import { Card } from '@/components/ui/Card';
import iconAmp from '@/assets/icons/amp.svg';
import type { AmpcodeConfig } from '@/types';
import { maskApiKey } from '@/utils/format';
import styles from '@/pages/AiProvidersPage.module.scss';
import { useTranslation } from 'react-i18next';
import { AmpcodeModal } from './AmpcodeModal';
interface AmpcodeSectionProps {
config: AmpcodeConfig | null | undefined;
loading: boolean;
disableControls: boolean;
isSaving: boolean;
isSwitching: boolean;
isBusy: boolean;
isModalOpen: boolean;
onOpen: () => void;
onCloseModal: () => void;
onBusyChange: (busy: boolean) => void;
}
export function AmpcodeSection({
config,
loading,
disableControls,
isSaving,
isSwitching,
isBusy,
isModalOpen,
onOpen,
onCloseModal,
onBusyChange,
}: AmpcodeSectionProps) {
const { t } = useTranslation();
return (
<>
<Card
title={
<span className={styles.cardTitle}>
<img src={iconAmp} alt="" className={styles.cardTitleIcon} />
{t('ai_providers.ampcode_title')}
</span>
}
extra={
<Button
size="sm"
onClick={onOpen}
disabled={disableControls || isSaving || isBusy || isSwitching}
>
{t('common.edit')}
</Button>
}
>
{loading ? (
<div className="hint">{t('common.loading')}</div>
) : (
<>
<div className={styles.fieldRow}>
<span className={styles.fieldLabel}>{t('ai_providers.ampcode_upstream_url_label')}:</span>
<span className={styles.fieldValue}>{config?.upstreamUrl || t('common.not_set')}</span>
</div>
<div className={styles.fieldRow}>
<span className={styles.fieldLabel}>
{t('ai_providers.ampcode_upstream_api_key_label')}:
</span>
<span className={styles.fieldValue}>
{config?.upstreamApiKey ? maskApiKey(config.upstreamApiKey) : t('common.not_set')}
</span>
</div>
<div className={styles.fieldRow}>
<span className={styles.fieldLabel}>
{t('ai_providers.ampcode_force_model_mappings_label')}:
</span>
<span className={styles.fieldValue}>
{(config?.forceModelMappings ?? false) ? t('common.yes') : t('common.no')}
</span>
</div>
<div className={styles.fieldRow} style={{ marginTop: 8 }}>
<span className={styles.fieldLabel}>{t('ai_providers.ampcode_model_mappings_count')}:</span>
<span className={styles.fieldValue}>{config?.modelMappings?.length || 0}</span>
</div>
{config?.modelMappings?.length ? (
<div className={styles.modelTagList}>
{config.modelMappings.slice(0, 5).map((mapping) => (
<span key={`${mapping.from}${mapping.to}`} className={styles.modelTag}>
<span className={styles.modelName}>{mapping.from}</span>
<span className={styles.modelAlias}>{mapping.to}</span>
</span>
))}
{config.modelMappings.length > 5 && (
<span className={styles.modelTag}>
<span className={styles.modelName}>+{config.modelMappings.length - 5}</span>
</span>
)}
</div>
) : null}
</>
)}
</Card>
<AmpcodeModal
isOpen={isModalOpen}
disableControls={disableControls}
onClose={onCloseModal}
onBusyChange={onBusyChange}
/>
</>
);
}

View File

@@ -0,0 +1 @@
export { AmpcodeSection } from './AmpcodeSection';

View File

@@ -0,0 +1,128 @@
import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Button } from '@/components/ui/Button';
import { HeaderInputList } from '@/components/ui/HeaderInputList';
import { Input } from '@/components/ui/Input';
import { Modal } from '@/components/ui/Modal';
import { ModelInputList, modelsToEntries } from '@/components/ui/ModelInputList';
import type { ProviderKeyConfig } from '@/types';
import { buildHeaderObject, headersToEntries } from '@/utils/headers';
import { excludedModelsToText } from '../utils';
import type { ProviderFormState, ProviderModalProps } from '../types';
interface ClaudeModalProps extends ProviderModalProps<ProviderKeyConfig, ProviderFormState> {
isSaving: boolean;
}
const buildEmptyForm = (): ProviderFormState => ({
apiKey: '',
prefix: '',
baseUrl: '',
proxyUrl: '',
headers: {},
models: [],
excludedModels: [],
modelEntries: [{ name: '', alias: '' }],
excludedText: '',
});
export function ClaudeModal({
isOpen,
editIndex,
initialData,
onClose,
onSave,
isSaving,
}: ClaudeModalProps) {
const { t } = useTranslation();
const [form, setForm] = useState<ProviderFormState>(buildEmptyForm);
useEffect(() => {
if (!isOpen) return;
if (initialData) {
// eslint-disable-next-line react-hooks/set-state-in-effect
setForm({
...initialData,
headers: initialData.headers ?? {},
modelEntries: modelsToEntries(initialData.models),
excludedText: excludedModelsToText(initialData.excludedModels),
});
return;
}
setForm(buildEmptyForm());
}, [initialData, isOpen]);
return (
<Modal
open={isOpen}
onClose={onClose}
title={
editIndex !== null
? t('ai_providers.claude_edit_modal_title')
: t('ai_providers.claude_add_modal_title')
}
footer={
<>
<Button variant="secondary" onClick={onClose} disabled={isSaving}>
{t('common.cancel')}
</Button>
<Button onClick={() => void onSave(form, editIndex)} loading={isSaving}>
{t('common.save')}
</Button>
</>
}
>
<Input
label={t('ai_providers.claude_add_modal_key_label')}
value={form.apiKey}
onChange={(e) => setForm((prev) => ({ ...prev, apiKey: e.target.value }))}
/>
<Input
label={t('ai_providers.prefix_label')}
placeholder={t('ai_providers.prefix_placeholder')}
value={form.prefix ?? ''}
onChange={(e) => setForm((prev) => ({ ...prev, prefix: e.target.value }))}
hint={t('ai_providers.prefix_hint')}
/>
<Input
label={t('ai_providers.claude_add_modal_url_label')}
value={form.baseUrl ?? ''}
onChange={(e) => setForm((prev) => ({ ...prev, baseUrl: e.target.value }))}
/>
<Input
label={t('ai_providers.claude_add_modal_proxy_label')}
value={form.proxyUrl ?? ''}
onChange={(e) => setForm((prev) => ({ ...prev, proxyUrl: e.target.value }))}
/>
<HeaderInputList
entries={headersToEntries(form.headers)}
onChange={(entries) => setForm((prev) => ({ ...prev, headers: buildHeaderObject(entries) }))}
addLabel={t('common.custom_headers_add')}
keyPlaceholder={t('common.custom_headers_key_placeholder')}
valuePlaceholder={t('common.custom_headers_value_placeholder')}
/>
<div className="form-group">
<label>{t('ai_providers.claude_models_label')}</label>
<ModelInputList
entries={form.modelEntries}
onChange={(entries) => setForm((prev) => ({ ...prev, modelEntries: entries }))}
addLabel={t('ai_providers.claude_models_add_btn')}
namePlaceholder={t('common.model_name_placeholder')}
aliasPlaceholder={t('common.model_alias_placeholder')}
disabled={isSaving}
/>
</div>
<div className="form-group">
<label>{t('ai_providers.excluded_models_label')}</label>
<textarea
className="input"
placeholder={t('ai_providers.excluded_models_placeholder')}
value={form.excludedText}
onChange={(e) => setForm((prev) => ({ ...prev, excludedText: e.target.value }))}
rows={4}
/>
<div className="hint">{t('ai_providers.excluded_models_hint')}</div>
</div>
</Modal>
);
}

View File

@@ -0,0 +1,202 @@
import { Fragment, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { Button } from '@/components/ui/Button';
import { Card } from '@/components/ui/Card';
import { ToggleSwitch } from '@/components/ui/ToggleSwitch';
import iconClaude from '@/assets/icons/claude.svg';
import type { ProviderKeyConfig } from '@/types';
import { maskApiKey } from '@/utils/format';
import { calculateStatusBarData, type KeyStats, type UsageDetail } from '@/utils/usage';
import styles from '@/pages/AiProvidersPage.module.scss';
import { ProviderList } from '../ProviderList';
import { ProviderStatusBar } from '../ProviderStatusBar';
import { getStatsBySource, hasDisableAllModelsRule } from '../utils';
import type { ProviderFormState } from '../types';
import { ClaudeModal } from './ClaudeModal';
interface ClaudeSectionProps {
configs: ProviderKeyConfig[];
keyStats: KeyStats;
usageDetails: UsageDetail[];
loading: boolean;
disableControls: boolean;
isSaving: boolean;
isSwitching: boolean;
isModalOpen: boolean;
modalIndex: number | null;
onAdd: () => void;
onEdit: (index: number) => void;
onDelete: (index: number) => void;
onToggle: (index: number, enabled: boolean) => void;
onCloseModal: () => void;
onSave: (data: ProviderFormState, index: number | null) => Promise<void>;
}
export function ClaudeSection({
configs,
keyStats,
usageDetails,
loading,
disableControls,
isSaving,
isSwitching,
isModalOpen,
modalIndex,
onAdd,
onEdit,
onDelete,
onToggle,
onCloseModal,
onSave,
}: ClaudeSectionProps) {
const { t } = useTranslation();
const actionsDisabled = disableControls || isSaving || isSwitching;
const toggleDisabled = disableControls || loading || isSaving || isSwitching;
const statusBarCache = useMemo(() => {
const cache = new Map<string, ReturnType<typeof calculateStatusBarData>>();
const allApiKeys = new Set<string>();
configs.forEach((config) => config.apiKey && allApiKeys.add(config.apiKey));
allApiKeys.forEach((apiKey) => {
cache.set(apiKey, calculateStatusBarData(usageDetails, apiKey));
});
return cache;
}, [configs, usageDetails]);
const initialData = modalIndex !== null ? configs[modalIndex] : undefined;
return (
<>
<Card
title={
<span className={styles.cardTitle}>
<img src={iconClaude} alt="" className={styles.cardTitleIcon} />
{t('ai_providers.claude_title')}
</span>
}
extra={
<Button size="sm" onClick={onAdd} disabled={actionsDisabled}>
{t('ai_providers.claude_add_button')}
</Button>
}
>
<ProviderList<ProviderKeyConfig>
items={configs}
loading={loading}
keyField={(item) => item.apiKey}
emptyTitle={t('ai_providers.claude_empty_title')}
emptyDescription={t('ai_providers.claude_empty_desc')}
onEdit={onEdit}
onDelete={onDelete}
actionsDisabled={actionsDisabled}
getRowDisabled={(item) => hasDisableAllModelsRule(item.excludedModels)}
renderExtraActions={(item, index) => (
<ToggleSwitch
label={t('ai_providers.config_toggle_label')}
checked={!hasDisableAllModelsRule(item.excludedModels)}
disabled={toggleDisabled}
onChange={(value) => void onToggle(index, value)}
/>
)}
renderContent={(item) => {
const stats = getStatsBySource(item.apiKey, keyStats, maskApiKey);
const headerEntries = Object.entries(item.headers || {});
const configDisabled = hasDisableAllModelsRule(item.excludedModels);
const excludedModels = item.excludedModels ?? [];
const statusData =
statusBarCache.get(item.apiKey) || calculateStatusBarData([], item.apiKey);
return (
<Fragment>
<div className="item-title">{t('ai_providers.claude_item_title')}</div>
<div className={styles.fieldRow}>
<span className={styles.fieldLabel}>{t('common.api_key')}:</span>
<span className={styles.fieldValue}>{maskApiKey(item.apiKey)}</span>
</div>
{item.prefix && (
<div className={styles.fieldRow}>
<span className={styles.fieldLabel}>{t('common.prefix')}:</span>
<span className={styles.fieldValue}>{item.prefix}</span>
</div>
)}
{item.baseUrl && (
<div className={styles.fieldRow}>
<span className={styles.fieldLabel}>{t('common.base_url')}:</span>
<span className={styles.fieldValue}>{item.baseUrl}</span>
</div>
)}
{item.proxyUrl && (
<div className={styles.fieldRow}>
<span className={styles.fieldLabel}>{t('common.proxy_url')}:</span>
<span className={styles.fieldValue}>{item.proxyUrl}</span>
</div>
)}
{headerEntries.length > 0 && (
<div className={styles.headerBadgeList}>
{headerEntries.map(([key, value]) => (
<span key={key} className={styles.headerBadge}>
<strong>{key}:</strong> {value}
</span>
))}
</div>
)}
{configDisabled && (
<div className="status-badge warning" style={{ marginTop: 8, marginBottom: 0 }}>
{t('ai_providers.config_disabled_badge')}
</div>
)}
{item.models?.length ? (
<div className={styles.modelTagList}>
<span className={styles.modelCountLabel}>
{t('ai_providers.claude_models_count')}: {item.models.length}
</span>
{item.models.map((model) => (
<span key={model.name} className={styles.modelTag}>
<span className={styles.modelName}>{model.name}</span>
{model.alias && model.alias !== model.name && (
<span className={styles.modelAlias}>{model.alias}</span>
)}
</span>
))}
</div>
) : null}
{excludedModels.length ? (
<div className={styles.excludedModelsSection}>
<div className={styles.excludedModelsLabel}>
{t('ai_providers.excluded_models_count', { count: excludedModels.length })}
</div>
<div className={styles.modelTagList}>
{excludedModels.map((model) => (
<span key={model} className={`${styles.modelTag} ${styles.excludedModelTag}`}>
<span className={styles.modelName}>{model}</span>
</span>
))}
</div>
</div>
) : null}
<div className={styles.cardStats}>
<span className={`${styles.statPill} ${styles.statSuccess}`}>
{t('stats.success')}: {stats.success}
</span>
<span className={`${styles.statPill} ${styles.statFailure}`}>
{t('stats.failure')}: {stats.failure}
</span>
</div>
<ProviderStatusBar statusData={statusData} />
</Fragment>
);
}}
/>
</Card>
<ClaudeModal
isOpen={isModalOpen}
editIndex={modalIndex}
initialData={initialData}
onClose={onCloseModal}
onSave={onSave}
isSaving={isSaving}
/>
</>
);
}

View File

@@ -0,0 +1 @@
export { ClaudeSection } from './ClaudeSection';

View File

@@ -0,0 +1,117 @@
import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Button } from '@/components/ui/Button';
import { HeaderInputList } from '@/components/ui/HeaderInputList';
import { Input } from '@/components/ui/Input';
import { Modal } from '@/components/ui/Modal';
import type { ProviderKeyConfig } from '@/types';
import { buildHeaderObject, headersToEntries } from '@/utils/headers';
import { modelsToEntries } from '@/components/ui/ModelInputList';
import { excludedModelsToText } from '../utils';
import type { ProviderFormState, ProviderModalProps } from '../types';
interface CodexModalProps extends ProviderModalProps<ProviderKeyConfig, ProviderFormState> {
isSaving: boolean;
}
const buildEmptyForm = (): ProviderFormState => ({
apiKey: '',
prefix: '',
baseUrl: '',
proxyUrl: '',
headers: {},
models: [],
excludedModels: [],
modelEntries: [{ name: '', alias: '' }],
excludedText: '',
});
export function CodexModal({
isOpen,
editIndex,
initialData,
onClose,
onSave,
isSaving,
}: CodexModalProps) {
const { t } = useTranslation();
const [form, setForm] = useState<ProviderFormState>(buildEmptyForm);
useEffect(() => {
if (!isOpen) return;
if (initialData) {
// eslint-disable-next-line react-hooks/set-state-in-effect
setForm({
...initialData,
headers: initialData.headers ?? {},
modelEntries: modelsToEntries(initialData.models),
excludedText: excludedModelsToText(initialData.excludedModels),
});
return;
}
setForm(buildEmptyForm());
}, [initialData, isOpen]);
return (
<Modal
open={isOpen}
onClose={onClose}
title={
editIndex !== null
? t('ai_providers.codex_edit_modal_title')
: t('ai_providers.codex_add_modal_title')
}
footer={
<>
<Button variant="secondary" onClick={onClose} disabled={isSaving}>
{t('common.cancel')}
</Button>
<Button onClick={() => void onSave(form, editIndex)} loading={isSaving}>
{t('common.save')}
</Button>
</>
}
>
<Input
label={t('ai_providers.codex_add_modal_key_label')}
value={form.apiKey}
onChange={(e) => setForm((prev) => ({ ...prev, apiKey: e.target.value }))}
/>
<Input
label={t('ai_providers.prefix_label')}
placeholder={t('ai_providers.prefix_placeholder')}
value={form.prefix ?? ''}
onChange={(e) => setForm((prev) => ({ ...prev, prefix: e.target.value }))}
hint={t('ai_providers.prefix_hint')}
/>
<Input
label={t('ai_providers.codex_add_modal_url_label')}
value={form.baseUrl ?? ''}
onChange={(e) => setForm((prev) => ({ ...prev, baseUrl: e.target.value }))}
/>
<Input
label={t('ai_providers.codex_add_modal_proxy_label')}
value={form.proxyUrl ?? ''}
onChange={(e) => setForm((prev) => ({ ...prev, proxyUrl: e.target.value }))}
/>
<HeaderInputList
entries={headersToEntries(form.headers)}
onChange={(entries) => setForm((prev) => ({ ...prev, headers: buildHeaderObject(entries) }))}
addLabel={t('common.custom_headers_add')}
keyPlaceholder={t('common.custom_headers_key_placeholder')}
valuePlaceholder={t('common.custom_headers_value_placeholder')}
/>
<div className="form-group">
<label>{t('ai_providers.excluded_models_label')}</label>
<textarea
className="input"
placeholder={t('ai_providers.excluded_models_placeholder')}
value={form.excludedText}
onChange={(e) => setForm((prev) => ({ ...prev, excludedText: e.target.value }))}
rows={4}
/>
<div className="hint">{t('ai_providers.excluded_models_hint')}</div>
</div>
</Modal>
);
}

View File

@@ -0,0 +1,194 @@
import { Fragment, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { Button } from '@/components/ui/Button';
import { Card } from '@/components/ui/Card';
import { ToggleSwitch } from '@/components/ui/ToggleSwitch';
import iconOpenaiLight from '@/assets/icons/openai-light.svg';
import iconOpenaiDark from '@/assets/icons/openai-dark.svg';
import type { ProviderKeyConfig } from '@/types';
import { maskApiKey } from '@/utils/format';
import { calculateStatusBarData, type KeyStats, type UsageDetail } from '@/utils/usage';
import styles from '@/pages/AiProvidersPage.module.scss';
import { ProviderList } from '../ProviderList';
import { ProviderStatusBar } from '../ProviderStatusBar';
import { getStatsBySource, hasDisableAllModelsRule } from '../utils';
import type { ProviderFormState } from '../types';
import { CodexModal } from './CodexModal';
interface CodexSectionProps {
configs: ProviderKeyConfig[];
keyStats: KeyStats;
usageDetails: UsageDetail[];
loading: boolean;
disableControls: boolean;
isSaving: boolean;
isSwitching: boolean;
resolvedTheme: string;
isModalOpen: boolean;
modalIndex: number | null;
onAdd: () => void;
onEdit: (index: number) => void;
onDelete: (index: number) => void;
onToggle: (index: number, enabled: boolean) => void;
onCloseModal: () => void;
onSave: (data: ProviderFormState, index: number | null) => Promise<void>;
}
export function CodexSection({
configs,
keyStats,
usageDetails,
loading,
disableControls,
isSaving,
isSwitching,
resolvedTheme,
isModalOpen,
modalIndex,
onAdd,
onEdit,
onDelete,
onToggle,
onCloseModal,
onSave,
}: CodexSectionProps) {
const { t } = useTranslation();
const actionsDisabled = disableControls || isSaving || isSwitching;
const toggleDisabled = disableControls || loading || isSaving || isSwitching;
const statusBarCache = useMemo(() => {
const cache = new Map<string, ReturnType<typeof calculateStatusBarData>>();
const allApiKeys = new Set<string>();
configs.forEach((config) => config.apiKey && allApiKeys.add(config.apiKey));
allApiKeys.forEach((apiKey) => {
cache.set(apiKey, calculateStatusBarData(usageDetails, apiKey));
});
return cache;
}, [configs, usageDetails]);
const initialData = modalIndex !== null ? configs[modalIndex] : undefined;
return (
<>
<Card
title={
<span className={styles.cardTitle}>
<img
src={resolvedTheme === 'dark' ? iconOpenaiDark : iconOpenaiLight}
alt=""
className={styles.cardTitleIcon}
/>
{t('ai_providers.codex_title')}
</span>
}
extra={
<Button size="sm" onClick={onAdd} disabled={actionsDisabled}>
{t('ai_providers.codex_add_button')}
</Button>
}
>
<ProviderList<ProviderKeyConfig>
items={configs}
loading={loading}
keyField={(item) => item.apiKey}
emptyTitle={t('ai_providers.codex_empty_title')}
emptyDescription={t('ai_providers.codex_empty_desc')}
onEdit={onEdit}
onDelete={onDelete}
actionsDisabled={actionsDisabled}
getRowDisabled={(item) => hasDisableAllModelsRule(item.excludedModels)}
renderExtraActions={(item, index) => (
<ToggleSwitch
label={t('ai_providers.config_toggle_label')}
checked={!hasDisableAllModelsRule(item.excludedModels)}
disabled={toggleDisabled}
onChange={(value) => void onToggle(index, value)}
/>
)}
renderContent={(item) => {
const stats = getStatsBySource(item.apiKey, keyStats, maskApiKey);
const headerEntries = Object.entries(item.headers || {});
const configDisabled = hasDisableAllModelsRule(item.excludedModels);
const excludedModels = item.excludedModels ?? [];
const statusData =
statusBarCache.get(item.apiKey) || calculateStatusBarData([], item.apiKey);
return (
<Fragment>
<div className="item-title">{t('ai_providers.codex_item_title')}</div>
<div className={styles.fieldRow}>
<span className={styles.fieldLabel}>{t('common.api_key')}:</span>
<span className={styles.fieldValue}>{maskApiKey(item.apiKey)}</span>
</div>
{item.prefix && (
<div className={styles.fieldRow}>
<span className={styles.fieldLabel}>{t('common.prefix')}:</span>
<span className={styles.fieldValue}>{item.prefix}</span>
</div>
)}
{item.baseUrl && (
<div className={styles.fieldRow}>
<span className={styles.fieldLabel}>{t('common.base_url')}:</span>
<span className={styles.fieldValue}>{item.baseUrl}</span>
</div>
)}
{item.proxyUrl && (
<div className={styles.fieldRow}>
<span className={styles.fieldLabel}>{t('common.proxy_url')}:</span>
<span className={styles.fieldValue}>{item.proxyUrl}</span>
</div>
)}
{headerEntries.length > 0 && (
<div className={styles.headerBadgeList}>
{headerEntries.map(([key, value]) => (
<span key={key} className={styles.headerBadge}>
<strong>{key}:</strong> {value}
</span>
))}
</div>
)}
{configDisabled && (
<div className="status-badge warning" style={{ marginTop: 8, marginBottom: 0 }}>
{t('ai_providers.config_disabled_badge')}
</div>
)}
{excludedModels.length ? (
<div className={styles.excludedModelsSection}>
<div className={styles.excludedModelsLabel}>
{t('ai_providers.excluded_models_count', { count: excludedModels.length })}
</div>
<div className={styles.modelTagList}>
{excludedModels.map((model) => (
<span key={model} className={`${styles.modelTag} ${styles.excludedModelTag}`}>
<span className={styles.modelName}>{model}</span>
</span>
))}
</div>
</div>
) : null}
<div className={styles.cardStats}>
<span className={`${styles.statPill} ${styles.statSuccess}`}>
{t('stats.success')}: {stats.success}
</span>
<span className={`${styles.statPill} ${styles.statFailure}`}>
{t('stats.failure')}: {stats.failure}
</span>
</div>
<ProviderStatusBar statusData={statusData} />
</Fragment>
);
}}
/>
</Card>
<CodexModal
isOpen={isModalOpen}
editIndex={modalIndex}
initialData={initialData}
onClose={onCloseModal}
onSave={onSave}
isSaving={isSaving}
/>
</>
);
}

View File

@@ -0,0 +1 @@
export { CodexSection } from './CodexSection';

View File

@@ -0,0 +1,113 @@
import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Button } from '@/components/ui/Button';
import { HeaderInputList } from '@/components/ui/HeaderInputList';
import { Input } from '@/components/ui/Input';
import { Modal } from '@/components/ui/Modal';
import type { GeminiKeyConfig } from '@/types';
import { buildHeaderObject, headersToEntries } from '@/utils/headers';
import { excludedModelsToText } from '../utils';
import type { GeminiFormState, ProviderModalProps } from '../types';
interface GeminiModalProps extends ProviderModalProps<GeminiKeyConfig, GeminiFormState> {
isSaving: boolean;
}
const buildEmptyForm = (): GeminiFormState => ({
apiKey: '',
prefix: '',
baseUrl: '',
headers: {},
excludedModels: [],
excludedText: '',
});
export function GeminiModal({
isOpen,
editIndex,
initialData,
onClose,
onSave,
isSaving,
}: GeminiModalProps) {
const { t } = useTranslation();
const [form, setForm] = useState<GeminiFormState>(buildEmptyForm);
useEffect(() => {
if (!isOpen) return;
if (initialData) {
// eslint-disable-next-line react-hooks/set-state-in-effect
setForm({
...initialData,
headers: initialData.headers ?? {},
excludedText: excludedModelsToText(initialData.excludedModels),
});
return;
}
setForm(buildEmptyForm());
}, [initialData, isOpen]);
const handleSave = () => {
void onSave(form, editIndex);
};
return (
<Modal
open={isOpen}
onClose={onClose}
title={
editIndex !== null
? t('ai_providers.gemini_edit_modal_title')
: t('ai_providers.gemini_add_modal_title')
}
footer={
<>
<Button variant="secondary" onClick={onClose} disabled={isSaving}>
{t('common.cancel')}
</Button>
<Button onClick={handleSave} loading={isSaving}>
{t('common.save')}
</Button>
</>
}
>
<Input
label={t('ai_providers.gemini_add_modal_key_label')}
placeholder={t('ai_providers.gemini_add_modal_key_placeholder')}
value={form.apiKey}
onChange={(e) => setForm((prev) => ({ ...prev, apiKey: e.target.value }))}
/>
<Input
label={t('ai_providers.prefix_label')}
placeholder={t('ai_providers.prefix_placeholder')}
value={form.prefix ?? ''}
onChange={(e) => setForm((prev) => ({ ...prev, prefix: e.target.value }))}
hint={t('ai_providers.prefix_hint')}
/>
<Input
label={t('ai_providers.gemini_base_url_label')}
placeholder={t('ai_providers.gemini_base_url_placeholder')}
value={form.baseUrl ?? ''}
onChange={(e) => setForm((prev) => ({ ...prev, baseUrl: e.target.value }))}
/>
<HeaderInputList
entries={headersToEntries(form.headers)}
onChange={(entries) => setForm((prev) => ({ ...prev, headers: buildHeaderObject(entries) }))}
addLabel={t('common.custom_headers_add')}
keyPlaceholder={t('common.custom_headers_key_placeholder')}
valuePlaceholder={t('common.custom_headers_value_placeholder')}
/>
<div className="form-group">
<label>{t('ai_providers.excluded_models_label')}</label>
<textarea
className="input"
placeholder={t('ai_providers.excluded_models_placeholder')}
value={form.excludedText}
onChange={(e) => setForm((prev) => ({ ...prev, excludedText: e.target.value }))}
rows={4}
/>
<div className="hint">{t('ai_providers.excluded_models_hint')}</div>
</div>
</Modal>
);
}

View File

@@ -0,0 +1,183 @@
import { Fragment, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { Button } from '@/components/ui/Button';
import { Card } from '@/components/ui/Card';
import { ToggleSwitch } from '@/components/ui/ToggleSwitch';
import iconGemini from '@/assets/icons/gemini.svg';
import type { GeminiKeyConfig } from '@/types';
import { maskApiKey } from '@/utils/format';
import { calculateStatusBarData, type KeyStats, type UsageDetail } from '@/utils/usage';
import styles from '@/pages/AiProvidersPage.module.scss';
import type { GeminiFormState } from '../types';
import { ProviderList } from '../ProviderList';
import { ProviderStatusBar } from '../ProviderStatusBar';
import { getStatsBySource, hasDisableAllModelsRule } from '../utils';
import { GeminiModal } from './GeminiModal';
interface GeminiSectionProps {
configs: GeminiKeyConfig[];
keyStats: KeyStats;
usageDetails: UsageDetail[];
loading: boolean;
disableControls: boolean;
isSaving: boolean;
isSwitching: boolean;
isModalOpen: boolean;
modalIndex: number | null;
onAdd: () => void;
onEdit: (index: number) => void;
onDelete: (index: number) => void;
onToggle: (index: number, enabled: boolean) => void;
onCloseModal: () => void;
onSave: (data: GeminiFormState, index: number | null) => Promise<void>;
}
export function GeminiSection({
configs,
keyStats,
usageDetails,
loading,
disableControls,
isSaving,
isSwitching,
isModalOpen,
modalIndex,
onAdd,
onEdit,
onDelete,
onToggle,
onCloseModal,
onSave,
}: GeminiSectionProps) {
const { t } = useTranslation();
const actionsDisabled = disableControls || isSaving || isSwitching;
const toggleDisabled = disableControls || loading || isSaving || isSwitching;
const statusBarCache = useMemo(() => {
const cache = new Map<string, ReturnType<typeof calculateStatusBarData>>();
const allApiKeys = new Set<string>();
configs.forEach((config) => config.apiKey && allApiKeys.add(config.apiKey));
allApiKeys.forEach((apiKey) => {
cache.set(apiKey, calculateStatusBarData(usageDetails, apiKey));
});
return cache;
}, [configs, usageDetails]);
const initialData = modalIndex !== null ? configs[modalIndex] : undefined;
return (
<>
<Card
title={
<span className={styles.cardTitle}>
<img src={iconGemini} alt="" className={styles.cardTitleIcon} />
{t('ai_providers.gemini_title')}
</span>
}
extra={
<Button size="sm" onClick={onAdd} disabled={actionsDisabled}>
{t('ai_providers.gemini_add_button')}
</Button>
}
>
<ProviderList<GeminiKeyConfig>
items={configs}
loading={loading}
keyField={(item) => item.apiKey}
emptyTitle={t('ai_providers.gemini_empty_title')}
emptyDescription={t('ai_providers.gemini_empty_desc')}
onEdit={onEdit}
onDelete={onDelete}
actionsDisabled={actionsDisabled}
getRowDisabled={(item) => hasDisableAllModelsRule(item.excludedModels)}
renderExtraActions={(item, index) => (
<ToggleSwitch
label={t('ai_providers.config_toggle_label')}
checked={!hasDisableAllModelsRule(item.excludedModels)}
disabled={toggleDisabled}
onChange={(value) => void onToggle(index, value)}
/>
)}
renderContent={(item, index) => {
const stats = getStatsBySource(item.apiKey, keyStats, maskApiKey);
const headerEntries = Object.entries(item.headers || {});
const configDisabled = hasDisableAllModelsRule(item.excludedModels);
const excludedModels = item.excludedModels ?? [];
const statusData =
statusBarCache.get(item.apiKey) || calculateStatusBarData([], item.apiKey);
return (
<Fragment>
<div className="item-title">
{t('ai_providers.gemini_item_title')} #{index + 1}
</div>
<div className={styles.fieldRow}>
<span className={styles.fieldLabel}>{t('common.api_key')}:</span>
<span className={styles.fieldValue}>{maskApiKey(item.apiKey)}</span>
</div>
{item.prefix && (
<div className={styles.fieldRow}>
<span className={styles.fieldLabel}>{t('common.prefix')}:</span>
<span className={styles.fieldValue}>{item.prefix}</span>
</div>
)}
{item.baseUrl && (
<div className={styles.fieldRow}>
<span className={styles.fieldLabel}>{t('common.base_url')}:</span>
<span className={styles.fieldValue}>{item.baseUrl}</span>
</div>
)}
{headerEntries.length > 0 && (
<div className={styles.headerBadgeList}>
{headerEntries.map(([key, value]) => (
<span key={key} className={styles.headerBadge}>
<strong>{key}:</strong> {value}
</span>
))}
</div>
)}
{configDisabled && (
<div className="status-badge warning" style={{ marginTop: 8, marginBottom: 0 }}>
{t('ai_providers.config_disabled_badge')}
</div>
)}
{excludedModels.length ? (
<div className={styles.excludedModelsSection}>
<div className={styles.excludedModelsLabel}>
{t('ai_providers.excluded_models_count', { count: excludedModels.length })}
</div>
<div className={styles.modelTagList}>
{excludedModels.map((model) => (
<span key={model} className={`${styles.modelTag} ${styles.excludedModelTag}`}>
<span className={styles.modelName}>{model}</span>
</span>
))}
</div>
</div>
) : null}
<div className={styles.cardStats}>
<span className={`${styles.statPill} ${styles.statSuccess}`}>
{t('stats.success')}: {stats.success}
</span>
<span className={`${styles.statPill} ${styles.statFailure}`}>
{t('stats.failure')}: {stats.failure}
</span>
</div>
<ProviderStatusBar statusData={statusData} />
</Fragment>
);
}}
/>
</Card>
<GeminiModal
isOpen={isModalOpen}
editIndex={modalIndex}
initialData={initialData}
onClose={onCloseModal}
onSave={onSave}
isSaving={isSaving}
/>
</>
);
}

View File

@@ -0,0 +1 @@
export { GeminiSection } from './GeminiSection';

View File

@@ -0,0 +1,194 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Button } from '@/components/ui/Button';
import { Input } from '@/components/ui/Input';
import { Modal } from '@/components/ui/Modal';
import { modelsApi } from '@/services/api';
import type { ApiKeyEntry } from '@/types';
import type { ModelInfo } from '@/utils/models';
import { buildHeaderObject, type HeaderEntry } from '@/utils/headers';
import { buildOpenAIModelsEndpoint } from '../utils';
import styles from '@/pages/AiProvidersPage.module.scss';
interface OpenAIDiscoveryModalProps {
isOpen: boolean;
baseUrl: string;
headers: HeaderEntry[];
apiKeyEntries: ApiKeyEntry[];
onClose: () => void;
onApply: (selected: ModelInfo[]) => void;
}
export function OpenAIDiscoveryModal({
isOpen,
baseUrl,
headers,
apiKeyEntries,
onClose,
onApply,
}: OpenAIDiscoveryModalProps) {
const { t } = useTranslation();
const [endpoint, setEndpoint] = useState('');
const [models, setModels] = useState<ModelInfo[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const [search, setSearch] = useState('');
const [selected, setSelected] = useState<Set<string>>(new Set());
const getErrorMessage = (err: unknown) => {
if (err instanceof Error) return err.message;
if (typeof err === 'string') return err;
return '';
};
const filteredModels = useMemo(() => {
const filter = search.trim().toLowerCase();
if (!filter) return models;
return models.filter((model) => {
const name = (model.name || '').toLowerCase();
const alias = (model.alias || '').toLowerCase();
const desc = (model.description || '').toLowerCase();
return name.includes(filter) || alias.includes(filter) || desc.includes(filter);
});
}, [models, search]);
const fetchOpenaiModelDiscovery = useCallback(
async ({ allowFallback = true }: { allowFallback?: boolean } = {}) => {
const trimmedBaseUrl = baseUrl.trim();
if (!trimmedBaseUrl) return;
setLoading(true);
setError('');
try {
const headerObject = buildHeaderObject(headers);
const firstKey = apiKeyEntries.find((entry) => entry.apiKey?.trim())?.apiKey?.trim();
const hasAuthHeader = Boolean(headerObject.Authorization || headerObject['authorization']);
const list = await modelsApi.fetchModelsViaApiCall(
trimmedBaseUrl,
hasAuthHeader ? undefined : firstKey,
headerObject
);
setModels(list);
} catch (err: unknown) {
if (allowFallback) {
try {
const list = await modelsApi.fetchModelsViaApiCall(trimmedBaseUrl);
setModels(list);
return;
} catch (fallbackErr: unknown) {
const message = getErrorMessage(fallbackErr) || getErrorMessage(err);
setModels([]);
setError(`${t('ai_providers.openai_models_fetch_error')}: ${message}`);
}
} else {
setModels([]);
setError(`${t('ai_providers.openai_models_fetch_error')}: ${getErrorMessage(err)}`);
}
} finally {
setLoading(false);
}
},
[apiKeyEntries, baseUrl, headers, t]
);
useEffect(() => {
if (!isOpen) return;
setEndpoint(buildOpenAIModelsEndpoint(baseUrl));
setModels([]);
setSearch('');
setSelected(new Set());
setError('');
void fetchOpenaiModelDiscovery();
}, [baseUrl, fetchOpenaiModelDiscovery, isOpen]);
const toggleSelection = (name: string) => {
setSelected((prev) => {
const next = new Set(prev);
if (next.has(name)) {
next.delete(name);
} else {
next.add(name);
}
return next;
});
};
const handleApply = () => {
const selectedModels = models.filter((model) => selected.has(model.name));
onApply(selectedModels);
};
return (
<Modal
open={isOpen}
onClose={onClose}
title={t('ai_providers.openai_models_fetch_title')}
width={720}
footer={
<>
<Button variant="secondary" onClick={onClose} disabled={loading}>
{t('ai_providers.openai_models_fetch_back')}
</Button>
<Button onClick={handleApply} disabled={loading}>
{t('ai_providers.openai_models_fetch_apply')}
</Button>
</>
}
>
<div className="hint" style={{ marginBottom: 8 }}>
{t('ai_providers.openai_models_fetch_hint')}
</div>
<div className="form-group">
<label>{t('ai_providers.openai_models_fetch_url_label')}</label>
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
<input className="input" readOnly value={endpoint} />
<Button
variant="secondary"
size="sm"
onClick={() => void fetchOpenaiModelDiscovery({ allowFallback: true })}
loading={loading}
>
{t('ai_providers.openai_models_fetch_refresh')}
</Button>
</div>
</div>
<Input
label={t('ai_providers.openai_models_search_label')}
placeholder={t('ai_providers.openai_models_search_placeholder')}
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
{error && <div className="error-box">{error}</div>}
{loading ? (
<div className="hint">{t('ai_providers.openai_models_fetch_loading')}</div>
) : models.length === 0 ? (
<div className="hint">{t('ai_providers.openai_models_fetch_empty')}</div>
) : filteredModels.length === 0 ? (
<div className="hint">{t('ai_providers.openai_models_search_empty')}</div>
) : (
<div className={styles.modelDiscoveryList}>
{filteredModels.map((model) => {
const checked = selected.has(model.name);
return (
<label
key={model.name}
className={`${styles.modelDiscoveryRow} ${checked ? styles.modelDiscoveryRowSelected : ''}`}
>
<input type="checkbox" checked={checked} onChange={() => toggleSelection(model.name)} />
<div className={styles.modelDiscoveryMeta}>
<div className={styles.modelDiscoveryName}>
{model.name}
{model.alias && <span className={styles.modelDiscoveryAlias}>{model.alias}</span>}
</div>
{model.description && (
<div className={styles.modelDiscoveryDesc}>{model.description}</div>
)}
</div>
</label>
);
})}
</div>
)}
</Modal>
);
}

View File

@@ -0,0 +1,432 @@
import { useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Button } from '@/components/ui/Button';
import { HeaderInputList } from '@/components/ui/HeaderInputList';
import { Input } from '@/components/ui/Input';
import { Modal } from '@/components/ui/Modal';
import { ModelInputList, modelsToEntries } from '@/components/ui/ModelInputList';
import { useNotificationStore } from '@/stores';
import { apiCallApi, getApiCallErrorMessage } from '@/services/api';
import type { OpenAIProviderConfig, ApiKeyEntry } from '@/types';
import { buildHeaderObject, headersToEntries } from '@/utils/headers';
import type { ModelInfo } from '@/utils/models';
import styles from '@/pages/AiProvidersPage.module.scss';
import { buildApiKeyEntry, buildOpenAIChatCompletionsEndpoint } from '../utils';
import type { ModelEntry, OpenAIFormState, ProviderModalProps } from '../types';
import { OpenAIDiscoveryModal } from './OpenAIDiscoveryModal';
const OPENAI_TEST_TIMEOUT_MS = 30_000;
interface OpenAIModalProps extends ProviderModalProps<OpenAIProviderConfig, OpenAIFormState> {
isSaving: boolean;
}
const buildEmptyForm = (): OpenAIFormState => ({
name: '',
prefix: '',
baseUrl: '',
headers: [],
apiKeyEntries: [buildApiKeyEntry()],
modelEntries: [{ name: '', alias: '' }],
testModel: undefined,
});
export function OpenAIModal({
isOpen,
editIndex,
initialData,
onClose,
onSave,
isSaving,
}: OpenAIModalProps) {
const { t } = useTranslation();
const { showNotification } = useNotificationStore();
const [form, setForm] = useState<OpenAIFormState>(buildEmptyForm);
const [discoveryOpen, setDiscoveryOpen] = useState(false);
const [testModel, setTestModel] = useState('');
const [testStatus, setTestStatus] = useState<'idle' | 'loading' | 'success' | 'error'>('idle');
const [testMessage, setTestMessage] = useState('');
const getErrorMessage = (err: unknown) => {
if (err instanceof Error) return err.message;
if (typeof err === 'string') return err;
return '';
};
const availableModels = useMemo(
() => form.modelEntries.map((entry) => entry.name.trim()).filter(Boolean),
[form.modelEntries]
);
useEffect(() => {
if (!isOpen) {
setDiscoveryOpen(false);
return;
}
if (initialData) {
const modelEntries = modelsToEntries(initialData.models);
setForm({
name: initialData.name,
prefix: initialData.prefix ?? '',
baseUrl: initialData.baseUrl,
headers: headersToEntries(initialData.headers),
testModel: initialData.testModel,
modelEntries,
apiKeyEntries: initialData.apiKeyEntries?.length
? initialData.apiKeyEntries
: [buildApiKeyEntry()],
});
const available = modelEntries.map((entry) => entry.name.trim()).filter(Boolean);
const initialModel =
initialData.testModel && available.includes(initialData.testModel)
? initialData.testModel
: available[0] || '';
setTestModel(initialModel);
} else {
setForm(buildEmptyForm());
setTestModel('');
}
setTestStatus('idle');
setTestMessage('');
setDiscoveryOpen(false);
}, [initialData, isOpen]);
useEffect(() => {
if (!isOpen) return;
if (availableModels.length === 0) {
if (testModel) {
setTestModel('');
setTestStatus('idle');
setTestMessage('');
}
return;
}
if (!testModel || !availableModels.includes(testModel)) {
setTestModel(availableModels[0]);
setTestStatus('idle');
setTestMessage('');
}
}, [availableModels, isOpen, testModel]);
const renderKeyEntries = (entries: ApiKeyEntry[]) => {
const list = entries.length ? entries : [buildApiKeyEntry()];
const updateEntry = (idx: number, field: keyof ApiKeyEntry, value: string) => {
const next = list.map((entry, i) => (i === idx ? { ...entry, [field]: value } : entry));
setForm((prev) => ({ ...prev, apiKeyEntries: next }));
};
const removeEntry = (idx: number) => {
const next = list.filter((_, i) => i !== idx);
setForm((prev) => ({
...prev,
apiKeyEntries: next.length ? next : [buildApiKeyEntry()],
}));
};
const addEntry = () => {
setForm((prev) => ({ ...prev, apiKeyEntries: [...list, buildApiKeyEntry()] }));
};
return (
<div className="stack">
{list.map((entry, index) => (
<div key={index} className="item-row">
<div className="item-meta">
<Input
label={`${t('common.api_key')} #${index + 1}`}
value={entry.apiKey}
onChange={(e) => updateEntry(index, 'apiKey', e.target.value)}
/>
<Input
label={t('common.proxy_url')}
value={entry.proxyUrl ?? ''}
onChange={(e) => updateEntry(index, 'proxyUrl', e.target.value)}
/>
</div>
<div className="item-actions">
<Button
variant="ghost"
size="sm"
onClick={() => removeEntry(index)}
disabled={list.length <= 1 || isSaving}
>
{t('common.delete')}
</Button>
</div>
</div>
))}
<Button variant="secondary" size="sm" onClick={addEntry} disabled={isSaving}>
{t('ai_providers.openai_keys_add_btn')}
</Button>
</div>
);
};
const openOpenaiModelDiscovery = () => {
const baseUrl = form.baseUrl.trim();
if (!baseUrl) {
showNotification(t('ai_providers.openai_models_fetch_invalid_url'), 'error');
return;
}
setDiscoveryOpen(true);
};
const applyOpenaiModelDiscoverySelection = (selectedModels: ModelInfo[]) => {
if (!selectedModels.length) {
setDiscoveryOpen(false);
return;
}
const mergedMap = new Map<string, ModelEntry>();
form.modelEntries.forEach((entry) => {
const name = entry.name.trim();
if (!name) return;
mergedMap.set(name, { name, alias: entry.alias?.trim() || '' });
});
let addedCount = 0;
selectedModels.forEach((model) => {
const name = model.name.trim();
if (!name || mergedMap.has(name)) return;
mergedMap.set(name, { name, alias: model.alias ?? '' });
addedCount += 1;
});
const mergedEntries = Array.from(mergedMap.values());
setForm((prev) => ({
...prev,
modelEntries: mergedEntries.length ? mergedEntries : [{ name: '', alias: '' }],
}));
setDiscoveryOpen(false);
if (addedCount > 0) {
showNotification(t('ai_providers.openai_models_fetch_added', { count: addedCount }), 'success');
}
};
const testOpenaiProviderConnection = async () => {
const baseUrl = form.baseUrl.trim();
if (!baseUrl) {
const message = t('notification.openai_test_url_required');
setTestStatus('error');
setTestMessage(message);
showNotification(message, 'error');
return;
}
const endpoint = buildOpenAIChatCompletionsEndpoint(baseUrl);
if (!endpoint) {
const message = t('notification.openai_test_url_required');
setTestStatus('error');
setTestMessage(message);
showNotification(message, 'error');
return;
}
const firstKeyEntry = form.apiKeyEntries.find((entry) => entry.apiKey?.trim());
if (!firstKeyEntry) {
const message = t('notification.openai_test_key_required');
setTestStatus('error');
setTestMessage(message);
showNotification(message, 'error');
return;
}
const modelName = testModel.trim() || availableModels[0] || '';
if (!modelName) {
const message = t('notification.openai_test_model_required');
setTestStatus('error');
setTestMessage(message);
showNotification(message, 'error');
return;
}
const customHeaders = buildHeaderObject(form.headers);
const headers: Record<string, string> = {
'Content-Type': 'application/json',
...customHeaders,
};
if (!headers.Authorization && !headers['authorization']) {
headers.Authorization = `Bearer ${firstKeyEntry.apiKey.trim()}`;
}
setTestStatus('loading');
setTestMessage(t('ai_providers.openai_test_running'));
try {
const result = await apiCallApi.request(
{
method: 'POST',
url: endpoint,
header: Object.keys(headers).length ? headers : undefined,
data: JSON.stringify({
model: modelName,
messages: [{ role: 'user', content: 'Hi' }],
stream: false,
max_tokens: 5,
}),
},
{ timeout: OPENAI_TEST_TIMEOUT_MS }
);
if (result.statusCode < 200 || result.statusCode >= 300) {
throw new Error(getApiCallErrorMessage(result));
}
setTestStatus('success');
setTestMessage(t('ai_providers.openai_test_success'));
} catch (err: unknown) {
setTestStatus('error');
const message = getErrorMessage(err);
const errorCode =
typeof err === 'object' && err !== null && 'code' in err ? String((err as { code?: string }).code) : '';
const isTimeout =
errorCode === 'ECONNABORTED' || message.toLowerCase().includes('timeout');
if (isTimeout) {
setTestMessage(t('ai_providers.openai_test_timeout', { seconds: OPENAI_TEST_TIMEOUT_MS / 1000 }));
} else {
setTestMessage(`${t('ai_providers.openai_test_failed')}: ${message}`);
}
}
};
return (
<>
<Modal
open={isOpen}
onClose={onClose}
title={
editIndex !== null
? t('ai_providers.openai_edit_modal_title')
: t('ai_providers.openai_add_modal_title')
}
footer={
<>
<Button variant="secondary" onClick={onClose} disabled={isSaving}>
{t('common.cancel')}
</Button>
<Button onClick={() => void onSave(form, editIndex)} loading={isSaving}>
{t('common.save')}
</Button>
</>
}
>
<Input
label={t('ai_providers.openai_add_modal_name_label')}
value={form.name}
onChange={(e) => setForm((prev) => ({ ...prev, name: e.target.value }))}
/>
<Input
label={t('ai_providers.prefix_label')}
placeholder={t('ai_providers.prefix_placeholder')}
value={form.prefix ?? ''}
onChange={(e) => setForm((prev) => ({ ...prev, prefix: e.target.value }))}
hint={t('ai_providers.prefix_hint')}
/>
<Input
label={t('ai_providers.openai_add_modal_url_label')}
value={form.baseUrl}
onChange={(e) => setForm((prev) => ({ ...prev, baseUrl: e.target.value }))}
/>
<HeaderInputList
entries={form.headers}
onChange={(entries) => setForm((prev) => ({ ...prev, headers: entries }))}
addLabel={t('common.custom_headers_add')}
keyPlaceholder={t('common.custom_headers_key_placeholder')}
valuePlaceholder={t('common.custom_headers_value_placeholder')}
/>
<div className="form-group">
<label>
{editIndex !== null
? t('ai_providers.openai_edit_modal_models_label')
: t('ai_providers.openai_add_modal_models_label')}
</label>
<div className="hint">{t('ai_providers.openai_models_hint')}</div>
<ModelInputList
entries={form.modelEntries}
onChange={(entries) => setForm((prev) => ({ ...prev, modelEntries: entries }))}
addLabel={t('ai_providers.openai_models_add_btn')}
namePlaceholder={t('common.model_name_placeholder')}
aliasPlaceholder={t('common.model_alias_placeholder')}
disabled={isSaving}
/>
<Button variant="secondary" size="sm" onClick={openOpenaiModelDiscovery} disabled={isSaving}>
{t('ai_providers.openai_models_fetch_button')}
</Button>
</div>
<div className="form-group">
<label>{t('ai_providers.openai_test_title')}</label>
<div className="hint">{t('ai_providers.openai_test_hint')}</div>
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
<select
className={`input ${styles.openaiTestSelect}`}
value={testModel}
onChange={(e) => {
setTestModel(e.target.value);
setTestStatus('idle');
setTestMessage('');
}}
disabled={isSaving || availableModels.length === 0}
>
<option value="">
{availableModels.length
? t('ai_providers.openai_test_select_placeholder')
: t('ai_providers.openai_test_select_empty')}
</option>
{form.modelEntries
.filter((entry) => entry.name.trim())
.map((entry, idx) => {
const name = entry.name.trim();
const alias = entry.alias.trim();
const label = alias && alias !== name ? `${name} (${alias})` : name;
return (
<option key={`${name}-${idx}`} value={name}>
{label}
</option>
);
})}
</select>
<Button
variant={testStatus === 'error' ? 'danger' : 'secondary'}
className={`${styles.openaiTestButton} ${testStatus === 'success' ? styles.openaiTestButtonSuccess : ''}`}
onClick={testOpenaiProviderConnection}
loading={testStatus === 'loading'}
disabled={isSaving || availableModels.length === 0}
>
{t('ai_providers.openai_test_action')}
</Button>
</div>
{testMessage && (
<div
className={`status-badge ${
testStatus === 'error' ? 'error' : testStatus === 'success' ? 'success' : 'muted'
}`}
>
{testMessage}
</div>
)}
</div>
<div className="form-group">
<label>{t('ai_providers.openai_add_modal_keys_label')}</label>
{renderKeyEntries(form.apiKeyEntries)}
</div>
</Modal>
<OpenAIDiscoveryModal
isOpen={discoveryOpen}
baseUrl={form.baseUrl}
headers={form.headers}
apiKeyEntries={form.apiKeyEntries}
onClose={() => setDiscoveryOpen(false)}
onApply={applyOpenaiModelDiscoverySelection}
/>
</>
);
}

View File

@@ -0,0 +1,206 @@
import { Fragment, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { Button } from '@/components/ui/Button';
import { Card } from '@/components/ui/Card';
import { IconCheck, IconX } from '@/components/ui/icons';
import iconOpenaiLight from '@/assets/icons/openai-light.svg';
import iconOpenaiDark from '@/assets/icons/openai-dark.svg';
import type { OpenAIProviderConfig } from '@/types';
import { maskApiKey } from '@/utils/format';
import { calculateStatusBarData, type KeyStats, type UsageDetail } from '@/utils/usage';
import styles from '@/pages/AiProvidersPage.module.scss';
import { ProviderList } from '../ProviderList';
import { ProviderStatusBar } from '../ProviderStatusBar';
import { getOpenAIProviderStats, getStatsBySource } from '../utils';
import type { OpenAIFormState } from '../types';
import { OpenAIModal } from './OpenAIModal';
interface OpenAISectionProps {
configs: OpenAIProviderConfig[];
keyStats: KeyStats;
usageDetails: UsageDetail[];
loading: boolean;
disableControls: boolean;
isSaving: boolean;
isSwitching: boolean;
resolvedTheme: string;
isModalOpen: boolean;
modalIndex: number | null;
onAdd: () => void;
onEdit: (index: number) => void;
onDelete: (index: number) => void;
onCloseModal: () => void;
onSave: (data: OpenAIFormState, index: number | null) => Promise<void>;
}
export function OpenAISection({
configs,
keyStats,
usageDetails,
loading,
disableControls,
isSaving,
isSwitching,
resolvedTheme,
isModalOpen,
modalIndex,
onAdd,
onEdit,
onDelete,
onCloseModal,
onSave,
}: OpenAISectionProps) {
const { t } = useTranslation();
const actionsDisabled = disableControls || isSaving || isSwitching;
const statusBarCache = useMemo(() => {
const cache = new Map<string, ReturnType<typeof calculateStatusBarData>>();
configs.forEach((provider) => {
const allKeys = (provider.apiKeyEntries || []).map((entry) => entry.apiKey).filter(Boolean);
const filteredDetails = usageDetails.filter((detail) => allKeys.includes(detail.source));
cache.set(provider.name, calculateStatusBarData(filteredDetails));
});
return cache;
}, [configs, usageDetails]);
const initialData = modalIndex !== null ? configs[modalIndex] : undefined;
return (
<>
<Card
title={
<span className={styles.cardTitle}>
<img
src={resolvedTheme === 'dark' ? iconOpenaiDark : iconOpenaiLight}
alt=""
className={styles.cardTitleIcon}
/>
{t('ai_providers.openai_title')}
</span>
}
extra={
<Button size="sm" onClick={onAdd} disabled={actionsDisabled}>
{t('ai_providers.openai_add_button')}
</Button>
}
>
<ProviderList<OpenAIProviderConfig>
items={configs}
loading={loading}
keyField={(item) => item.name}
emptyTitle={t('ai_providers.openai_empty_title')}
emptyDescription={t('ai_providers.openai_empty_desc')}
onEdit={onEdit}
onDelete={onDelete}
actionsDisabled={actionsDisabled}
renderContent={(item) => {
const stats = getOpenAIProviderStats(item.apiKeyEntries, keyStats, maskApiKey);
const headerEntries = Object.entries(item.headers || {});
const apiKeyEntries = item.apiKeyEntries || [];
const statusData = statusBarCache.get(item.name) || calculateStatusBarData([]);
return (
<Fragment>
<div className="item-title">{item.name}</div>
{item.prefix && (
<div className={styles.fieldRow}>
<span className={styles.fieldLabel}>{t('common.prefix')}:</span>
<span className={styles.fieldValue}>{item.prefix}</span>
</div>
)}
<div className={styles.fieldRow}>
<span className={styles.fieldLabel}>{t('common.base_url')}:</span>
<span className={styles.fieldValue}>{item.baseUrl}</span>
</div>
{headerEntries.length > 0 && (
<div className={styles.headerBadgeList}>
{headerEntries.map(([key, value]) => (
<span key={key} className={styles.headerBadge}>
<strong>{key}:</strong> {value}
</span>
))}
</div>
)}
{apiKeyEntries.length > 0 && (
<div className={styles.apiKeyEntriesSection}>
<div className={styles.apiKeyEntriesLabel}>
{t('ai_providers.openai_keys_count')}: {apiKeyEntries.length}
</div>
<div className={styles.apiKeyEntryList}>
{apiKeyEntries.map((entry, entryIndex) => {
const entryStats = getStatsBySource(entry.apiKey, keyStats, maskApiKey);
return (
<div key={entryIndex} className={styles.apiKeyEntryCard}>
<span className={styles.apiKeyEntryIndex}>{entryIndex + 1}</span>
<span className={styles.apiKeyEntryKey}>{maskApiKey(entry.apiKey)}</span>
{entry.proxyUrl && (
<span className={styles.apiKeyEntryProxy}>{entry.proxyUrl}</span>
)}
<div className={styles.apiKeyEntryStats}>
<span
className={`${styles.apiKeyEntryStat} ${styles.apiKeyEntryStatSuccess}`}
>
<IconCheck size={12} /> {entryStats.success}
</span>
<span
className={`${styles.apiKeyEntryStat} ${styles.apiKeyEntryStatFailure}`}
>
<IconX size={12} /> {entryStats.failure}
</span>
</div>
</div>
);
})}
</div>
</div>
)}
<div className={styles.fieldRow} style={{ marginTop: '8px' }}>
<span className={styles.fieldLabel}>{t('ai_providers.openai_models_count')}:</span>
<span className={styles.fieldValue}>{item.models?.length || 0}</span>
</div>
{item.models?.length ? (
<div className={styles.modelTagList}>
{item.models.map((model) => (
<span key={model.name} className={styles.modelTag}>
<span className={styles.modelName}>{model.name}</span>
{model.alias && model.alias !== model.name && (
<span className={styles.modelAlias}>{model.alias}</span>
)}
</span>
))}
</div>
) : null}
{item.testModel && (
<div className={styles.fieldRow}>
<span className={styles.fieldLabel}>Test Model:</span>
<span className={styles.fieldValue}>{item.testModel}</span>
</div>
)}
<div className={styles.cardStats}>
<span className={`${styles.statPill} ${styles.statSuccess}`}>
{t('stats.success')}: {stats.success}
</span>
<span className={`${styles.statPill} ${styles.statFailure}`}>
{t('stats.failure')}: {stats.failure}
</span>
</div>
<ProviderStatusBar statusData={statusData} />
</Fragment>
);
}}
/>
</Card>
<OpenAIModal
isOpen={isModalOpen}
editIndex={modalIndex}
initialData={initialData}
onClose={onCloseModal}
onSave={onSave}
isSaving={isSaving}
/>
</>
);
}

View File

@@ -0,0 +1 @@
export { OpenAISection } from './OpenAISection';

View File

@@ -0,0 +1,80 @@
import type { ReactNode } from 'react';
import { useTranslation } from 'react-i18next';
import { Button } from '@/components/ui/Button';
import { EmptyState } from '@/components/ui/EmptyState';
interface ProviderListProps<T> {
items: T[];
loading: boolean;
keyField: (item: T) => string;
renderContent: (item: T, index: number) => ReactNode;
onEdit: (index: number) => void;
onDelete: (index: number) => void;
emptyTitle: string;
emptyDescription: string;
deleteLabel?: string;
actionsDisabled?: boolean;
getRowDisabled?: (item: T, index: number) => boolean;
renderExtraActions?: (item: T, index: number) => ReactNode;
}
export function ProviderList<T>({
items,
loading,
keyField,
renderContent,
onEdit,
onDelete,
emptyTitle,
emptyDescription,
deleteLabel,
actionsDisabled = false,
getRowDisabled,
renderExtraActions,
}: ProviderListProps<T>) {
const { t } = useTranslation();
if (loading) {
return <div className="hint">{t('common.loading')}</div>;
}
if (!items.length) {
return <EmptyState title={emptyTitle} description={emptyDescription} />;
}
return (
<div className="item-list">
{items.map((item, index) => {
const rowDisabled = getRowDisabled ? getRowDisabled(item, index) : false;
return (
<div
key={keyField(item)}
className="item-row"
style={rowDisabled ? { opacity: 0.6 } : undefined}
>
<div className="item-meta">{renderContent(item, index)}</div>
<div className="item-actions">
<Button
variant="secondary"
size="sm"
onClick={() => onEdit(index)}
disabled={actionsDisabled}
>
{t('common.edit')}
</Button>
<Button
variant="danger"
size="sm"
onClick={() => onDelete(index)}
disabled={actionsDisabled}
>
{deleteLabel || t('common.delete')}
</Button>
{renderExtraActions ? renderExtraActions(item, index) : null}
</div>
</div>
);
})}
</div>
);
}

View File

@@ -0,0 +1,38 @@
import { calculateStatusBarData } from '@/utils/usage';
import styles from '@/pages/AiProvidersPage.module.scss';
interface ProviderStatusBarProps {
statusData: ReturnType<typeof calculateStatusBarData>;
}
export function ProviderStatusBar({ statusData }: ProviderStatusBarProps) {
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>
);
}

View File

@@ -0,0 +1,37 @@
import { useCallback, useRef, useState } from 'react';
import { useInterval } from '@/hooks/useInterval';
import { usageApi } from '@/services/api';
import { collectUsageDetails, type KeyStats, type UsageDetail } from '@/utils/usage';
const EMPTY_STATS: KeyStats = { bySource: {}, byAuthIndex: {} };
export const useProviderStats = () => {
const [keyStats, setKeyStats] = useState<KeyStats>(EMPTY_STATS);
const [usageDetails, setUsageDetails] = useState<UsageDetail[]>([]);
const [isLoading, setIsLoading] = useState(false);
const loadingRef = useRef(false);
// 加载 key 统计和 usage 明细API 层已有60秒超时
const loadKeyStats = useCallback(async () => {
if (loadingRef.current) return;
loadingRef.current = true;
setIsLoading(true);
try {
const usageResponse = await usageApi.getUsage();
const usageData = usageResponse?.usage ?? usageResponse;
const stats = await usageApi.getKeyStats(usageData);
setKeyStats(stats);
setUsageDetails(collectUsageDetails(usageData));
} catch {
// 静默失败
} finally {
loadingRef.current = false;
setIsLoading(false);
}
}, []);
// 定时刷新状态数据每240秒
useInterval(loadKeyStats, 240_000);
return { keyStats, usageDetails, loadKeyStats, isLoading };
};

View File

@@ -0,0 +1,10 @@
export { AmpcodeSection } from './AmpcodeSection';
export { ClaudeSection } from './ClaudeSection';
export { CodexSection } from './CodexSection';
export { GeminiSection } from './GeminiSection';
export { OpenAISection } from './OpenAISection';
export { ProviderList } from './ProviderList';
export { ProviderStatusBar } from './ProviderStatusBar';
export * from './hooks/useProviderStats';
export * from './types';
export * from './utils';

View File

@@ -0,0 +1,59 @@
import type { ApiKeyEntry, GeminiKeyConfig, ProviderKeyConfig } from '@/types';
import type { HeaderEntry } from '@/utils/headers';
import type { KeyStats, UsageDetail } from '@/utils/usage';
export type ProviderModal =
| { type: 'gemini'; index: number | null }
| { type: 'codex'; index: number | null }
| { type: 'claude'; index: number | null }
| { type: 'ampcode'; index: null }
| { type: 'openai'; index: number | null };
export interface ModelEntry {
name: string;
alias: string;
}
export interface OpenAIFormState {
name: string;
prefix: string;
baseUrl: string;
headers: HeaderEntry[];
testModel?: string;
modelEntries: ModelEntry[];
apiKeyEntries: ApiKeyEntry[];
}
export interface AmpcodeFormState {
upstreamUrl: string;
upstreamApiKey: string;
forceModelMappings: boolean;
mappingEntries: ModelEntry[];
}
export type GeminiFormState = GeminiKeyConfig & { excludedText: string };
export type ProviderFormState = ProviderKeyConfig & {
modelEntries: ModelEntry[];
excludedText: string;
};
export interface ProviderSectionProps<TConfig> {
configs: TConfig[];
keyStats: KeyStats;
usageDetails: UsageDetail[];
disabled: boolean;
onEdit: (index: number) => void;
onAdd: () => void;
onDelete: (index: number) => void;
onToggle?: (index: number, enabled: boolean) => void;
}
export interface ProviderModalProps<TConfig, TPayload = TConfig> {
isOpen: boolean;
editIndex: number | null;
initialData?: TConfig;
onClose: () => void;
onSave: (data: TPayload, index: number | null) => Promise<void>;
disabled?: boolean;
}

View File

@@ -0,0 +1,132 @@
import type { AmpcodeConfig, AmpcodeModelMapping, ApiKeyEntry } from '@/types';
import type { KeyStatBucket, KeyStats } from '@/utils/usage';
import type { AmpcodeFormState, ModelEntry } from './types';
export const DISABLE_ALL_MODELS_RULE = '*';
export const hasDisableAllModelsRule = (models?: string[]) =>
Array.isArray(models) &&
models.some((model) => String(model ?? '').trim() === DISABLE_ALL_MODELS_RULE);
export const stripDisableAllModelsRule = (models?: string[]) =>
Array.isArray(models)
? models.filter((model) => String(model ?? '').trim() !== DISABLE_ALL_MODELS_RULE)
: [];
export const withDisableAllModelsRule = (models?: string[]) => {
const base = stripDisableAllModelsRule(models);
return [...base, DISABLE_ALL_MODELS_RULE];
};
export const withoutDisableAllModelsRule = (models?: string[]) => {
const base = stripDisableAllModelsRule(models);
return base;
};
export const parseExcludedModels = (text: string): string[] =>
text
.split(/[\n,]+/)
.map((item) => item.trim())
.filter(Boolean);
export const excludedModelsToText = (models?: string[]) =>
Array.isArray(models) ? models.join('\n') : '';
export 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;
};
export const buildOpenAIModelsEndpoint = (baseUrl: string): string => {
const trimmed = normalizeOpenAIBaseUrl(baseUrl);
if (!trimmed) return '';
return trimmed.endsWith('/v1') ? `${trimmed}/models` : `${trimmed}/v1/models`;
};
export const buildOpenAIChatCompletionsEndpoint = (baseUrl: string): string => {
const trimmed = normalizeOpenAIBaseUrl(baseUrl);
if (!trimmed) return '';
if (trimmed.endsWith('/chat/completions')) {
return trimmed;
}
return trimmed.endsWith('/v1') ? `${trimmed}/chat/completions` : `${trimmed}/v1/chat/completions`;
};
// 根据 source (apiKey) 获取统计数据 - 与旧版逻辑一致
export const getStatsBySource = (
apiKey: string,
keyStats: KeyStats,
maskFn: (key: string) => string
): KeyStatBucket => {
const bySource = keyStats.bySource ?? {};
const masked = maskFn(apiKey);
return bySource[apiKey] || bySource[masked] || { success: 0, failure: 0 };
};
// 对于 OpenAI 提供商,汇总所有 apiKeyEntries 的统计 - 与旧版逻辑一致
export const getOpenAIProviderStats = (
apiKeyEntries: ApiKeyEntry[] | undefined,
keyStats: KeyStats,
maskFn: (key: string) => string
): KeyStatBucket => {
const bySource = keyStats.bySource ?? {};
let totalSuccess = 0;
let totalFailure = 0;
(apiKeyEntries || []).forEach((entry) => {
const key = entry?.apiKey || '';
if (!key) return;
const masked = maskFn(key);
const stats = bySource[key] || bySource[masked] || { success: 0, failure: 0 };
totalSuccess += stats.success;
totalFailure += stats.failure;
});
return { success: totalSuccess, failure: totalFailure };
};
export const buildApiKeyEntry = (input?: Partial<ApiKeyEntry>): ApiKeyEntry => ({
apiKey: input?.apiKey ?? '',
proxyUrl: input?.proxyUrl ?? '',
headers: input?.headers ?? {},
});
export const ampcodeMappingsToEntries = (mappings?: AmpcodeModelMapping[]): ModelEntry[] => {
if (!Array.isArray(mappings) || mappings.length === 0) {
return [{ name: '', alias: '' }];
}
return mappings.map((mapping) => ({
name: mapping.from ?? '',
alias: mapping.to ?? '',
}));
};
export const entriesToAmpcodeMappings = (entries: ModelEntry[]): AmpcodeModelMapping[] => {
const seen = new Set<string>();
const mappings: AmpcodeModelMapping[] = [];
entries.forEach((entry) => {
const from = entry.name.trim();
const to = entry.alias.trim();
if (!from || !to) return;
const key = from.toLowerCase();
if (seen.has(key)) return;
seen.add(key);
mappings.push({ from, to });
});
return mappings;
};
export const buildAmpcodeFormState = (ampcode?: AmpcodeConfig | null): AmpcodeFormState => ({
upstreamUrl: ampcode?.upstreamUrl ?? '',
upstreamApiKey: '',
forceModelMappings: ampcode?.forceModelMappings ?? false,
mappingEntries: ampcodeMappingsToEntries(ampcode?.modelMappings),
});

View File

@@ -0,0 +1,145 @@
/**
* Generic quota card component.
*/
import { useTranslation } from 'react-i18next';
import type { ReactElement, ReactNode } from 'react';
import type { TFunction } from 'i18next';
import type { AuthFileItem, ResolvedTheme, ThemeColors } from '@/types';
import { TYPE_COLORS } from '@/utils/quota';
import styles from '@/pages/QuotaPage.module.scss';
type QuotaStatus = 'idle' | 'loading' | 'success' | 'error';
export interface QuotaStatusState {
status: QuotaStatus;
error?: string;
errorStatus?: number;
}
export interface QuotaProgressBarProps {
percent: number | null;
highThreshold: number;
mediumThreshold: number;
}
export function QuotaProgressBar({
percent,
highThreshold,
mediumThreshold
}: QuotaProgressBarProps) {
const clamp = (value: number, min: number, max: number) =>
Math.min(max, Math.max(min, value));
const normalized = percent === null ? null : clamp(percent, 0, 100);
const fillClass =
normalized === null
? styles.quotaBarFillMedium
: normalized >= highThreshold
? styles.quotaBarFillHigh
: normalized >= mediumThreshold
? styles.quotaBarFillMedium
: styles.quotaBarFillLow;
const widthPercent = Math.round(normalized ?? 0);
return (
<div className={styles.quotaBar}>
<div
className={`${styles.quotaBarFill} ${fillClass}`}
style={{ width: `${widthPercent}%` }}
/>
</div>
);
}
export interface QuotaRenderHelpers {
styles: typeof styles;
QuotaProgressBar: (props: QuotaProgressBarProps) => ReactElement;
}
interface QuotaCardProps<TState extends QuotaStatusState> {
item: AuthFileItem;
quota?: TState;
resolvedTheme: ResolvedTheme;
i18nPrefix: string;
cardClassName: string;
defaultType: string;
renderQuotaItems: (quota: TState, t: TFunction, helpers: QuotaRenderHelpers) => ReactNode;
}
export function QuotaCard<TState extends QuotaStatusState>({
item,
quota,
resolvedTheme,
i18nPrefix,
cardClassName,
defaultType,
renderQuotaItems
}: QuotaCardProps<TState>) {
const { t } = useTranslation();
const displayType = item.type || item.provider || defaultType;
const typeColorSet = TYPE_COLORS[displayType] || TYPE_COLORS.unknown;
const typeColor: ThemeColors =
resolvedTheme === 'dark' && typeColorSet.dark ? typeColorSet.dark : typeColorSet.light;
const quotaStatus = quota?.status ?? 'idle';
const quotaErrorMessage = resolveQuotaErrorMessage(
t,
quota?.errorStatus,
quota?.error || t('common.unknown_error')
);
const getTypeLabel = (type: string): string => {
const key = `auth_files.filter_${type}`;
const translated = t(key);
if (translated !== key) return translated;
if (type.toLowerCase() === 'iflow') return 'iFlow';
return type.charAt(0).toUpperCase() + type.slice(1);
};
return (
<div className={`${styles.fileCard} ${cardClassName}`}>
<div className={styles.cardHeader}>
<span
className={styles.typeBadge}
style={{
backgroundColor: typeColor.bg,
color: typeColor.text,
...(typeColor.border ? { border: typeColor.border } : {})
}}
>
{getTypeLabel(displayType)}
</span>
<span className={styles.fileName}>{item.name}</span>
</div>
<div className={styles.quotaSection}>
{quotaStatus === 'loading' ? (
<div className={styles.quotaMessage}>{t(`${i18nPrefix}.loading`)}</div>
) : quotaStatus === 'idle' ? (
<div className={styles.quotaMessage}>{t(`${i18nPrefix}.idle`)}</div>
) : quotaStatus === 'error' ? (
<div className={styles.quotaError}>
{t(`${i18nPrefix}.load_failed`, {
message: quotaErrorMessage
})}
</div>
) : quota ? (
renderQuotaItems(quota, t, { styles, QuotaProgressBar })
) : (
<div className={styles.quotaMessage}>{t(`${i18nPrefix}.idle`)}</div>
)}
</div>
</div>
);
}
const resolveQuotaErrorMessage = (
t: TFunction,
status: number | undefined,
fallback: string
): string => {
if (status === 404) return t('common.quota_update_required');
if (status === 403) return t('common.quota_check_credential');
return fallback;
};

View File

@@ -0,0 +1,258 @@
/**
* Generic quota section component.
*/
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Card } from '@/components/ui/Card';
import { Button } from '@/components/ui/Button';
import { EmptyState } from '@/components/ui/EmptyState';
import { useQuotaStore, useThemeStore } from '@/stores';
import type { AuthFileItem, ResolvedTheme } from '@/types';
import { QuotaCard } from './QuotaCard';
import type { QuotaStatusState } from './QuotaCard';
import { useQuotaLoader } from './useQuotaLoader';
import type { QuotaConfig } from './quotaConfigs';
import styles from '@/pages/QuotaPage.module.scss';
type QuotaUpdater<T> = T | ((prev: T) => T);
type QuotaSetter<T> = (updater: QuotaUpdater<T>) => void;
const MIN_CARD_PAGE_SIZE = 3;
const MAX_CARD_PAGE_SIZE = 30;
const clampCardPageSize = (value: number) =>
Math.min(MAX_CARD_PAGE_SIZE, Math.max(MIN_CARD_PAGE_SIZE, Math.round(value)));
interface QuotaPaginationState<T> {
pageSize: number;
totalPages: number;
currentPage: number;
pageItems: T[];
setPageSize: (size: number) => void;
goToPrev: () => void;
goToNext: () => void;
loading: boolean;
loadingScope: 'page' | 'all' | null;
setLoading: (loading: boolean, scope?: 'page' | 'all' | null) => void;
}
const useQuotaPagination = <T,>(items: T[], defaultPageSize = 6): QuotaPaginationState<T> => {
const [page, setPage] = useState(1);
const [pageSize, setPageSizeState] = useState(() => clampCardPageSize(defaultPageSize));
const [loading, setLoadingState] = useState(false);
const [loadingScope, setLoadingScope] = useState<'page' | 'all' | null>(null);
const totalPages = useMemo(
() => Math.max(1, Math.ceil(items.length / pageSize)),
[items.length, pageSize]
);
const currentPage = useMemo(() => Math.min(page, totalPages), [page, totalPages]);
const pageItems = useMemo(() => {
const start = (currentPage - 1) * pageSize;
return items.slice(start, start + pageSize);
}, [items, currentPage, pageSize]);
const setPageSize = useCallback((size: number) => {
setPageSizeState(clampCardPageSize(size));
setPage(1);
}, []);
const goToPrev = useCallback(() => {
setPage((prev) => Math.max(1, prev - 1));
}, []);
const goToNext = useCallback(() => {
setPage((prev) => Math.min(totalPages, prev + 1));
}, [totalPages]);
const setLoading = useCallback((isLoading: boolean, scope?: 'page' | 'all' | null) => {
setLoadingState(isLoading);
setLoadingScope(isLoading ? (scope ?? null) : null);
}, []);
return {
pageSize,
totalPages,
currentPage,
pageItems,
setPageSize,
goToPrev,
goToNext,
loading,
loadingScope,
setLoading
};
};
interface QuotaSectionProps<TState extends QuotaStatusState, TData> {
config: QuotaConfig<TState, TData>;
files: AuthFileItem[];
loading: boolean;
disabled: boolean;
}
export function QuotaSection<TState extends QuotaStatusState, TData>({
config,
files,
loading,
disabled
}: QuotaSectionProps<TState, TData>) {
const { t } = useTranslation();
const resolvedTheme: ResolvedTheme = useThemeStore((state) => state.resolvedTheme);
const setQuota = useQuotaStore((state) => state[config.storeSetter]) as QuotaSetter<
Record<string, TState>
>;
const filteredFiles = useMemo(() => files.filter((file) => config.filterFn(file)), [
files,
config.filterFn
]);
const {
pageSize,
totalPages,
currentPage,
pageItems,
setPageSize,
goToPrev,
goToNext,
loading: sectionLoading,
loadingScope,
setLoading
} = useQuotaPagination(filteredFiles);
const { quota, loadQuota } = useQuotaLoader(config);
const handleRefreshPage = useCallback(() => {
loadQuota(pageItems, 'page', setLoading);
}, [loadQuota, pageItems, setLoading]);
const handleRefreshAll = useCallback(() => {
loadQuota(filteredFiles, 'all', setLoading);
}, [loadQuota, filteredFiles, setLoading]);
useEffect(() => {
if (loading) return;
if (filteredFiles.length === 0) {
setQuota({});
return;
}
setQuota((prev) => {
const nextState: Record<string, TState> = {};
filteredFiles.forEach((file) => {
const cached = prev[file.name];
if (cached) {
nextState[file.name] = cached;
}
});
return nextState;
});
}, [filteredFiles, loading, setQuota]);
return (
<Card
title={t(`${config.i18nPrefix}.title`)}
extra={
<div className={styles.headerActions}>
<Button
variant="secondary"
size="sm"
onClick={handleRefreshPage}
disabled={disabled || sectionLoading || pageItems.length === 0}
loading={sectionLoading && loadingScope === 'page'}
>
{t(`${config.i18nPrefix}.refresh_button`)}
</Button>
<Button
variant="secondary"
size="sm"
onClick={handleRefreshAll}
disabled={disabled || sectionLoading || filteredFiles.length === 0}
loading={sectionLoading && loadingScope === 'all'}
>
{t(`${config.i18nPrefix}.fetch_all`)}
</Button>
</div>
}
>
{filteredFiles.length === 0 ? (
<EmptyState
title={t(`${config.i18nPrefix}.empty_title`)}
description={t(`${config.i18nPrefix}.empty_desc`)}
/>
) : (
<>
<div className={config.controlsClassName}>
<div className={config.controlClassName}>
<label>{t('auth_files.page_size_label')}</label>
<input
className={styles.pageSizeSelect}
type="number"
min={MIN_CARD_PAGE_SIZE}
max={MAX_CARD_PAGE_SIZE}
step={1}
value={pageSize}
onChange={(e) => {
const value = e.currentTarget.valueAsNumber;
if (!Number.isFinite(value)) return;
setPageSize(value);
}}
/>
</div>
<div className={config.controlClassName}>
<label>{t('common.info')}</label>
<div className={styles.statsInfo}>
{filteredFiles.length} {t('auth_files.files_count')}
</div>
</div>
</div>
<div className={config.gridClassName}>
{pageItems.map((item) => (
<QuotaCard
key={item.name}
item={item}
quota={quota[item.name]}
resolvedTheme={resolvedTheme}
i18nPrefix={config.i18nPrefix}
cardClassName={config.cardClassName}
defaultType={config.type}
renderQuotaItems={config.renderQuotaItems}
/>
))}
</div>
{filteredFiles.length > pageSize && (
<div className={styles.pagination}>
<Button
variant="secondary"
size="sm"
onClick={goToPrev}
disabled={currentPage <= 1}
>
{t('auth_files.pagination_prev')}
</Button>
<div className={styles.pageInfo}>
{t('auth_files.pagination_info', {
current: currentPage,
total: totalPages,
count: filteredFiles.length
})}
</div>
<Button
variant="secondary"
size="sm"
onClick={goToNext}
disabled={currentPage >= totalPages}
>
{t('auth_files.pagination_next')}
</Button>
</div>
)}
</>
)}
</Card>
);
}

View File

@@ -0,0 +1,9 @@
/**
* Quota components barrel export.
*/
export { QuotaSection } from './QuotaSection';
export { QuotaCard } from './QuotaCard';
export { useQuotaLoader } from './useQuotaLoader';
export { ANTIGRAVITY_CONFIG, CODEX_CONFIG, GEMINI_CLI_CONFIG } from './quotaConfigs';
export type { QuotaConfig } from './quotaConfigs';

View File

@@ -0,0 +1,553 @@
/**
* Quota configuration definitions.
*/
import React from 'react';
import type { ReactNode } from 'react';
import type { TFunction } from 'i18next';
import type {
AntigravityQuotaGroup,
AntigravityModelsPayload,
AntigravityQuotaState,
AuthFileItem,
CodexQuotaState,
CodexUsageWindow,
CodexQuotaWindow,
CodexUsagePayload,
GeminiCliParsedBucket,
GeminiCliQuotaBucketState,
GeminiCliQuotaState
} from '@/types';
import { apiCallApi, getApiCallErrorMessage } from '@/services/api';
import {
ANTIGRAVITY_QUOTA_URLS,
ANTIGRAVITY_REQUEST_HEADERS,
CODEX_USAGE_URL,
CODEX_REQUEST_HEADERS,
GEMINI_CLI_QUOTA_URL,
GEMINI_CLI_REQUEST_HEADERS,
normalizeAuthIndexValue,
normalizeNumberValue,
normalizePlanType,
normalizeQuotaFraction,
normalizeStringValue,
parseAntigravityPayload,
parseCodexUsagePayload,
parseGeminiCliQuotaPayload,
resolveCodexChatgptAccountId,
resolveCodexPlanType,
resolveGeminiCliProjectId,
formatCodexResetLabel,
formatQuotaResetTime,
buildAntigravityQuotaGroups,
buildGeminiCliQuotaBuckets,
createStatusError,
getStatusFromError,
isAntigravityFile,
isCodexFile,
isGeminiCliFile,
isRuntimeOnlyAuthFile
} from '@/utils/quota';
import type { QuotaRenderHelpers } from './QuotaCard';
import styles from '@/pages/QuotaPage.module.scss';
type QuotaUpdater<T> = T | ((prev: T) => T);
type QuotaType = 'antigravity' | 'codex' | 'gemini-cli';
export interface QuotaStore {
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;
}
export interface QuotaConfig<TState, TData> {
type: QuotaType;
i18nPrefix: string;
filterFn: (file: AuthFileItem) => boolean;
fetchQuota: (file: AuthFileItem, t: TFunction) => Promise<TData>;
storeSelector: (state: QuotaStore) => Record<string, TState>;
storeSetter: keyof QuotaStore;
buildLoadingState: () => TState;
buildSuccessState: (data: TData) => TState;
buildErrorState: (message: string, status?: number) => TState;
cardClassName: string;
controlsClassName: string;
controlClassName: string;
gridClassName: string;
renderQuotaItems: (quota: TState, t: TFunction, helpers: QuotaRenderHelpers) => ReactNode;
}
const fetchAntigravityQuota = async (
file: AuthFileItem,
t: TFunction
): Promise<AntigravityQuotaGroup[]> => {
const rawAuthIndex = file['auth_index'] ?? file.authIndex;
const authIndex = normalizeAuthIndexValue(rawAuthIndex);
if (!authIndex) {
throw new Error(t('antigravity_quota.missing_auth_index'));
}
let lastError = '';
let lastStatus: number | undefined;
let priorityStatus: number | undefined;
let hadSuccess = false;
for (const url of ANTIGRAVITY_QUOTA_URLS) {
try {
const result = await apiCallApi.request({
authIndex,
method: 'POST',
url,
header: { ...ANTIGRAVITY_REQUEST_HEADERS },
data: '{}'
});
if (result.statusCode < 200 || result.statusCode >= 300) {
lastError = getApiCallErrorMessage(result);
lastStatus = result.statusCode;
if (result.statusCode === 403 || result.statusCode === 404) {
priorityStatus ??= result.statusCode;
}
continue;
}
hadSuccess = true;
const payload = parseAntigravityPayload(result.body ?? result.bodyText);
const models = payload?.models;
if (!models || typeof models !== 'object' || Array.isArray(models)) {
lastError = t('antigravity_quota.empty_models');
continue;
}
const groups = buildAntigravityQuotaGroups(models as AntigravityModelsPayload);
if (groups.length === 0) {
lastError = t('antigravity_quota.empty_models');
continue;
}
return groups;
} catch (err: unknown) {
lastError = err instanceof Error ? err.message : t('common.unknown_error');
const status = getStatusFromError(err);
if (status) {
lastStatus = status;
if (status === 403 || status === 404) {
priorityStatus ??= status;
}
}
}
}
if (hadSuccess) {
return [];
}
throw createStatusError(lastError || t('common.unknown_error'), priorityStatus ?? lastStatus);
};
const buildCodexQuotaWindows = (payload: CodexUsagePayload, t: TFunction): CodexQuotaWindow[] => {
const rateLimit = payload.rate_limit ?? payload.rateLimit ?? undefined;
const codeReviewLimit = payload.code_review_rate_limit ?? payload.codeReviewRateLimit ?? undefined;
const windows: CodexQuotaWindow[] = [];
const addWindow = (
id: string,
labelKey: string,
window?: CodexUsageWindow | null,
limitReached?: boolean,
allowed?: boolean
) => {
if (!window) return;
const resetLabel = formatCodexResetLabel(window);
const usedPercentRaw = normalizeNumberValue(window.used_percent ?? window.usedPercent);
const isLimitReached = Boolean(limitReached) || allowed === false;
const usedPercent = usedPercentRaw ?? (isLimitReached && resetLabel !== '-' ? 100 : null);
windows.push({
id,
label: t(labelKey),
labelKey,
usedPercent,
resetLabel
});
};
addWindow(
'primary',
'codex_quota.primary_window',
rateLimit?.primary_window ?? rateLimit?.primaryWindow,
rateLimit?.limit_reached ?? rateLimit?.limitReached,
rateLimit?.allowed
);
addWindow(
'secondary',
'codex_quota.secondary_window',
rateLimit?.secondary_window ?? rateLimit?.secondaryWindow,
rateLimit?.limit_reached ?? rateLimit?.limitReached,
rateLimit?.allowed
);
addWindow(
'code-review',
'codex_quota.code_review_window',
codeReviewLimit?.primary_window ?? codeReviewLimit?.primaryWindow,
codeReviewLimit?.limit_reached ?? codeReviewLimit?.limitReached,
codeReviewLimit?.allowed
);
return windows;
};
const fetchCodexQuota = async (
file: AuthFileItem,
t: TFunction
): Promise<{ planType: string | null; windows: CodexQuotaWindow[] }> => {
const rawAuthIndex = file['auth_index'] ?? file.authIndex;
const authIndex = normalizeAuthIndexValue(rawAuthIndex);
if (!authIndex) {
throw new Error(t('codex_quota.missing_auth_index'));
}
const planTypeFromFile = resolveCodexPlanType(file);
const accountId = resolveCodexChatgptAccountId(file);
if (!accountId) {
throw new Error(t('codex_quota.missing_account_id'));
}
const requestHeader: Record<string, string> = {
...CODEX_REQUEST_HEADERS,
'Chatgpt-Account-Id': accountId
};
const result = await apiCallApi.request({
authIndex,
method: 'GET',
url: CODEX_USAGE_URL,
header: requestHeader
});
if (result.statusCode < 200 || result.statusCode >= 300) {
throw createStatusError(getApiCallErrorMessage(result), result.statusCode);
}
const payload = parseCodexUsagePayload(result.body ?? result.bodyText);
if (!payload) {
throw new Error(t('codex_quota.empty_windows'));
}
const planTypeFromUsage = normalizePlanType(payload.plan_type ?? payload.planType);
const windows = buildCodexQuotaWindows(payload, t);
return { planType: planTypeFromUsage ?? planTypeFromFile, windows };
};
const fetchGeminiCliQuota = async (
file: AuthFileItem,
t: TFunction
): Promise<GeminiCliQuotaBucketState[]> => {
const rawAuthIndex = file['auth_index'] ?? file.authIndex;
const authIndex = normalizeAuthIndexValue(rawAuthIndex);
if (!authIndex) {
throw new Error(t('gemini_cli_quota.missing_auth_index'));
}
const projectId = resolveGeminiCliProjectId(file);
if (!projectId) {
throw new Error(t('gemini_cli_quota.missing_project_id'));
}
const result = await apiCallApi.request({
authIndex,
method: 'POST',
url: GEMINI_CLI_QUOTA_URL,
header: { ...GEMINI_CLI_REQUEST_HEADERS },
data: JSON.stringify({ project: projectId })
});
if (result.statusCode < 200 || result.statusCode >= 300) {
throw createStatusError(getApiCallErrorMessage(result), result.statusCode);
}
const payload = parseGeminiCliQuotaPayload(result.body ?? result.bodyText);
const buckets = Array.isArray(payload?.buckets) ? payload?.buckets : [];
if (buckets.length === 0) return [];
const parsedBuckets = buckets
.map((bucket) => {
const modelId = normalizeStringValue(bucket.modelId ?? bucket.model_id);
if (!modelId) return null;
const tokenType = normalizeStringValue(bucket.tokenType ?? bucket.token_type);
const remainingFractionRaw = normalizeQuotaFraction(
bucket.remainingFraction ?? bucket.remaining_fraction
);
const remainingAmount = normalizeNumberValue(bucket.remainingAmount ?? bucket.remaining_amount);
const resetTime = normalizeStringValue(bucket.resetTime ?? bucket.reset_time) ?? undefined;
let fallbackFraction: number | null = null;
if (remainingAmount !== null) {
fallbackFraction = remainingAmount <= 0 ? 0 : null;
} else if (resetTime) {
fallbackFraction = 0;
}
const remainingFraction = remainingFractionRaw ?? fallbackFraction;
return {
modelId,
tokenType,
remainingFraction,
remainingAmount,
resetTime
};
})
.filter((bucket): bucket is GeminiCliParsedBucket => bucket !== null);
return buildGeminiCliQuotaBuckets(parsedBuckets);
};
const renderAntigravityItems = (
quota: AntigravityQuotaState,
t: TFunction,
helpers: QuotaRenderHelpers
): ReactNode => {
const { styles: styleMap, QuotaProgressBar } = helpers;
const { createElement: h } = React;
const groups = quota.groups ?? [];
if (groups.length === 0) {
return h('div', { className: styleMap.quotaMessage }, t('antigravity_quota.empty_models'));
}
return groups.map((group) => {
const clamped = Math.max(0, Math.min(1, group.remainingFraction));
const percent = Math.round(clamped * 100);
const resetLabel = formatQuotaResetTime(group.resetTime);
return h(
'div',
{ key: group.id, className: styleMap.quotaRow },
h(
'div',
{ className: styleMap.quotaRowHeader },
h(
'span',
{ className: styleMap.quotaModel, title: group.models.join(', ') },
group.label
),
h(
'div',
{ className: styleMap.quotaMeta },
h('span', { className: styleMap.quotaPercent }, `${percent}%`),
h('span', { className: styleMap.quotaReset }, resetLabel)
)
),
h(QuotaProgressBar, { percent, highThreshold: 60, mediumThreshold: 20 })
);
});
};
const renderCodexItems = (
quota: CodexQuotaState,
t: TFunction,
helpers: QuotaRenderHelpers
): ReactNode => {
const { styles: styleMap, QuotaProgressBar } = helpers;
const { createElement: h, Fragment } = React;
const windows = quota.windows ?? [];
const planType = quota.planType ?? null;
const getPlanLabel = (pt?: string | null): string | null => {
const normalized = normalizePlanType(pt);
if (!normalized) return null;
if (normalized === 'plus') return t('codex_quota.plan_plus');
if (normalized === 'team') return t('codex_quota.plan_team');
if (normalized === 'free') return t('codex_quota.plan_free');
return pt || normalized;
};
const planLabel = getPlanLabel(planType);
const isFreePlan = normalizePlanType(planType) === 'free';
const nodes: ReactNode[] = [];
if (planLabel) {
nodes.push(
h(
'div',
{ key: 'plan', className: styleMap.codexPlan },
h('span', { className: styleMap.codexPlanLabel }, t('codex_quota.plan_label')),
h('span', { className: styleMap.codexPlanValue }, planLabel)
)
);
}
if (isFreePlan) {
nodes.push(
h(
'div',
{ key: 'warning', className: styleMap.quotaWarning },
t('codex_quota.no_access')
)
);
return h(Fragment, null, ...nodes);
}
if (windows.length === 0) {
nodes.push(
h('div', { key: 'empty', className: styleMap.quotaMessage }, t('codex_quota.empty_windows'))
);
return h(Fragment, null, ...nodes);
}
nodes.push(
...windows.map((window) => {
const used = window.usedPercent;
const clampedUsed = used === null ? null : Math.max(0, Math.min(100, used));
const remaining = clampedUsed === null ? null : Math.max(0, Math.min(100, 100 - clampedUsed));
const percentLabel = remaining === null ? '--' : `${Math.round(remaining)}%`;
const windowLabel = window.labelKey ? t(window.labelKey) : window.label;
return h(
'div',
{ key: window.id, className: styleMap.quotaRow },
h(
'div',
{ className: styleMap.quotaRowHeader },
h('span', { className: styleMap.quotaModel }, windowLabel),
h(
'div',
{ className: styleMap.quotaMeta },
h('span', { className: styleMap.quotaPercent }, percentLabel),
h('span', { className: styleMap.quotaReset }, window.resetLabel)
)
),
h(QuotaProgressBar, { percent: remaining, highThreshold: 80, mediumThreshold: 50 })
);
})
);
return h(Fragment, null, ...nodes);
};
const renderGeminiCliItems = (
quota: GeminiCliQuotaState,
t: TFunction,
helpers: QuotaRenderHelpers
): ReactNode => {
const { styles: styleMap, QuotaProgressBar } = helpers;
const { createElement: h } = React;
const buckets = quota.buckets ?? [];
if (buckets.length === 0) {
return h('div', { className: styleMap.quotaMessage }, t('gemini_cli_quota.empty_buckets'));
}
return buckets.map((bucket) => {
const fraction = bucket.remainingFraction;
const clamped = fraction === null ? null : Math.max(0, Math.min(1, fraction));
const percent = clamped === null ? null : Math.round(clamped * 100);
const percentLabel = percent === null ? '--' : `${percent}%`;
const remainingAmountLabel =
bucket.remainingAmount === null || bucket.remainingAmount === undefined
? null
: t('gemini_cli_quota.remaining_amount', {
count: bucket.remainingAmount
});
const titleBase =
bucket.modelIds && bucket.modelIds.length > 0 ? bucket.modelIds.join(', ') : bucket.label;
const title = bucket.tokenType ? `${titleBase} (${bucket.tokenType})` : titleBase;
const resetLabel = formatQuotaResetTime(bucket.resetTime);
return h(
'div',
{ key: bucket.id, className: styleMap.quotaRow },
h(
'div',
{ className: styleMap.quotaRowHeader },
h('span', { className: styleMap.quotaModel, title }, bucket.label),
h(
'div',
{ className: styleMap.quotaMeta },
h('span', { className: styleMap.quotaPercent }, percentLabel),
remainingAmountLabel
? h('span', { className: styleMap.quotaAmount }, remainingAmountLabel)
: null,
h('span', { className: styleMap.quotaReset }, resetLabel)
)
),
h(QuotaProgressBar, { percent, highThreshold: 60, mediumThreshold: 20 })
);
});
};
export const ANTIGRAVITY_CONFIG: QuotaConfig<AntigravityQuotaState, AntigravityQuotaGroup[]> = {
type: 'antigravity',
i18nPrefix: 'antigravity_quota',
filterFn: (file) => isAntigravityFile(file),
fetchQuota: fetchAntigravityQuota,
storeSelector: (state) => state.antigravityQuota,
storeSetter: 'setAntigravityQuota',
buildLoadingState: () => ({ status: 'loading', groups: [] }),
buildSuccessState: (groups) => ({ status: 'success', groups }),
buildErrorState: (message, status) => ({
status: 'error',
groups: [],
error: message,
errorStatus: status
}),
cardClassName: styles.antigravityCard,
controlsClassName: styles.antigravityControls,
controlClassName: styles.antigravityControl,
gridClassName: styles.antigravityGrid,
renderQuotaItems: renderAntigravityItems
};
export const CODEX_CONFIG: QuotaConfig<
CodexQuotaState,
{ planType: string | null; windows: CodexQuotaWindow[] }
> = {
type: 'codex',
i18nPrefix: 'codex_quota',
filterFn: (file) => isCodexFile(file),
fetchQuota: fetchCodexQuota,
storeSelector: (state) => state.codexQuota,
storeSetter: 'setCodexQuota',
buildLoadingState: () => ({ status: 'loading', windows: [] }),
buildSuccessState: (data) => ({
status: 'success',
windows: data.windows,
planType: data.planType
}),
buildErrorState: (message, status) => ({
status: 'error',
windows: [],
error: message,
errorStatus: status
}),
cardClassName: styles.codexCard,
controlsClassName: styles.codexControls,
controlClassName: styles.codexControl,
gridClassName: styles.codexGrid,
renderQuotaItems: renderCodexItems
};
export const GEMINI_CLI_CONFIG: QuotaConfig<GeminiCliQuotaState, GeminiCliQuotaBucketState[]> = {
type: 'gemini-cli',
i18nPrefix: 'gemini_cli_quota',
filterFn: (file) => isGeminiCliFile(file) && !isRuntimeOnlyAuthFile(file),
fetchQuota: fetchGeminiCliQuota,
storeSelector: (state) => state.geminiCliQuota,
storeSetter: 'setGeminiCliQuota',
buildLoadingState: () => ({ status: 'loading', buckets: [] }),
buildSuccessState: (buckets) => ({ status: 'success', buckets }),
buildErrorState: (message, status) => ({
status: 'error',
buckets: [],
error: message,
errorStatus: status
}),
cardClassName: styles.geminiCliCard,
controlsClassName: styles.geminiCliControls,
controlClassName: styles.geminiCliControl,
gridClassName: styles.geminiCliGrid,
renderQuotaItems: renderGeminiCliItems
};

View File

@@ -0,0 +1,98 @@
/**
* Generic hook for quota data fetching and management.
*/
import { useCallback, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import type { AuthFileItem } from '@/types';
import { useQuotaStore } from '@/stores';
import { getStatusFromError } from '@/utils/quota';
import type { QuotaConfig } from './quotaConfigs';
type QuotaScope = 'page' | 'all';
type QuotaUpdater<T> = T | ((prev: T) => T);
type QuotaSetter<T> = (updater: QuotaUpdater<T>) => void;
interface LoadQuotaResult<TData> {
name: string;
status: 'success' | 'error';
data?: TData;
error?: string;
errorStatus?: number;
}
export function useQuotaLoader<TState, TData>(config: QuotaConfig<TState, TData>) {
const { t } = useTranslation();
const quota = useQuotaStore(config.storeSelector);
const setQuota = useQuotaStore((state) => state[config.storeSetter]) as QuotaSetter<
Record<string, TState>
>;
const loadingRef = useRef(false);
const requestIdRef = useRef(0);
const loadQuota = useCallback(
async (
targets: AuthFileItem[],
scope: QuotaScope,
setLoading: (loading: boolean, scope?: QuotaScope | null) => void
) => {
if (loadingRef.current) return;
loadingRef.current = true;
const requestId = ++requestIdRef.current;
setLoading(true, scope);
try {
if (targets.length === 0) return;
setQuota((prev) => {
const nextState = { ...prev };
targets.forEach((file) => {
nextState[file.name] = config.buildLoadingState();
});
return nextState;
});
const results = await Promise.all(
targets.map(async (file): Promise<LoadQuotaResult<TData>> => {
try {
const data = await config.fetchQuota(file, t);
return { name: file.name, status: 'success', data };
} catch (err: unknown) {
const message = err instanceof Error ? err.message : t('common.unknown_error');
const errorStatus = getStatusFromError(err);
return { name: file.name, status: 'error', error: message, errorStatus };
}
})
);
if (requestId !== requestIdRef.current) return;
setQuota((prev) => {
const nextState = { ...prev };
results.forEach((result) => {
if (result.status === 'success') {
nextState[result.name] = config.buildSuccessState(result.data as TData);
} else {
nextState[result.name] = config.buildErrorState(
result.error || t('common.unknown_error'),
result.errorStatus
);
}
});
return nextState;
});
} finally {
if (requestId === requestIdRef.current) {
setLoading(false);
loadingRef.current = false;
}
}
},
[config, setQuota, t]
);
return { quota, loadQuota };
}

View File

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

View File

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

View File

@@ -1,4 +1,5 @@
import type { PropsWithChildren, ReactNode } from 'react';
import { useState, useEffect, useCallback, useRef, type PropsWithChildren, type ReactNode } from 'react';
import { createPortal } from 'react-dom';
import { IconX } from './icons';
interface ModalProps {
@@ -9,27 +10,108 @@ interface ModalProps {
width?: number | string;
}
const CLOSE_ANIMATION_DURATION = 350;
const MODAL_LOCK_CLASS = 'modal-open';
let activeModalCount = 0;
const lockScroll = () => {
if (typeof document === 'undefined') return;
if (activeModalCount === 0) {
document.body?.classList.add(MODAL_LOCK_CLASS);
document.documentElement?.classList.add(MODAL_LOCK_CLASS);
}
activeModalCount += 1;
};
const unlockScroll = () => {
if (typeof document === 'undefined') return;
activeModalCount = Math.max(0, activeModalCount - 1);
if (activeModalCount === 0) {
document.body?.classList.remove(MODAL_LOCK_CLASS);
document.documentElement?.classList.remove(MODAL_LOCK_CLASS);
}
};
export function Modal({ open, title, onClose, footer, width = 520, children }: PropsWithChildren<ModalProps>) {
if (!open) return null;
const [isVisible, setIsVisible] = useState(false);
const [isClosing, setIsClosing] = useState(false);
const closeTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const handleMaskClick = (event: React.MouseEvent<HTMLDivElement>) => {
if (event.target === event.currentTarget) {
onClose();
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();
}
}, CLOSE_ANIMATION_DURATION);
},
[onClose]
);
useEffect(() => {
if (open) {
if (closeTimerRef.current !== null) {
window.clearTimeout(closeTimerRef.current);
closeTimerRef.current = null;
}
setIsVisible(true);
setIsClosing(false);
return;
}
};
return (
<div className="modal-overlay" onClick={handleMaskClick}>
<div className="modal" style={{ width }} role="dialog" aria-modal="true">
if (isVisible) {
startClose(false);
}
}, [open, isVisible, startClose]);
const handleClose = useCallback(() => {
startClose(true);
}, [startClose]);
useEffect(() => {
return () => {
if (closeTimerRef.current !== null) {
window.clearTimeout(closeTimerRef.current);
}
};
}, []);
const shouldLockScroll = open || isVisible;
useEffect(() => {
if (!shouldLockScroll) return;
lockScroll();
return () => unlockScroll();
}, [shouldLockScroll]);
if (!open && !isVisible) return null;
const overlayClass = `modal-overlay ${isClosing ? 'modal-overlay-closing' : 'modal-overlay-entering'}`;
const modalClass = `modal ${isClosing ? 'modal-closing' : 'modal-entering'}`;
const modalContent = (
<div className={overlayClass}>
<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-title">{title}</div>
<button className="modal-close" onClick={onClose} aria-label="Close">
<IconX size={18} />
</button>
</div>
<div className="modal-body">{children}</div>
{footer && <div className="modal-footer">{footer}</div>}
</div>
</div>
);
if (typeof document === 'undefined') {
return modalContent;
}
return createPortal(modalContent, document.body);
}

View File

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

View File

@@ -266,3 +266,51 @@ export function IconDollarSign({ size = 20, ...props }: IconProps) {
</svg>
);
}
export function IconGithub({ size = 20, ...props }: IconProps) {
return (
<svg {...baseSvgProps} width={size} height={size} {...props}>
<path d="M15 22v-4a4.8 4.8 0 0 0-1-3.5c3 0 6-2 6-5.5.08-1.25-.27-2.48-1-3.5.28-1.15.28-2.35 0-3.5 0 0-1 0-3 1.5-2.64-.5-5.36-.5-8 0C6 2 5 2 5 2c-.3 1.15-.3 2.35 0 3.5A5.403 5.403 0 0 0 4 9c0 3.5 3 5.5 6 5.5-.39.49-.68 1.05-.85 1.65-.17.6-.22 1.23-.15 1.85v4" />
<path d="M9 18c-4.51 2-5-2-7-2" />
</svg>
);
}
export function IconExternalLink({ size = 20, ...props }: IconProps) {
return (
<svg {...baseSvgProps} width={size} height={size} {...props}>
<path d="M15 3h6v6" />
<path d="M10 14 21 3" />
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6" />
</svg>
);
}
export function IconBookOpen({ size = 20, ...props }: IconProps) {
return (
<svg {...baseSvgProps} width={size} height={size} {...props}>
<path d="M12 7v14" />
<path d="M3 18a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1h5a4 4 0 0 1 4 4 4 4 0 0 1 4-4h5a1 1 0 0 1 1 1v13a1 1 0 0 1-1 1h-6a3 3 0 0 0-3 3 3 3 0 0 0-3-3z" />
</svg>
);
}
export function IconCode({ size = 20, ...props }: IconProps) {
return (
<svg {...baseSvgProps} width={size} height={size} {...props}>
<polyline points="16 18 22 12 16 6" />
<polyline points="8 6 2 12 8 18" />
</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

@@ -0,0 +1,79 @@
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Card } from '@/components/ui/Card';
import { formatTokensInMillions, formatUsd, type ApiStats } from '@/utils/usage';
import styles from '@/pages/UsagePage.module.scss';
export interface ApiDetailsCardProps {
apiStats: ApiStats[];
loading: boolean;
hasPrices: boolean;
}
export function ApiDetailsCard({ apiStats, loading, hasPrices }: ApiDetailsCardProps) {
const { t } = useTranslation();
const [expandedApis, setExpandedApis] = useState<Set<string>>(new Set());
const toggleExpand = (endpoint: string) => {
setExpandedApis((prev) => {
const newSet = new Set(prev);
if (newSet.has(endpoint)) {
newSet.delete(endpoint);
} else {
newSet.add(endpoint);
}
return newSet;
});
};
return (
<Card title={t('usage_stats.api_details')}>
{loading ? (
<div className={styles.hint}>{t('common.loading')}</div>
) : apiStats.length > 0 ? (
<div className={styles.apiList}>
{apiStats.map((api) => (
<div key={api.endpoint} className={styles.apiItem}>
<div className={styles.apiHeader} onClick={() => toggleExpand(api.endpoint)}>
<div className={styles.apiInfo}>
<span className={styles.apiEndpoint}>{api.endpoint}</span>
<div className={styles.apiStats}>
<span className={styles.apiBadge}>
{t('usage_stats.requests_count')}: {api.totalRequests}
</span>
<span className={styles.apiBadge}>
Tokens: {formatTokensInMillions(api.totalTokens)}
</span>
{hasPrices && api.totalCost > 0 && (
<span className={styles.apiBadge}>
{t('usage_stats.total_cost')}: {formatUsd(api.totalCost)}
</span>
)}
</div>
</div>
<span className={styles.expandIcon}>
{expandedApis.has(api.endpoint) ? '▼' : '▶'}
</span>
</div>
{expandedApis.has(api.endpoint) && (
<div className={styles.apiModels}>
{Object.entries(api.models).map(([model, stats]) => (
<div key={model} className={styles.modelRow}>
<span className={styles.modelName}>{model}</span>
<span className={styles.modelStat}>
{stats.requests} {t('usage_stats.requests_count')}
</span>
<span className={styles.modelStat}>{formatTokensInMillions(stats.tokens)}</span>
</div>
))}
</div>
)}
</div>
))}
</div>
) : (
<div className={styles.hint}>{t('usage_stats.no_data')}</div>
)}
</Card>
);
}

View File

@@ -0,0 +1,92 @@
import { useTranslation } from 'react-i18next';
import { Card } from '@/components/ui/Card';
import { Button } from '@/components/ui/Button';
import styles from '@/pages/UsagePage.module.scss';
export interface ChartLineSelectorProps {
chartLines: string[];
modelNames: string[];
maxLines?: number;
onChange: (lines: string[]) => void;
}
export function ChartLineSelector({
chartLines,
modelNames,
maxLines = 9,
onChange
}: ChartLineSelectorProps) {
const { t } = useTranslation();
const handleAdd = () => {
if (chartLines.length >= maxLines) return;
const unusedModel = modelNames.find((m) => !chartLines.includes(m));
if (unusedModel) {
onChange([...chartLines, unusedModel]);
} else {
onChange([...chartLines, 'all']);
}
};
const handleRemove = (index: number) => {
if (chartLines.length <= 1) return;
const newLines = [...chartLines];
newLines.splice(index, 1);
onChange(newLines);
};
const handleChange = (index: number, value: string) => {
const newLines = [...chartLines];
newLines[index] = value;
onChange(newLines);
};
return (
<Card
title={t('usage_stats.chart_line_actions_label')}
extra={
<div className={styles.chartLineHeader}>
<span className={styles.chartLineCount}>
{chartLines.length}/{maxLines}
</span>
<Button
variant="secondary"
size="sm"
onClick={handleAdd}
disabled={chartLines.length >= maxLines}
>
{t('usage_stats.chart_line_add')}
</Button>
</div>
}
>
<div className={styles.chartLineList}>
{chartLines.map((line, index) => (
<div key={index} className={styles.chartLineItem}>
<span className={styles.chartLineLabel}>
{t(`usage_stats.chart_line_label_${index + 1}`)}
</span>
<select
value={line}
onChange={(e) => handleChange(index, e.target.value)}
className={styles.select}
>
<option value="all">{t('usage_stats.chart_line_all')}</option>
{modelNames.map((name) => (
<option key={name} value={name}>
{name}
</option>
))}
</select>
{chartLines.length > 1 && (
<Button variant="danger" size="sm" onClick={() => handleRemove(index)}>
{t('usage_stats.chart_line_delete')}
</Button>
)}
</div>
))}
</div>
<p className={styles.chartLineHint}>{t('usage_stats.chart_line_hint')}</p>
</Card>
);
}

View File

@@ -0,0 +1,54 @@
import { useTranslation } from 'react-i18next';
import { Card } from '@/components/ui/Card';
import { formatTokensInMillions, formatUsd } from '@/utils/usage';
import styles from '@/pages/UsagePage.module.scss';
export interface ModelStat {
model: string;
requests: number;
tokens: number;
cost: number;
}
export interface ModelStatsCardProps {
modelStats: ModelStat[];
loading: boolean;
hasPrices: boolean;
}
export function ModelStatsCard({ modelStats, loading, hasPrices }: ModelStatsCardProps) {
const { t } = useTranslation();
return (
<Card title={t('usage_stats.models')}>
{loading ? (
<div className={styles.hint}>{t('common.loading')}</div>
) : modelStats.length > 0 ? (
<div className={styles.tableWrapper}>
<table className={styles.table}>
<thead>
<tr>
<th>{t('usage_stats.model_name')}</th>
<th>{t('usage_stats.requests_count')}</th>
<th>{t('usage_stats.tokens_count')}</th>
{hasPrices && <th>{t('usage_stats.total_cost')}</th>}
</tr>
</thead>
<tbody>
{modelStats.map((stat) => (
<tr key={stat.model}>
<td className={styles.modelCell}>{stat.model}</td>
<td>{stat.requests.toLocaleString()}</td>
<td>{formatTokensInMillions(stat.tokens)}</td>
{hasPrices && <td>{stat.cost > 0 ? formatUsd(stat.cost) : '--'}</td>}
</tr>
))}
</tbody>
</table>
</div>
) : (
<div className={styles.hint}>{t('usage_stats.no_data')}</div>
)}
</Card>
);
}

View File

@@ -0,0 +1,164 @@
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Card } from '@/components/ui/Card';
import { Button } from '@/components/ui/Button';
import { Input } from '@/components/ui/Input';
import type { ModelPrice } from '@/utils/usage';
import styles from '@/pages/UsagePage.module.scss';
export interface PriceSettingsCardProps {
modelNames: string[];
modelPrices: Record<string, ModelPrice>;
onPricesChange: (prices: Record<string, ModelPrice>) => void;
}
export function PriceSettingsCard({
modelNames,
modelPrices,
onPricesChange
}: PriceSettingsCardProps) {
const { t } = useTranslation();
const [selectedModel, setSelectedModel] = useState('');
const [promptPrice, setPromptPrice] = useState('');
const [completionPrice, setCompletionPrice] = useState('');
const [cachePrice, setCachePrice] = useState('');
const handleSavePrice = () => {
if (!selectedModel) return;
const prompt = parseFloat(promptPrice) || 0;
const completion = parseFloat(completionPrice) || 0;
const cache = cachePrice.trim() === '' ? prompt : parseFloat(cachePrice) || 0;
const newPrices = { ...modelPrices, [selectedModel]: { prompt, completion, cache } };
onPricesChange(newPrices);
setSelectedModel('');
setPromptPrice('');
setCompletionPrice('');
setCachePrice('');
};
const handleDeletePrice = (model: string) => {
const newPrices = { ...modelPrices };
delete newPrices[model];
onPricesChange(newPrices);
};
const handleEditPrice = (model: string) => {
const price = modelPrices[model];
setSelectedModel(model);
setPromptPrice(price?.prompt?.toString() || '');
setCompletionPrice(price?.completion?.toString() || '');
setCachePrice(price?.cache?.toString() || '');
};
const handleModelSelect = (value: string) => {
setSelectedModel(value);
const price = modelPrices[value];
if (price) {
setPromptPrice(price.prompt.toString());
setCompletionPrice(price.completion.toString());
setCachePrice(price.cache.toString());
} else {
setPromptPrice('');
setCompletionPrice('');
setCachePrice('');
}
};
return (
<Card title={t('usage_stats.model_price_settings')}>
<div className={styles.pricingSection}>
{/* Price Form */}
<div className={styles.priceForm}>
<div className={styles.formRow}>
<div className={styles.formField}>
<label>{t('usage_stats.model_name')}</label>
<select
value={selectedModel}
onChange={(e) => handleModelSelect(e.target.value)}
className={styles.select}
>
<option value="">{t('usage_stats.model_price_select_placeholder')}</option>
{modelNames.map((name) => (
<option key={name} value={name}>
{name}
</option>
))}
</select>
</div>
<div className={styles.formField}>
<label>{t('usage_stats.model_price_prompt')} ($/1M)</label>
<Input
type="number"
value={promptPrice}
onChange={(e) => setPromptPrice(e.target.value)}
placeholder="0.00"
step="0.0001"
/>
</div>
<div className={styles.formField}>
<label>{t('usage_stats.model_price_completion')} ($/1M)</label>
<Input
type="number"
value={completionPrice}
onChange={(e) => setCompletionPrice(e.target.value)}
placeholder="0.00"
step="0.0001"
/>
</div>
<div className={styles.formField}>
<label>{t('usage_stats.model_price_cache')} ($/1M)</label>
<Input
type="number"
value={cachePrice}
onChange={(e) => setCachePrice(e.target.value)}
placeholder="0.00"
step="0.0001"
/>
</div>
<Button variant="primary" onClick={handleSavePrice} disabled={!selectedModel}>
{t('common.save')}
</Button>
</div>
</div>
{/* Saved Prices List */}
<div className={styles.pricesList}>
<h4 className={styles.pricesTitle}>{t('usage_stats.saved_prices')}</h4>
{Object.keys(modelPrices).length > 0 ? (
<div className={styles.pricesGrid}>
{Object.entries(modelPrices).map(([model, price]) => (
<div key={model} className={styles.priceItem}>
<div className={styles.priceInfo}>
<span className={styles.priceModel}>{model}</span>
<div className={styles.priceMeta}>
<span>
{t('usage_stats.model_price_prompt')}: ${price.prompt.toFixed(4)}/1M
</span>
<span>
{t('usage_stats.model_price_completion')}: ${price.completion.toFixed(4)}/1M
</span>
<span>
{t('usage_stats.model_price_cache')}: ${price.cache.toFixed(4)}/1M
</span>
</div>
</div>
<div className={styles.priceActions}>
<Button variant="secondary" size="sm" onClick={() => handleEditPrice(model)}>
{t('common.edit')}
</Button>
<Button variant="danger" size="sm" onClick={() => handleDeletePrice(model)}>
{t('common.delete')}
</Button>
</div>
</div>
))}
</div>
) : (
<div className={styles.hint}>{t('usage_stats.model_price_empty')}</div>
)}
</div>
</div>
</Card>
);
}

View File

@@ -0,0 +1,184 @@
import type { CSSProperties, ReactNode } from 'react';
import { useTranslation } from 'react-i18next';
import { Line } from 'react-chartjs-2';
import { IconDiamond, IconDollarSign, IconSatellite, IconTimer, IconTrendingUp } from '@/components/ui/icons';
import {
formatTokensInMillions,
formatPerMinuteValue,
formatUsd,
calculateTokenBreakdown,
calculateRecentPerMinuteRates,
calculateTotalCost,
type ModelPrice
} from '@/utils/usage';
import { sparklineOptions } from '@/utils/usage/chartConfig';
import type { UsagePayload } from './hooks/useUsageData';
import type { SparklineBundle } from './hooks/useSparklines';
import styles from '@/pages/UsagePage.module.scss';
interface StatCardData {
key: string;
label: string;
icon: ReactNode;
accent: string;
accentSoft: string;
accentBorder: string;
value: string;
meta?: ReactNode;
trend: SparklineBundle | null;
}
export interface StatCardsProps {
usage: UsagePayload | null;
loading: boolean;
modelPrices: Record<string, ModelPrice>;
sparklines: {
requests: SparklineBundle | null;
tokens: SparklineBundle | null;
rpm: SparklineBundle | null;
tpm: SparklineBundle | null;
cost: SparklineBundle | null;
};
}
export function StatCards({ usage, loading, modelPrices, sparklines }: StatCardsProps) {
const { t } = useTranslation();
const tokenBreakdown = usage ? calculateTokenBreakdown(usage) : { cachedTokens: 0, reasoningTokens: 0 };
const rateStats = usage
? calculateRecentPerMinuteRates(30, usage)
: { rpm: 0, tpm: 0, windowMinutes: 30, requestCount: 0, tokenCount: 0 };
const totalCost = usage ? calculateTotalCost(usage, modelPrices) : 0;
const hasPrices = Object.keys(modelPrices).length > 0;
const statsCards: StatCardData[] = [
{
key: 'requests',
label: t('usage_stats.total_requests'),
icon: <IconSatellite size={16} />,
accent: '#3b82f6',
accentSoft: 'rgba(59, 130, 246, 0.18)',
accentBorder: 'rgba(59, 130, 246, 0.35)',
value: loading ? '-' : (usage?.total_requests ?? 0).toLocaleString(),
meta: (
<>
<span className={styles.statMetaItem}>
<span className={styles.statMetaDot} style={{ backgroundColor: '#10b981' }} />
{t('usage_stats.success_requests')}: {loading ? '-' : (usage?.success_count ?? 0)}
</span>
<span className={styles.statMetaItem}>
<span className={styles.statMetaDot} style={{ backgroundColor: '#ef4444' }} />
{t('usage_stats.failed_requests')}: {loading ? '-' : (usage?.failure_count ?? 0)}
</span>
</>
),
trend: sparklines.requests
},
{
key: 'tokens',
label: t('usage_stats.total_tokens'),
icon: <IconDiamond size={16} />,
accent: '#8b5cf6',
accentSoft: 'rgba(139, 92, 246, 0.18)',
accentBorder: 'rgba(139, 92, 246, 0.35)',
value: loading ? '-' : formatTokensInMillions(usage?.total_tokens ?? 0),
meta: (
<>
<span className={styles.statMetaItem}>
{t('usage_stats.cached_tokens')}: {loading ? '-' : formatTokensInMillions(tokenBreakdown.cachedTokens)}
</span>
<span className={styles.statMetaItem}>
{t('usage_stats.reasoning_tokens')}: {loading ? '-' : formatTokensInMillions(tokenBreakdown.reasoningTokens)}
</span>
</>
),
trend: sparklines.tokens
},
{
key: 'rpm',
label: t('usage_stats.rpm_30m'),
icon: <IconTimer size={16} />,
accent: '#22c55e',
accentSoft: 'rgba(34, 197, 94, 0.18)',
accentBorder: 'rgba(34, 197, 94, 0.32)',
value: loading ? '-' : formatPerMinuteValue(rateStats.rpm),
meta: (
<span className={styles.statMetaItem}>
{t('usage_stats.total_requests')}: {loading ? '-' : rateStats.requestCount.toLocaleString()}
</span>
),
trend: sparklines.rpm
},
{
key: 'tpm',
label: t('usage_stats.tpm_30m'),
icon: <IconTrendingUp size={16} />,
accent: '#f97316',
accentSoft: 'rgba(249, 115, 22, 0.18)',
accentBorder: 'rgba(249, 115, 22, 0.32)',
value: loading ? '-' : formatPerMinuteValue(rateStats.tpm),
meta: (
<span className={styles.statMetaItem}>
{t('usage_stats.total_tokens')}: {loading ? '-' : formatTokensInMillions(rateStats.tokenCount)}
</span>
),
trend: sparklines.tpm
},
{
key: 'cost',
label: t('usage_stats.total_cost'),
icon: <IconDollarSign size={16} />,
accent: '#f59e0b',
accentSoft: 'rgba(245, 158, 11, 0.18)',
accentBorder: 'rgba(245, 158, 11, 0.32)',
value: loading ? '-' : hasPrices ? formatUsd(totalCost) : '--',
meta: (
<>
<span className={styles.statMetaItem}>
{t('usage_stats.total_tokens')}: {loading ? '-' : formatTokensInMillions(usage?.total_tokens ?? 0)}
</span>
{!hasPrices && (
<span className={`${styles.statMetaItem} ${styles.statSubtle}`}>
{t('usage_stats.cost_need_price')}
</span>
)}
</>
),
trend: hasPrices ? sparklines.cost : null
}
];
return (
<div className={styles.statsGrid}>
{statsCards.map((card) => (
<div
key={card.key}
className={styles.statCard}
style={
{
'--accent': card.accent,
'--accent-soft': card.accentSoft,
'--accent-border': card.accentBorder
} as CSSProperties
}
>
<div className={styles.statCardHeader}>
<div className={styles.statLabelGroup}>
<span className={styles.statLabel}>{card.label}</span>
</div>
<span className={styles.statIconBadge}>{card.icon}</span>
</div>
<div className={styles.statValue}>{card.value}</div>
{card.meta && <div className={styles.statMetaRow}>{card.meta}</div>}
<div className={styles.statTrend}>
{card.trend ? (
<Line className={styles.sparkline} data={card.trend.data} options={sparklineOptions} />
) : (
<div className={styles.statTrendPlaceholder}></div>
)}
</div>
</div>
))}
</div>
);
}

View File

@@ -0,0 +1,91 @@
import { useTranslation } from 'react-i18next';
import type { ChartOptions } from 'chart.js';
import { Line } from 'react-chartjs-2';
import { Card } from '@/components/ui/Card';
import { Button } from '@/components/ui/Button';
import type { ChartData } from '@/utils/usage';
import { getHourChartMinWidth } from '@/utils/usage/chartConfig';
import styles from '@/pages/UsagePage.module.scss';
export interface UsageChartProps {
title: string;
period: 'hour' | 'day';
onPeriodChange: (period: 'hour' | 'day') => void;
chartData: ChartData;
chartOptions: ChartOptions<'line'>;
loading: boolean;
isMobile: boolean;
emptyText: string;
}
export function UsageChart({
title,
period,
onPeriodChange,
chartData,
chartOptions,
loading,
isMobile,
emptyText
}: UsageChartProps) {
const { t } = useTranslation();
return (
<Card
title={title}
extra={
<div className={styles.periodButtons}>
<Button
variant={period === 'hour' ? 'primary' : 'secondary'}
size="sm"
onClick={() => onPeriodChange('hour')}
>
{t('usage_stats.by_hour')}
</Button>
<Button
variant={period === 'day' ? 'primary' : 'secondary'}
size="sm"
onClick={() => onPeriodChange('day')}
>
{t('usage_stats.by_day')}
</Button>
</div>
}
>
{loading ? (
<div className={styles.hint}>{t('common.loading')}</div>
) : chartData.labels.length > 0 ? (
<div className={styles.chartWrapper}>
<div className={styles.chartLegend} aria-label="Chart legend">
{chartData.datasets.map((dataset, index) => (
<div
key={`${dataset.label}-${index}`}
className={styles.legendItem}
title={dataset.label}
>
<span className={styles.legendDot} style={{ backgroundColor: dataset.borderColor }} />
<span className={styles.legendLabel}>{dataset.label}</span>
</div>
))}
</div>
<div className={styles.chartArea}>
<div className={styles.chartScroller}>
<div
className={styles.chartCanvas}
style={
period === 'hour'
? { minWidth: getHourChartMinWidth(chartData.labels.length, isMobile) }
: undefined
}
>
<Line data={chartData} options={chartOptions} />
</div>
</div>
</div>
</div>
) : (
<div className={styles.hint}>{emptyText}</div>
)}
</Card>
);
}

View File

@@ -0,0 +1,8 @@
export { useUsageData } from './useUsageData';
export type { UsagePayload, UseUsageDataReturn } from './useUsageData';
export { useSparklines } from './useSparklines';
export type { SparklineData, SparklineBundle, UseSparklinesOptions, UseSparklinesReturn } from './useSparklines';
export { useChartData } from './useChartData';
export type { UseChartDataOptions, UseChartDataReturn } from './useChartData';

View File

@@ -0,0 +1,76 @@
import { useState, useMemo } from 'react';
import type { ChartOptions } from 'chart.js';
import { buildChartData, type ChartData } from '@/utils/usage';
import { buildChartOptions } from '@/utils/usage/chartConfig';
import type { UsagePayload } from './useUsageData';
export interface UseChartDataOptions {
usage: UsagePayload | null;
chartLines: string[];
isDark: boolean;
isMobile: boolean;
}
export interface UseChartDataReturn {
requestsPeriod: 'hour' | 'day';
setRequestsPeriod: (period: 'hour' | 'day') => void;
tokensPeriod: 'hour' | 'day';
setTokensPeriod: (period: 'hour' | 'day') => void;
requestsChartData: ChartData;
tokensChartData: ChartData;
requestsChartOptions: ChartOptions<'line'>;
tokensChartOptions: ChartOptions<'line'>;
}
export function useChartData({
usage,
chartLines,
isDark,
isMobile
}: UseChartDataOptions): UseChartDataReturn {
const [requestsPeriod, setRequestsPeriod] = useState<'hour' | 'day'>('day');
const [tokensPeriod, setTokensPeriod] = useState<'hour' | 'day'>('day');
const requestsChartData = useMemo(() => {
if (!usage) return { labels: [], datasets: [] };
return buildChartData(usage, requestsPeriod, 'requests', chartLines);
}, [usage, requestsPeriod, chartLines]);
const tokensChartData = useMemo(() => {
if (!usage) return { labels: [], datasets: [] };
return buildChartData(usage, tokensPeriod, 'tokens', chartLines);
}, [usage, tokensPeriod, chartLines]);
const requestsChartOptions = useMemo(
() =>
buildChartOptions({
period: requestsPeriod,
labels: requestsChartData.labels,
isDark,
isMobile
}),
[requestsPeriod, requestsChartData.labels, isDark, isMobile]
);
const tokensChartOptions = useMemo(
() =>
buildChartOptions({
period: tokensPeriod,
labels: tokensChartData.labels,
isDark,
isMobile
}),
[tokensPeriod, tokensChartData.labels, isDark, isMobile]
);
return {
requestsPeriod,
setRequestsPeriod,
tokensPeriod,
setTokensPeriod,
requestsChartData,
tokensChartData,
requestsChartOptions,
tokensChartOptions
};
}

View File

@@ -0,0 +1,138 @@
import { useCallback, useMemo } from 'react';
import { collectUsageDetails, extractTotalTokens } from '@/utils/usage';
import type { UsagePayload } from './useUsageData';
export interface SparklineData {
labels: string[];
datasets: [
{
data: number[];
borderColor: string;
backgroundColor: string;
fill: boolean;
tension: number;
pointRadius: number;
borderWidth: number;
}
];
}
export interface SparklineBundle {
data: SparklineData;
}
export interface UseSparklinesOptions {
usage: UsagePayload | null;
loading: boolean;
}
export interface UseSparklinesReturn {
requestsSparkline: SparklineBundle | null;
tokensSparkline: SparklineBundle | null;
rpmSparkline: SparklineBundle | null;
tpmSparkline: SparklineBundle | null;
costSparkline: SparklineBundle | null;
}
export function useSparklines({ usage, loading }: UseSparklinesOptions): UseSparklinesReturn {
const buildLastHourSeries = useCallback(
(metric: 'requests' | 'tokens'): { labels: string[]; data: number[] } => {
if (!usage) return { labels: [], data: [] };
const details = collectUsageDetails(usage);
if (!details.length) return { labels: [], data: [] };
const windowMinutes = 60;
const now = Date.now();
const windowStart = now - windowMinutes * 60 * 1000;
const buckets = new Array(windowMinutes).fill(0);
details.forEach((detail) => {
const timestamp = Date.parse(detail.timestamp);
if (Number.isNaN(timestamp) || timestamp < windowStart) {
return;
}
const minuteIndex = Math.min(
windowMinutes - 1,
Math.floor((timestamp - windowStart) / 60000)
);
const increment = metric === 'tokens' ? extractTotalTokens(detail) : 1;
buckets[minuteIndex] += increment;
});
const labels = buckets.map((_, idx) => {
const date = new Date(windowStart + (idx + 1) * 60000);
const h = date.getHours().toString().padStart(2, '0');
const m = date.getMinutes().toString().padStart(2, '0');
return `${h}:${m}`;
});
return { labels, data: buckets };
},
[usage]
);
const buildSparkline = useCallback(
(
series: { labels: string[]; data: number[] },
color: string,
backgroundColor: string
): SparklineBundle | null => {
if (loading || !series?.data?.length) {
return null;
}
const sliceStart = Math.max(series.data.length - 60, 0);
const labels = series.labels.slice(sliceStart);
const points = series.data.slice(sliceStart);
return {
data: {
labels,
datasets: [
{
data: points,
borderColor: color,
backgroundColor,
fill: true,
tension: 0.45,
pointRadius: 0,
borderWidth: 2
}
]
}
};
},
[loading]
);
const requestsSparkline = useMemo(
() => buildSparkline(buildLastHourSeries('requests'), '#3b82f6', 'rgba(59, 130, 246, 0.18)'),
[buildLastHourSeries, buildSparkline]
);
const tokensSparkline = useMemo(
() => buildSparkline(buildLastHourSeries('tokens'), '#8b5cf6', 'rgba(139, 92, 246, 0.18)'),
[buildLastHourSeries, buildSparkline]
);
const rpmSparkline = useMemo(
() => buildSparkline(buildLastHourSeries('requests'), '#22c55e', 'rgba(34, 197, 94, 0.18)'),
[buildLastHourSeries, buildSparkline]
);
const tpmSparkline = useMemo(
() => buildSparkline(buildLastHourSeries('tokens'), '#f97316', 'rgba(249, 115, 22, 0.18)'),
[buildLastHourSeries, buildSparkline]
);
const costSparkline = useMemo(
() => buildSparkline(buildLastHourSeries('tokens'), '#f59e0b', 'rgba(245, 158, 11, 0.18)'),
[buildLastHourSeries, buildSparkline]
);
return {
requestsSparkline,
tokensSparkline,
rpmSparkline,
tpmSparkline,
costSparkline
};
}

View File

@@ -0,0 +1,153 @@
import { useEffect, useState, useCallback, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import { useNotificationStore } from '@/stores';
import { usageApi } from '@/services/api/usage';
import { loadModelPrices, saveModelPrices, type ModelPrice } from '@/utils/usage';
export interface UsagePayload {
total_requests?: number;
success_count?: number;
failure_count?: number;
total_tokens?: number;
apis?: Record<string, unknown>;
[key: string]: unknown;
}
export interface UseUsageDataReturn {
usage: UsagePayload | null;
loading: boolean;
error: string;
modelPrices: Record<string, ModelPrice>;
setModelPrices: (prices: Record<string, ModelPrice>) => void;
loadUsage: () => Promise<void>;
handleExport: () => Promise<void>;
handleImport: () => void;
handleImportChange: (event: React.ChangeEvent<HTMLInputElement>) => Promise<void>;
importInputRef: React.RefObject<HTMLInputElement | null>;
exporting: boolean;
importing: boolean;
}
export function useUsageData(): UseUsageDataReturn {
const { t } = useTranslation();
const { showNotification } = useNotificationStore();
const [usage, setUsage] = useState<UsagePayload | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [modelPrices, setModelPrices] = useState<Record<string, ModelPrice>>({});
const [exporting, setExporting] = useState(false);
const [importing, setImporting] = useState(false);
const importInputRef = useRef<HTMLInputElement | null>(null);
const loadUsage = useCallback(async () => {
setLoading(true);
setError('');
try {
const data = await usageApi.getUsage();
const payload = data?.usage ?? data;
setUsage(payload);
} catch (err: unknown) {
const message = err instanceof Error ? err.message : t('usage_stats.loading_error');
setError(message);
} finally {
setLoading(false);
}
}, [t]);
useEffect(() => {
loadUsage();
setModelPrices(loadModelPrices());
}, [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 handleImport = () => {
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);
}
};
const handleSetModelPrices = useCallback((prices: Record<string, ModelPrice>) => {
setModelPrices(prices);
saveModelPrices(prices);
}, []);
return {
usage,
loading,
error,
modelPrices,
setModelPrices: handleSetModelPrices,
loadUsage,
handleExport,
handleImport,
handleImportChange,
importInputRef,
exporting,
importing
};
}

View File

@@ -0,0 +1,28 @@
// Hooks
export { useUsageData } from './hooks/useUsageData';
export type { UsagePayload, UseUsageDataReturn } from './hooks/useUsageData';
export { useSparklines } from './hooks/useSparklines';
export type { SparklineData, SparklineBundle, UseSparklinesOptions, UseSparklinesReturn } from './hooks/useSparklines';
export { useChartData } from './hooks/useChartData';
export type { UseChartDataOptions, UseChartDataReturn } from './hooks/useChartData';
// Components
export { StatCards } from './StatCards';
export type { StatCardsProps } from './StatCards';
export { UsageChart } from './UsageChart';
export type { UsageChartProps } from './UsageChart';
export { ChartLineSelector } from './ChartLineSelector';
export type { ChartLineSelectorProps } from './ChartLineSelector';
export { ApiDetailsCard } from './ApiDetailsCard';
export type { ApiDetailsCardProps } from './ApiDetailsCard';
export { ModelStatsCard } from './ModelStatsCard';
export type { ModelStatsCardProps, ModelStat } from './ModelStatsCard';
export { PriceSettingsCard } from './PriceSettingsCard';
export type { PriceSettingsCardProps } from './PriceSettingsCard';

View File

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

View File

@@ -29,10 +29,13 @@
"required": "Required",
"api_key": "Key",
"base_url": "Address",
"prefix": "Prefix",
"proxy_url": "Proxy",
"alias": "Alias",
"failure": "Failure",
"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",
"custom_headers_label": "Custom Headers",
"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_hint": "By default the current URL is used. Override it here if needed.",
"use_current_address": "Use Current URL",
"remember_password_label": "Remember password",
"management_key_label": "Management Key:",
"management_key_placeholder": "Enter the management key",
"connect_button": "Connect",
@@ -81,16 +85,39 @@
"status": "Connection Status:"
},
"nav": {
"dashboard": "Dashboard",
"basic_settings": "Basic Settings",
"api_keys": "API Keys",
"ai_providers": "AI Providers",
"auth_files": "Auth Files",
"oauth": "OAuth Login",
"quota_management": "Quota Management",
"usage_stats": "Usage Statistics",
"config_management": "Config Management",
"logs": "Logs Viewer",
"system_info": "Management Center Info"
},
"dashboard": {
"title": "Dashboard",
"subtitle": "Welcome to CLI Proxy API Management Center",
"openai_providers": "OpenAI Providers",
"quick_actions": "Quick Actions",
"current_config": "Current Configuration",
"management_keys": "Management Keys",
"provider_keys_detail": "G:{{gemini}} C:{{codex}} Cl:{{claude}} O:{{openai}}",
"oauth_credentials": "OAuth Credentials",
"usage_overview": "Usage Overview",
"total_requests": "Total Requests",
"total_tokens": "Total Tokens",
"rpm_30min": "RPM (30min)",
"tpm_30min": "TPM (30min)",
"models_used": "Models Used",
"no_usage_data": "No usage data available",
"view_detailed_usage": "View Detailed Stats",
"edit_settings": "Edit Settings",
"available_models": "Available Models",
"available_models_desc": "Total models from all providers"
},
"basic_settings": {
"title": "Basic Settings",
"debug_title": "Debug Mode",
@@ -110,7 +137,9 @@
"usage_statistics_enable": "Enable usage statistics",
"logging_title": "Logging",
"logging_to_file_enable": "Enable logging to file",
"request_log_title": "Request Logging",
"request_log_enable": "Enable request logging",
"request_log_warning": "Keep this off unless you need detailed troubleshooting.",
"ws_auth_title": "WebSocket Authentication",
"ws_auth_enable": "Require auth for /ws/*"
},
@@ -149,6 +178,9 @@
"excluded_models_placeholder": "Comma or newline separated, e.g. gemini-1.5-pro, gemini-1.5-flash",
"excluded_models_hint": "Leave empty to allow all models; values are trimmed and deduplicated automatically.",
"excluded_models_count": "Excluding {{count}} models",
"prefix_label": "Prefix (Optional):",
"prefix_placeholder": "e.g.: team-a",
"prefix_hint": "When set, call models as prefix/<model> to target this entry.",
"config_toggle_label": "Enabled",
"config_disabled_badge": "Disabled",
"codex_title": "Codex API Configuration",
@@ -200,8 +232,6 @@
"ampcode_upstream_api_key_current": "Current Amp official key: {{key}}",
"ampcode_clear_upstream_api_key": "Clear official key",
"ampcode_clear_upstream_api_key_confirm": "Are you sure you want to clear the Ampcode upstream API key (Amp official)?",
"ampcode_restrict_management_label": "Restrict Amp management routes to localhost",
"ampcode_restrict_management_hint": "When enabled, Amp management routes (/api/auth, /api/user, /api/threads, etc.) only accept 127.0.0.1/::1 (recommended).",
"ampcode_force_model_mappings_label": "Force model mappings",
"ampcode_force_model_mappings_hint": "When enabled, mappings override local API-key availability checks.",
"ampcode_model_mappings_label": "Model mappings (from → to)",
@@ -331,8 +361,55 @@
"models_excluded_badge": "Excluded",
"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": {
"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.",
"location_label": "Region (optional)",
"location_placeholder": "us-central1",
@@ -425,8 +502,9 @@
"gemini_cli_oauth_button": "Start Gemini CLI Login",
"gemini_cli_oauth_hint": "Login to Google Gemini CLI service through OAuth flow, automatically obtain and save authentication files.",
"gemini_cli_project_id_label": "Google Cloud Project ID (Optional):",
"gemini_cli_project_id_placeholder": "Enter Google Cloud Project ID (optional)",
"gemini_cli_project_id_hint": "If a project ID is specified, authentication information for that project will be used.",
"gemini_cli_project_id_placeholder": "Leave blank to auto-select first available project",
"gemini_cli_project_id_hint": "Optional. If not provided, the system will automatically select the first available project from your account.",
"gemini_cli_project_id_required": "Please enter a Google Cloud project ID.",
"gemini_cli_oauth_url_label": "Authorization URL:",
"gemini_cli_open_link": "Open Link",
"gemini_cli_copy_link": "Copy Link",
@@ -446,6 +524,16 @@
"qwen_oauth_status_error": "Authentication failed:",
"qwen_oauth_start_error": "Failed to start Qwen OAuth:",
"qwen_oauth_polling_error": "Failed to check authentication status:",
"oauth_callback_label": "Callback URL",
"oauth_callback_placeholder": "http://localhost:1455/auth/callback?code=...&state=...",
"oauth_callback_hint": "Remote browser mode: after the provider redirects to http://localhost:..., copy the full URL and submit it here.",
"oauth_callback_button": "Submit Callback URL",
"oauth_callback_required": "Please paste the full redirect URL first.",
"oauth_callback_success": "Callback URL submitted. Continue waiting for authentication.",
"oauth_callback_error": "Failed to submit callback URL:",
"oauth_callback_upgrade_hint": "Please update CLI Proxy API or check the connection.",
"oauth_callback_status_success": "Callback URL submitted, waiting for authentication...",
"oauth_callback_status_error": "Callback URL submission failed:",
"missing_state": "Unable to retrieve authentication state parameter",
"iflow_oauth_title": "iFlow OAuth",
"iflow_oauth_button": "Start iFlow Login",
@@ -497,6 +585,11 @@
"by_hour": "By Hour",
"by_day": "By Day",
"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_2": "Line 2",
"chart_line_label_3": "Line 3",
@@ -552,12 +645,16 @@
"error_log_button": "Select Error Log",
"error_logs_modal_title": "Error Request Logs",
"error_logs_description": "Pick an error request log file to download (only generated when request logging is off).",
"error_logs_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_load_error": "Failed to load error log list",
"error_logs_size": "Size",
"error_logs_modified": "Last modified",
"error_logs_download": "Download",
"error_log_download_success": "Error log downloaded successfully",
"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_desc": "When \"Enable logging to file\" is enabled, logs will be displayed here",
"log_content": "Log Content",
@@ -571,11 +668,17 @@
"auto_refresh_disabled": "Auto refresh disabled",
"load_more_hint": "Scroll up to load more",
"hidden_lines": "Hidden: {{count}} lines",
"loaded_lines": "Loaded: {{count}} lines",
"filtered_lines": "Filtered: {{count}} lines",
"hide_management_logs": "Hide {{prefix}} logs",
"search_placeholder": "Search logs by content or keyword",
"search_empty_title": "No matching logs found",
"search_empty_desc": "Try a different keyword or clear the search filter.",
"search_empty_desc": "Try a different keyword or clear the filters.",
"double_click_copy_hint": "Double-click to copy raw log line",
"copy_success": "Log copied to clipboard",
"copy_failed": "Copy failed",
"lines": "lines",
"removed": "Removed",
"removed": "Filtered",
"upgrade_required_title": "Please Upgrade CLI Proxy API",
"upgrade_required_desc": "The current server version does not support the logs viewing feature. Please upgrade to the latest version of CLI Proxy API to use this feature."
},
@@ -603,6 +706,11 @@
"search_prev": "Previous",
"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": {
"title": "Management Center Info",
"connection_status_title": "Connection Status",
@@ -630,7 +738,19 @@
"version_is_latest": "You are on the latest version",
"version_check_error": "Update check failed",
"version_current_missing": "Server version is unavailable; cannot compare",
"version_unknown": "Unknown"
"version_unknown": "Unknown",
"quick_links_title": "Quick Links",
"quick_links_desc": "Access project repositories and documentation for help and updates.",
"link_main_repo": "Main Repository",
"link_main_repo_desc": "CLI Proxy API core program source code",
"link_webui_repo": "WebUI Repository",
"link_webui_repo_desc": "Management Center frontend source code",
"link_docs": "Documentation",
"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": {
"debug_updated": "Debug settings updated",
@@ -643,6 +763,7 @@
"logging_to_file_updated": "Logging settings updated",
"request_log_updated": "Request logging 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_updated": "API key updated successfully",
"api_key_deleted": "API key deleted successfully",

View File

@@ -29,10 +29,13 @@
"required": "必填",
"api_key": "密钥",
"base_url": "地址",
"prefix": "前缀",
"proxy_url": "代理",
"alias": "别名",
"failure": "失败",
"unknown_error": "未知错误",
"quota_update_required": "请更新 CPA 版本或检查更新",
"quota_check_credential": "请检查凭证状态",
"copy": "复制",
"custom_headers_label": "自定义请求头",
"custom_headers_hint": "可选,设置需要附带到请求中的 HTTP 头,名称和值均不能为空。",
@@ -60,6 +63,7 @@
"custom_connection_placeholder": "例如: https://example.com:8317",
"custom_connection_hint": "默认使用当前访问地址,若需要可手动输入其他地址。",
"use_current_address": "使用当前地址",
"remember_password_label": "记住密码",
"management_key_label": "管理密钥:",
"management_key_placeholder": "请输入管理密钥",
"connect_button": "连接",
@@ -81,16 +85,39 @@
"status": "连接状态:"
},
"nav": {
"dashboard": "仪表盘",
"basic_settings": "基础设置",
"api_keys": "API 密钥",
"ai_providers": "AI 提供商",
"auth_files": "认证文件",
"oauth": "OAuth 登录",
"quota_management": "配额管理",
"usage_stats": "使用统计",
"config_management": "配置管理",
"logs": "日志查看",
"system_info": "中心信息"
},
"dashboard": {
"title": "仪表盘",
"subtitle": "欢迎使用 CLI Proxy API 管理中心",
"openai_providers": "OpenAI 提供商",
"quick_actions": "快捷操作",
"current_config": "当前配置",
"management_keys": "管理密钥",
"provider_keys_detail": "G:{{gemini}} C:{{codex}} Cl:{{claude}} O:{{openai}}",
"oauth_credentials": "OAuth 凭证",
"usage_overview": "使用概览",
"total_requests": "总请求数",
"total_tokens": "总 Token 数",
"rpm_30min": "RPM (30分钟)",
"tpm_30min": "TPM (30分钟)",
"models_used": "使用模型数",
"no_usage_data": "暂无使用数据",
"view_detailed_usage": "查看详细统计",
"edit_settings": "编辑设置",
"available_models": "可用模型",
"available_models_desc": "所有提供商的模型总数"
},
"basic_settings": {
"title": "基础设置",
"debug_title": "调试模式",
@@ -110,7 +137,9 @@
"usage_statistics_enable": "启用使用统计",
"logging_title": "日志记录",
"logging_to_file_enable": "启用日志记录到文件",
"request_log_title": "请求日志",
"request_log_enable": "启用请求日志",
"request_log_warning": "仅在需要排查问题时开启,日常请保持关闭。",
"ws_auth_title": "WebSocket 鉴权",
"ws_auth_enable": "启用 /ws/* 鉴权"
},
@@ -149,6 +178,9 @@
"excluded_models_placeholder": "用逗号或换行分隔,例如: gemini-1.5-pro, gemini-1.5-flash",
"excluded_models_hint": "留空表示不过滤;保存时会自动去重并忽略空白。",
"excluded_models_count": "排除 {{count}} 个模型",
"prefix_label": "前缀 (可选):",
"prefix_placeholder": "例如: team-a",
"prefix_hint": "设置后可用 prefix/<model> 选择该条目。",
"config_toggle_label": "启用",
"config_disabled_badge": "已停用",
"codex_title": "Codex API 配置",
@@ -200,8 +232,6 @@
"ampcode_upstream_api_key_current": "当前Amp官方密钥: {{key}}",
"ampcode_clear_upstream_api_key": "清除官方密钥",
"ampcode_clear_upstream_api_key_confirm": "确定要清除 Ampcode 的 upstream API keyAmp官方",
"ampcode_restrict_management_label": "仅允许本机访问 Amp 管理路由",
"ampcode_restrict_management_hint": "开启后,/api/auth、/api/user、/api/threads 等 Amp 管理路由仅允许 127.0.0.1/::1 访问(推荐)。",
"ampcode_force_model_mappings_label": "强制应用模型映射",
"ampcode_force_model_mappings_hint": "开启后,模型映射将覆盖本地 API Key 可用性判断。",
"ampcode_model_mappings_label": "模型映射 (from → to)",
@@ -331,8 +361,55 @@
"models_excluded_badge": "已排除",
"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": {
"title": "Vertex AI 凭证导入",
"title": "Vertex JSON 登录",
"description": "上传 Google 服务账号 JSON使用 CLI vertex-import 同步规则写入 auth-dir/vertex-<project>.json。",
"location_label": "目标区域 (可选)",
"location_placeholder": "us-central1",
@@ -425,8 +502,9 @@
"gemini_cli_oauth_button": "开始 Gemini CLI 登录",
"gemini_cli_oauth_hint": "通过 OAuth 流程登录 Google Gemini CLI 服务,自动获取并保存认证文件。",
"gemini_cli_project_id_label": "Google Cloud 项目 ID (可选):",
"gemini_cli_project_id_placeholder": "输入 Google Cloud 项目 ID (可选)",
"gemini_cli_project_id_hint": "如果指定了项目 ID将使用该项目的认证信息。",
"gemini_cli_project_id_placeholder": "留空将自动选择第一个可用项目",
"gemini_cli_project_id_hint": "可选填写项目 ID。如不填写系统将自动选择您账号下的第一个可用项目。",
"gemini_cli_project_id_required": "请填写 Google Cloud 项目 ID。",
"gemini_cli_oauth_url_label": "授权链接:",
"gemini_cli_open_link": "打开链接",
"gemini_cli_copy_link": "复制链接",
@@ -446,6 +524,16 @@
"qwen_oauth_status_error": "认证失败:",
"qwen_oauth_start_error": "启动 Qwen OAuth 失败:",
"qwen_oauth_polling_error": "检查认证状态失败:",
"oauth_callback_label": "回调 URL",
"oauth_callback_placeholder": "http://localhost:1455/auth/callback?code=...&state=...",
"oauth_callback_hint": "远程浏览器模式:当授权跳转到 http://localhost:... 后,复制完整 URL 并提交到这里。",
"oauth_callback_button": "提交回调 URL",
"oauth_callback_required": "请先粘贴完整的回调 URL。",
"oauth_callback_success": "回调 URL 已提交,请继续等待认证。",
"oauth_callback_error": "提交回调 URL 失败:",
"oauth_callback_upgrade_hint": "请更新CLI Proxy API或检查连接",
"oauth_callback_status_success": "回调 URL 已提交,等待认证中...",
"oauth_callback_status_error": "回调 URL 提交失败:",
"missing_state": "无法获取认证状态参数",
"iflow_oauth_title": "iFlow OAuth",
"iflow_oauth_button": "开始 iFlow 登录",
@@ -497,6 +585,11 @@
"by_hour": "按小时",
"by_day": "按天",
"refresh": "刷新",
"export": "导出数据",
"import": "导入数据",
"export_success": "使用统计已导出",
"import_success": "导入完成:新增 {{added}},跳过 {{skipped}},总请求 {{total}},失败 {{failed}}",
"import_invalid": "导入文件格式不正确",
"chart_line_label_1": "曲线 1",
"chart_line_label_2": "曲线 2",
"chart_line_label_3": "曲线 3",
@@ -552,12 +645,16 @@
"error_log_button": "选择错误日志",
"error_logs_modal_title": "错误请求日志",
"error_logs_description": "请选择要下载的错误请求日志文件(仅在关闭请求日志时生成)。",
"error_logs_request_log_enabled": "当前已开启请求日志,按接口约定错误请求日志列表会始终为空。关闭请求日志后再刷新即可查看。",
"error_logs_empty": "暂无错误请求日志文件",
"error_logs_load_error": "加载错误日志列表失败",
"error_logs_size": "大小",
"error_logs_modified": "最后修改",
"error_logs_download": "下载",
"error_log_download_success": "错误日志下载成功",
"request_log_download_title": "下载报文",
"request_log_download_confirm": "是否要下载id为{{id}}的报文?",
"request_log_download_success": "报文下载成功",
"empty_title": "暂无日志记录",
"empty_desc": "当启用\"日志记录到文件\"功能后,日志将显示在这里",
"log_content": "日志内容",
@@ -571,11 +668,17 @@
"auto_refresh_disabled": "自动刷新已关闭",
"load_more_hint": "向上滚动加载更多",
"hidden_lines": "已隐藏 {{count}} 行",
"loaded_lines": "已载入 {{count}} 行",
"filtered_lines": "已过滤 {{count}} 行",
"hide_management_logs": "屏蔽 {{prefix}} 日志",
"search_placeholder": "搜索日志内容或关键字",
"search_empty_title": "未找到匹配的日志",
"search_empty_desc": "尝试更换关键字或清空搜索条件。",
"search_empty_desc": "尝试更换关键字或清空筛选条件。",
"double_click_copy_hint": "双击复制日志原文",
"copy_success": "已复制日志原文",
"copy_failed": "复制失败",
"lines": "行",
"removed": "已删除",
"removed": "已过滤",
"upgrade_required_title": "需要升级 CLI Proxy API",
"upgrade_required_desc": "当前服务器版本不支持日志查看功能,请升级到最新版本的 CLI Proxy API 以使用此功能。"
},
@@ -603,6 +706,11 @@
"search_prev": "上一个",
"search_next": "下一个"
},
"quota_management": {
"title": "配额管理",
"description": "集中查看 OAuth 额度与剩余情况",
"refresh_files": "刷新认证文件"
},
"system_info": {
"title": "管理中心信息",
"connection_status_title": "连接状态",
@@ -630,7 +738,19 @@
"version_is_latest": "当前已是最新版本",
"version_check_error": "检查更新失败",
"version_current_missing": "未获取到服务器版本号,暂无法比对",
"version_unknown": "未知"
"version_unknown": "未知",
"quick_links_title": "快捷链接",
"quick_links_desc": "访问项目仓库和文档,获取帮助和更新。",
"link_main_repo": "主程序仓库",
"link_main_repo_desc": "CLI Proxy API 核心程序源代码",
"link_webui_repo": "WebUI 仓库",
"link_webui_repo_desc": "管理中心前端界面源代码",
"link_docs": "使用教程",
"link_docs_desc": "配置指南和使用说明",
"clear_login_title": "本地登录信息",
"clear_login_desc": "清理本地保存的登录信息并退出登录,不会影响使用统计中的价格设置。",
"clear_login_button": "清理登录信息",
"clear_login_confirm": "确认清理本地登录信息并退出登录?"
},
"notification": {
"debug_updated": "调试设置已更新",
@@ -643,6 +763,7 @@
"logging_to_file_updated": "日志记录设置已更新",
"request_log_updated": "请求日志设置已更新",
"ws_auth_updated": "WebSocket 鉴权设置已更新",
"login_storage_cleared": "本地登录信息已清理",
"api_key_added": "API密钥添加成功",
"api_key_updated": "API密钥更新成功",
"api_key_deleted": "API密钥删除成功",

View File

@@ -5,6 +5,17 @@
width: 100%;
}
.cardTitle {
display: flex;
align-items: center;
gap: $spacing-sm;
}
.cardTitleIcon {
width: 24px;
height: 24px;
}
.pageTitle {
font-size: 28px;
font-weight: 700;
@@ -386,6 +397,79 @@
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']) {
.headerBadge {
@@ -404,8 +488,17 @@
}
.excludedModelTag {
background: rgba(251, 191, 36, 0.2);
border-color: rgba(251, 191, 36, 0.4);
background: rgba(251, 191, 36, 0.22);
border-color: rgba(251, 191, 36, 0.55);
color: #fde68a;
.modelName {
color: #fde68a;
}
}
.excludedModelsLabel {
color: #fde68a;
}
.apiKeyEntryCard {
@@ -416,4 +509,23 @@
.apiKeyEntryIndex {
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;
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -124,7 +124,7 @@
background-color: var(--bg-primary);
color: var(--text-primary);
font-size: 14px;
cursor: pointer;
cursor: text;
height: 38px;
box-sizing: border-box;
@@ -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 {
background-color: var(--bg-primary);
@@ -250,6 +516,78 @@
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 {
display: flex;
gap: $spacing-xs;
@@ -350,6 +688,60 @@
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 {
max-height: 400px;
@@ -417,8 +809,6 @@
display: flex;
flex-direction: column;
gap: $spacing-sm;
max-height: 400px;
overflow-y: auto;
}
.modelItem {

File diff suppressed because it is too large Load Diff

View File

@@ -2,6 +2,10 @@
.container {
width: 100%;
min-height: 100%;
display: flex;
flex-direction: column;
overflow-y: auto;
}
.pageTitle {
@@ -21,6 +25,8 @@
display: flex;
flex-direction: column;
gap: $spacing-lg;
flex: 1;
min-height: 0;
}
.searchInputWrapper {
@@ -127,7 +133,8 @@
.editorWrapper {
width: 100%;
height: 500px;
flex: 1;
min-height: 800px;
border: 1px solid var(--border-color);
border-radius: $radius-lg;
overflow: hidden;
@@ -159,6 +166,9 @@
.cm-scroller {
overflow: auto;
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 {
@@ -206,6 +216,14 @@
}
}
.configCard {
display: flex;
flex-direction: column;
height: 1120px;
flex-shrink: 0;
overflow: visible;
}
.actions {
display: flex;
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 { showNotification } = useNotificationStore();
const connectionStatus = useAuthStore((state) => state.connectionStatus);
const theme = useThemeStore((state) => state.theme);
const resolvedTheme = useThemeStore((state) => state.resolvedTheme);
const [content, setContent] = useState('');
const [loading, setLoading] = useState(true);
@@ -224,7 +224,7 @@ export function ConfigPage() {
<h1 className={styles.pageTitle}>{t('config_management.title')}</h1>
<p className={styles.description}>{t('config_management.description')}</p>
<Card>
<Card className={styles.configCard}>
<div className={styles.content}>
{/* Editor */}
{error && <div className="error-box">{error}</div>}
@@ -289,7 +289,7 @@ export function ConfigPage() {
value={content}
onChange={handleChange}
extensions={extensions}
theme={theme === 'dark' ? 'dark' : 'light'}
theme={resolvedTheme}
editable={!disableControls && !loading}
placeholder={t('config_management.editor_placeholder')}
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 { useNavigate, useLocation } from 'react-router-dom';
import { Navigate, useNavigate, useLocation } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { Button } from '@/components/ui/Button';
import { Input } from '@/components/ui/Input';
import { IconEye, IconEyeOff } from '@/components/ui/icons';
import { useAuthStore, useNotificationStore } from '@/stores';
import { useAuthStore, useLanguageStore, useNotificationStore } from '@/stores';
import { detectApiBaseFromLocation, normalizeApiBase } from '@/utils/connection';
export function LoginPage() {
@@ -12,21 +12,26 @@ export function LoginPage() {
const navigate = useNavigate();
const location = useLocation();
const { showNotification } = useNotificationStore();
const language = useLanguageStore((state) => state.language);
const toggleLanguage = useLanguageStore((state) => state.toggleLanguage);
const isAuthenticated = useAuthStore((state) => state.isAuthenticated);
const login = useAuthStore((state) => state.login);
const restoreSession = useAuthStore((state) => state.restoreSession);
const storedBase = useAuthStore((state) => state.apiBase);
const storedKey = useAuthStore((state) => state.managementKey);
const storedRememberPassword = useAuthStore((state) => state.rememberPassword);
const [apiBase, setApiBase] = useState('');
const [managementKey, setManagementKey] = useState('');
const [showCustomBase, setShowCustomBase] = useState(false);
const [showKey, setShowKey] = useState(false);
const [rememberPassword, setRememberPassword] = useState(false);
const [loading, setLoading] = useState(false);
const [autoLoading, setAutoLoading] = useState(true);
const [error, setError] = useState('');
const detectedBase = useMemo(() => detectApiBaseFromLocation(), []);
const nextLanguageLabel = language === 'zh-CN' ? t('language.english') : t('language.chinese');
useEffect(() => {
const init = async () => {
@@ -35,6 +40,7 @@ export function LoginPage() {
if (!autoLoggedIn) {
setApiBase(storedBase || detectedBase);
setManagementKey(storedKey || '');
setRememberPassword(storedRememberPassword || Boolean(storedKey));
}
} finally {
setAutoLoading(false);
@@ -42,18 +48,12 @@ export function LoginPage() {
};
init();
}, [detectedBase, restoreSession, storedBase, storedKey]);
}, [detectedBase, restoreSession, storedBase, storedKey, storedRememberPassword]);
useEffect(() => {
if (isAuthenticated) {
const redirect = (location.state as any)?.from?.pathname || '/';
navigate(redirect, { replace: true });
}
}, [isAuthenticated, navigate, location.state]);
const handleUseCurrent = () => {
setApiBase(detectedBase);
};
if (isAuthenticated) {
const redirect = (location.state as any)?.from?.pathname || '/';
return <Navigate to={redirect} replace />;
}
const handleSubmit = async () => {
if (!managementKey.trim()) {
@@ -65,7 +65,11 @@ export function LoginPage() {
setLoading(true);
setError('');
try {
await login({ apiBase: baseToUse, managementKey: managementKey.trim() });
await login({
apiBase: baseToUse,
managementKey: managementKey.trim(),
rememberPassword
});
showNotification(t('common.connected_status'), 'success');
navigate('/', { replace: true });
} catch (err: any) {
@@ -81,7 +85,20 @@ export function LoginPage() {
<div className="login-page">
<div className="login-card">
<div className="login-header">
<div className="title">{t('title.login')}</div>
<div className="login-title-row">
<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>
@@ -138,15 +155,20 @@ export function LoginPage() {
}
/>
<div style={{ display: 'flex', gap: 12, alignItems: 'center' }}>
<Button variant="secondary" onClick={handleUseCurrent}>
{t('login.use_current_address')}
</Button>
<Button fullWidth onClick={handleSubmit} loading={loading}>
{loading ? t('login.submitting') : t('login.submit_button')}
</Button>
<div className="toggle-advanced">
<input
id="remember-password-toggle"
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}>
{loading ? t('login.submitting') : t('login.submit_button')}
</Button>
{error && <div className="error-box">{error}</div>}
{autoLoading && (

View File

@@ -2,19 +2,80 @@
.container {
width: 100%;
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
@include mobile {
min-height: auto;
overflow: visible;
}
}
.pageTitle {
font-size: 28px;
font-weight: 700;
color: var(--text-primary);
margin: 0 0 $spacing-xl 0;
margin: 0 0 $spacing-lg 0;
}
.tabBar {
display: flex;
gap: $spacing-xs;
margin-bottom: $spacing-lg;
border-bottom: 1px solid var(--border-color);
}
.tabItem {
@include button-reset;
padding: 12px 20px;
font-size: 14px;
font-weight: 500;
color: var(--text-secondary);
background: transparent;
border-bottom: 2px solid transparent;
margin-bottom: -1px;
cursor: pointer;
transition:
color 0.15s ease,
border-color 0.15s ease;
&:hover {
color: var(--text-primary);
}
}
.tabActive {
color: var(--primary-color);
border-bottom-color: var(--primary-color);
}
.content {
display: flex;
flex-direction: column;
gap: $spacing-lg;
flex: 1;
min-height: 0;
@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 {
@@ -22,12 +83,76 @@
align-items: center;
gap: $spacing-sm;
flex-wrap: wrap;
margin-left: auto;
@include mobile {
align-items: flex-start;
margin-left: 0;
width: 100%;
}
}
.filters {
display: flex;
align-items: center;
gap: $spacing-md;
flex-wrap: wrap;
margin-bottom: $spacing-md;
:global(.form-group) {
margin: 0;
}
@include mobile {
gap: $spacing-sm;
margin-bottom: $spacing-sm;
}
}
.searchWrapper {
flex: 1;
min-width: 220px;
max-width: 420px;
}
.searchInput {
padding-right: 44px !important;
}
.searchIcon {
color: var(--text-tertiary);
pointer-events: none;
}
.searchClear {
@include button-reset;
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border-radius: $radius-full;
color: var(--text-secondary);
&:hover {
background: var(--bg-secondary);
}
}
.filterStats {
margin-left: auto;
display: inline-flex;
align-items: center;
gap: 10px;
font-size: 12px;
color: var(--text-secondary);
white-space: nowrap;
}
.removedCount {
color: var(--text-tertiary);
}
.actionButton {
white-space: nowrap;
}
@@ -56,9 +181,33 @@
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: $radius-md;
max-height: 620px;
flex: 1 1 auto;
min-height: 280px;
max-height: calc(100vh - 320px);
overflow: auto;
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 {
@@ -74,6 +223,17 @@
background: var(--bg-primary);
color: var(--text-secondary);
font-size: 12px;
@include mobile {
flex-direction: column;
align-items: flex-start;
justify-content: flex-start;
gap: $spacing-xs;
> span {
width: 100%;
}
}
}
.loadMoreCount {
@@ -81,6 +241,22 @@
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 {
display: flex;
flex-direction: column;
@@ -93,7 +269,9 @@
padding: 10px 12px;
border-bottom: 1px solid var(--border-color);
border-left: 3px solid transparent;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New',
cursor: copy;
font-family:
ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New',
monospace;
font-size: 12.5px;
line-height: 1.45;
@@ -103,9 +281,18 @@
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 {
grid-template-columns: 1fr;
gap: $spacing-xs;
padding: 8px 10px;
font-size: 11.5px;
}
}
@@ -129,15 +316,8 @@
.rowMain {
display: flex;
flex-direction: column;
gap: 4px;
min-width: 0;
}
.rowMeta {
display: flex;
align-items: center;
flex-wrap: wrap;
align-items: baseline;
gap: 6px;
min-width: 0;
}
@@ -178,6 +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 {
font-variant-numeric: tabular-nums;
}
@@ -245,11 +434,101 @@
@include mobile {
max-width: 100%;
flex-basis: 100%;
}
}
.message {
color: var(--text-secondary);
white-space: pre-wrap;
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;
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -4,6 +4,17 @@
width: 100%;
}
.cardTitle {
display: flex;
align-items: center;
gap: $spacing-sm;
}
.cardTitleIcon {
width: 24px;
height: 24px;
}
.pageTitle {
font-size: 28px;
font-weight: 700;
@@ -59,3 +70,69 @@
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 { Card } from '@/components/ui/Card';
import { Button } from '@/components/ui/Button';
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 { isLocalhost } from '@/utils/connection';
import { vertexApi, type VertexImportResponse } from '@/services/api/vertex';
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 {
url?: string;
@@ -14,6 +22,12 @@ interface ProviderState {
status?: 'idle' | 'waiting' | 'success' | 'error';
error?: string;
polling?: boolean;
projectId?: string;
projectIdError?: string;
callbackUrl?: string;
callbackSubmitting?: boolean;
callbackStatus?: 'success' | 'error';
callbackError?: string;
}
interface IFlowCookieState {
@@ -24,24 +38,52 @@ interface IFlowCookieState {
errorType?: 'error' | 'warning';
}
const PROVIDERS: { id: OAuthProvider; titleKey: string; hintKey: string; urlLabelKey: string }[] = [
{ id: 'codex', titleKey: 'auth_login.codex_oauth_title', hintKey: 'auth_login.codex_oauth_hint', urlLabelKey: 'auth_login.codex_oauth_url_label' },
{ id: 'anthropic', titleKey: 'auth_login.anthropic_oauth_title', hintKey: 'auth_login.anthropic_oauth_hint', urlLabelKey: 'auth_login.anthropic_oauth_url_label' },
{ id: 'antigravity', titleKey: 'auth_login.antigravity_oauth_title', hintKey: 'auth_login.antigravity_oauth_hint', urlLabelKey: 'auth_login.antigravity_oauth_url_label' },
{ id: 'gemini-cli', titleKey: 'auth_login.gemini_cli_oauth_title', hintKey: 'auth_login.gemini_cli_oauth_hint', urlLabelKey: 'auth_login.gemini_cli_oauth_url_label' },
{ id: 'qwen', titleKey: 'auth_login.qwen_oauth_title', hintKey: 'auth_login.qwen_oauth_hint', urlLabelKey: 'auth_login.qwen_oauth_url_label' },
{ id: 'iflow', titleKey: 'auth_login.iflow_oauth_title', hintKey: 'auth_login.iflow_oauth_hint', urlLabelKey: 'auth_login.iflow_oauth_url_label' }
interface VertexImportResult {
projectId?: string;
email?: string;
location?: string;
authFile?: string;
}
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 }
];
const CALLBACK_SUPPORTED: OAuthProvider[] = ['codex', 'anthropic', 'antigravity', 'gemini-cli'];
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() {
const { t } = useTranslation();
const { showNotification } = useNotificationStore();
const resolvedTheme = useThemeStore((state) => state.resolvedTheme);
const [states, setStates] = useState<Record<OAuthProvider, ProviderState>>({} as Record<OAuthProvider, ProviderState>);
const [iflowCookie, setIflowCookie] = useState<IFlowCookieState>({ cookie: '', loading: false });
const [vertexState, setVertexState] = useState<VertexImportState>({
fileName: '',
location: '',
loading: false
});
const timers = useRef<Record<string, number>>({});
// 检测是否为本地访问
const isLocal = useMemo(() => isLocalhost(window.location.hostname), []);
const vertexFileInputRef = useRef<HTMLInputElement | null>(null);
useEffect(() => {
return () => {
@@ -49,6 +91,13 @@ export function OAuthPage() {
};
}, []);
const updateProviderState = (provider: OAuthProvider, next: Partial<ProviderState>) => {
setStates((prev) => ({
...prev,
[provider]: { ...(prev[provider] ?? {}), ...next }
}));
};
const startPolling = (provider: OAuthProvider, state: string) => {
if (timers.current[provider]) {
clearInterval(timers.current[provider]);
@@ -57,27 +106,21 @@ export function OAuthPage() {
try {
const res = await oauthApi.getAuthStatus(state);
if (res.status === 'ok') {
setStates((prev) => ({
...prev,
[provider]: { ...prev[provider], status: 'success', polling: false }
}));
showNotification(t('auth_login.codex_oauth_status_success'), 'success');
updateProviderState(provider, { status: 'success', polling: false });
showNotification(t(getAuthKey(provider, 'oauth_status_success')), 'success');
window.clearInterval(timer);
delete timers.current[provider];
} else if (res.status === 'error') {
setStates((prev) => ({
...prev,
[provider]: { ...prev[provider], status: 'error', error: res.error, polling: false }
}));
showNotification(`${t('auth_login.codex_oauth_status_error')} ${res.error || ''}`, 'error');
updateProviderState(provider, { status: 'error', error: res.error, polling: false });
showNotification(
`${t(getAuthKey(provider, 'oauth_status_error'))} ${res.error || ''}`,
'error'
);
window.clearInterval(timer);
delete timers.current[provider];
}
} catch (err: any) {
setStates((prev) => ({
...prev,
[provider]: { ...prev[provider], status: 'error', error: err?.message, polling: false }
}));
updateProviderState(provider, { status: 'error', error: err?.message, polling: false });
window.clearInterval(timer);
delete timers.current[provider];
}
@@ -86,25 +129,31 @@ export function OAuthPage() {
};
const startAuth = async (provider: OAuthProvider) => {
setStates((prev) => ({
...prev,
[provider]: { ...prev[provider], status: 'waiting', polling: true, error: undefined }
}));
const projectId = provider === 'gemini-cli' ? (states[provider]?.projectId || '').trim() : undefined;
// 项目 ID 现在是可选的,如果不输入将自动选择第一个可用项目
if (provider === 'gemini-cli') {
updateProviderState(provider, { projectIdError: undefined });
}
updateProviderState(provider, {
status: 'waiting',
polling: true,
error: undefined,
callbackStatus: undefined,
callbackError: undefined,
callbackUrl: ''
});
try {
const res = await oauthApi.startAuth(provider);
setStates((prev) => ({
...prev,
[provider]: { ...prev[provider], url: res.url, state: res.state, status: 'waiting', polling: true }
}));
const res = await oauthApi.startAuth(
provider,
provider === 'gemini-cli' ? { projectId: projectId || undefined } : undefined
);
updateProviderState(provider, { url: res.url, state: res.state, status: 'waiting', polling: true });
if (res.state) {
startPolling(provider, res.state);
}
} catch (err: any) {
setStates((prev) => ({
...prev,
[provider]: { ...prev[provider], status: 'error', error: err?.message, polling: false }
}));
showNotification(`${t('auth_login.codex_oauth_start_error')} ${err?.message || ''}`, 'error');
updateProviderState(provider, { status: 'error', error: err?.message, polling: false });
showNotification(`${t(getAuthKey(provider, 'oauth_start_error'))} ${err?.message || ''}`, 'error');
}
};
@@ -118,6 +167,40 @@ export function OAuthPage() {
}
};
const submitCallback = async (provider: OAuthProvider) => {
const redirectUrl = (states[provider]?.callbackUrl || '').trim();
if (!redirectUrl) {
showNotification(t('auth_login.oauth_callback_required'), 'warning');
return;
}
updateProviderState(provider, {
callbackSubmitting: true,
callbackStatus: undefined,
callbackError: undefined
});
try {
await oauthApi.submitCallback(provider, redirectUrl);
updateProviderState(provider, { callbackSubmitting: false, callbackStatus: 'success' });
showNotification(t('auth_login.oauth_callback_success'), 'success');
} catch (err: any) {
const errorMessage =
err?.status === 404
? t('auth_login.oauth_callback_upgrade_hint', {
defaultValue: 'Please update CLI Proxy API or check the connection.'
})
: err?.message;
updateProviderState(provider, {
callbackSubmitting: false,
callbackStatus: 'error',
callbackError: errorMessage
});
const notificationMessage = errorMessage
? `${t('auth_login.oauth_callback_error')} ${errorMessage}`
: t('auth_login.oauth_callback_error');
showNotification(notificationMessage, 'error');
}
};
const submitIflowCookie = async () => {
const cookie = iflowCookie.cookie.trim();
if (!cookie) {
@@ -157,6 +240,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 (
<div className={styles.container}>
<h1 className={styles.pageTitle}>{t('nav.oauth', { defaultValue: 'OAuth' })}</h1>
@@ -164,56 +305,104 @@ export function OAuthPage() {
<div className={styles.content}>
{PROVIDERS.map((provider) => {
const state = states[provider.id] || {};
// 非本地访问时禁用所有 OAuth 登录方式
const isDisabled = !isLocal;
const canSubmitCallback = CALLBACK_SUPPORTED.includes(provider.id) && Boolean(state.url);
return (
<div
key={provider.id}
style={isDisabled ? { opacity: 0.6, pointerEvents: 'none' } : undefined}
>
<div key={provider.id}>
<Card
title={t(provider.titleKey)}
title={
<span className={styles.cardTitle}>
<img
src={getIcon(provider.icon, resolvedTheme)}
alt=""
className={styles.cardTitleIcon}
/>
{t(provider.titleKey)}
</span>
}
extra={
<Button
onClick={() => startAuth(provider.id)}
loading={state.polling}
disabled={isDisabled}
>
<Button onClick={() => startAuth(provider.id)} loading={state.polling}>
{t('common.login')}
</Button>
}
>
<div className="hint">{t(provider.hintKey)}</div>
{isDisabled && (
<div className="status-badge warning" style={{ marginTop: 8 }}>
{t('auth_login.remote_access_disabled')}
</div>
{provider.id === 'gemini-cli' && (
<Input
label={t('auth_login.gemini_cli_project_id_label')}
hint={t('auth_login.gemini_cli_project_id_hint')}
value={state.projectId || ''}
error={state.projectIdError}
onChange={(e) =>
updateProviderState(provider.id, {
projectId: e.target.value,
projectIdError: undefined
})
}
placeholder={t('auth_login.gemini_cli_project_id_placeholder')}
/>
)}
{!isDisabled && state.url && (
<div className="connection-box">
<div className="label">{t(provider.urlLabelKey)}</div>
<div className="value">{state.url}</div>
<div className="item-actions" style={{ marginTop: 8 }}>
{state.url && (
<div className={`connection-box ${styles.authUrlBox}`}>
<div className={styles.authUrlLabel}>{t(provider.urlLabelKey)}</div>
<div className={styles.authUrlValue}>{state.url}</div>
<div className={styles.authUrlActions}>
<Button variant="secondary" size="sm" onClick={() => copyLink(state.url!)}>
{t('auth_login.codex_copy_link')}
{t(getAuthKey(provider.id, 'copy_link'))}
</Button>
<Button
variant="secondary"
size="sm"
onClick={() => window.open(state.url, '_blank', 'noopener,noreferrer')}
>
{t('auth_login.codex_open_link')}
{t(getAuthKey(provider.id, 'open_link'))}
</Button>
</div>
</div>
)}
{!isDisabled && state.status && state.status !== 'idle' && (
{canSubmitCallback && (
<div className={styles.callbackSection}>
<Input
label={t('auth_login.oauth_callback_label')}
hint={t('auth_login.oauth_callback_hint')}
value={state.callbackUrl || ''}
onChange={(e) =>
updateProviderState(provider.id, {
callbackUrl: e.target.value,
callbackStatus: undefined,
callbackError: undefined
})
}
placeholder={t('auth_login.oauth_callback_placeholder')}
/>
<div className={styles.callbackActions}>
<Button
variant="secondary"
size="sm"
onClick={() => submitCallback(provider.id)}
loading={state.callbackSubmitting}
>
{t('auth_login.oauth_callback_button')}
</Button>
</div>
{state.callbackStatus === 'success' && state.status === 'waiting' && (
<div className="status-badge success" style={{ marginTop: 8 }}>
{t('auth_login.oauth_callback_status_success')}
</div>
)}
{state.callbackStatus === 'error' && (
<div className="status-badge error" style={{ marginTop: 8 }}>
{t('auth_login.oauth_callback_status_error')} {state.callbackError || ''}
</div>
)}
</div>
)}
{state.status && state.status !== 'idle' && (
<div className="status-badge" style={{ marginTop: 8 }}>
{state.status === 'success'
? t('auth_login.codex_oauth_status_success')
? t(getAuthKey(provider.id, 'oauth_status_success'))
: state.status === 'error'
? `${t('auth_login.codex_oauth_status_error')} ${state.error || ''}`
: t('auth_login.codex_oauth_status_waiting')}
? `${t(getAuthKey(provider.id, 'oauth_status_error'))} ${state.error || ''}`
: t(getAuthKey(provider.id, 'oauth_status_waiting'))}
</div>
)}
</Card>
@@ -221,9 +410,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 登录 */}
<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={
<Button onClick={submitIflowCookie} loading={iflowCookie.loading}>
{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: text;
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;
}

81
src/pages/QuotaPage.tsx Normal file
View File

@@ -0,0 +1,81 @@
/**
* Quota management page - coordinates the three quota sections.
*/
import { useCallback, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Button } from '@/components/ui/Button';
import { useAuthStore } from '@/stores';
import { authFilesApi } from '@/services/api';
import {
QuotaSection,
ANTIGRAVITY_CONFIG,
CODEX_CONFIG,
GEMINI_CLI_CONFIG
} from '@/components/quota';
import type { AuthFileItem } from '@/types';
import styles from './QuotaPage.module.scss';
export function QuotaPage() {
const { t } = useTranslation();
const connectionStatus = useAuthStore((state) => state.connectionStatus);
const [files, setFiles] = useState<AuthFileItem[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const disableControls = connectionStatus !== 'connected';
const loadFiles = useCallback(async () => {
setLoading(true);
setError('');
try {
const data = await authFilesApi.list();
setFiles(data?.files || []);
} catch (err: unknown) {
const errorMessage = err instanceof Error ? err.message : t('notification.refresh_failed');
setError(errorMessage);
} finally {
setLoading(false);
}
}, [t]);
useEffect(() => {
loadFiles();
}, [loadFiles]);
return (
<div className={styles.container}>
<div className={styles.pageHeader}>
<h1 className={styles.pageTitle}>{t('quota_management.title')}</h1>
<p className={styles.description}>{t('quota_management.description')}</p>
<div className={styles.headerActions}>
<Button variant="secondary" size="sm" onClick={loadFiles} disabled={loading}>
{t('quota_management.refresh_files')}
</Button>
</div>
</div>
{error && <div className={styles.errorBox}>{error}</div>}
<QuotaSection
config={ANTIGRAVITY_CONFIG}
files={files}
loading={loading}
disabled={disableControls}
/>
<QuotaSection
config={CODEX_CONFIG}
files={files}
loading={loading}
disabled={disableControls}
/>
<QuotaSection
config={GEMINI_CLI_CONFIG}
files={files}
loading={loading}
disabled={disableControls}
/>
</div>
);
}

View File

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

View File

@@ -34,6 +34,12 @@
margin: 0 0 $spacing-md 0;
}
.clearLoginActions {
display: flex;
justify-content: flex-end;
align-items: center;
}
.infoGrid {
display: grid;
gap: $spacing-sm;
@@ -135,3 +141,81 @@
}
}
}
.quickLinks {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: $spacing-md;
}
.linkCard {
display: flex;
align-items: center;
gap: $spacing-md;
padding: $spacing-md $spacing-lg;
background-color: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: $radius-lg;
text-decoration: none;
color: inherit;
transition: all 0.2s ease;
&:hover {
background-color: var(--bg-hover);
border-color: var(--primary-color);
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
&:active {
transform: translateY(0);
}
}
.linkIcon {
display: flex;
align-items: center;
justify-content: center;
width: 44px;
height: 44px;
border-radius: $radius-md;
background-color: var(--primary-color);
color: white;
flex-shrink: 0;
&.github {
background-color: #24292f;
}
&.docs {
background-color: #10b981;
}
}
.linkContent {
flex: 1;
min-width: 0;
}
.linkTitle {
display: flex;
align-items: center;
gap: $spacing-xs;
font-size: 15px;
font-weight: 600;
color: var(--text-primary);
margin-bottom: 2px;
svg {
opacity: 0.5;
flex-shrink: 0;
}
}
.linkDesc {
font-size: 13px;
color: var(--text-secondary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}

View File

@@ -2,9 +2,11 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Card } from '@/components/ui/Card';
import { Button } from '@/components/ui/Button';
import { IconGithub, IconBookOpen, IconExternalLink, IconCode } from '@/components/ui/icons';
import { useAuthStore, useConfigStore, useNotificationStore, useModelsStore } from '@/stores';
import { apiKeysApi } from '@/services/api/apiKeys';
import { classifyModels } from '@/utils/models';
import { STORAGE_KEY_AUTH } from '@/utils/constants';
import styles from './SystemPage.module.scss';
export function SystemPage() {
@@ -103,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(() => {
fetchConfig().catch(() => {
// ignore
@@ -148,6 +159,65 @@ export function SystemPage() {
</div>
</Card>
<Card title={t('system_info.quick_links_title')}>
<p className={styles.sectionDescription}>{t('system_info.quick_links_desc')}</p>
<div className={styles.quickLinks}>
<a
href="https://github.com/router-for-me/CLIProxyAPI"
target="_blank"
rel="noopener noreferrer"
className={styles.linkCard}
>
<div className={`${styles.linkIcon} ${styles.github}`}>
<IconGithub size={22} />
</div>
<div className={styles.linkContent}>
<div className={styles.linkTitle}>
{t('system_info.link_main_repo')}
<IconExternalLink size={14} />
</div>
<div className={styles.linkDesc}>{t('system_info.link_main_repo_desc')}</div>
</div>
</a>
<a
href="https://github.com/router-for-me/Cli-Proxy-API-Management-Center"
target="_blank"
rel="noopener noreferrer"
className={styles.linkCard}
>
<div className={`${styles.linkIcon} ${styles.github}`}>
<IconCode size={22} />
</div>
<div className={styles.linkContent}>
<div className={styles.linkTitle}>
{t('system_info.link_webui_repo')}
<IconExternalLink size={14} />
</div>
<div className={styles.linkDesc}>{t('system_info.link_webui_repo_desc')}</div>
</div>
</a>
<a
href="https://help.router-for.me/"
target="_blank"
rel="noopener noreferrer"
className={styles.linkCard}
>
<div className={`${styles.linkIcon} ${styles.docs}`}>
<IconBookOpen size={22} />
</div>
<div className={styles.linkContent}>
<div className={styles.linkTitle}>
{t('system_info.link_docs')}
<IconExternalLink size={14} />
</div>
<div className={styles.linkDesc}>{t('system_info.link_docs_desc')}</div>
</div>
</a>
</div>
</Card>
<Card
title={t('system_info.models_title')}
extra={
@@ -188,6 +258,15 @@ export function SystemPage() {
</div>
)}
</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>
);

View File

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

File diff suppressed because it is too large Load Diff

32
src/router/MainRoutes.tsx Normal file
View File

@@ -0,0 +1,32 @@
import { Navigate, useRoutes, type Location } from 'react-router-dom';
import { DashboardPage } from '@/pages/DashboardPage';
import { SettingsPage } from '@/pages/SettingsPage';
import { ApiKeysPage } from '@/pages/ApiKeysPage';
import { AiProvidersPage } from '@/pages/AiProvidersPage';
import { AuthFilesPage } from '@/pages/AuthFilesPage';
import { OAuthPage } from '@/pages/OAuthPage';
import { QuotaPage } from '@/pages/QuotaPage';
import { UsagePage } from '@/pages/UsagePage';
import { ConfigPage } from '@/pages/ConfigPage';
import { LogsPage } from '@/pages/LogsPage';
import { SystemPage } from '@/pages/SystemPage';
const mainRoutes = [
{ path: '/', element: <DashboardPage /> },
{ path: '/dashboard', element: <DashboardPage /> },
{ path: '/settings', element: <SettingsPage /> },
{ path: '/api-keys', element: <ApiKeysPage /> },
{ path: '/ai-providers', element: <AiProvidersPage /> },
{ path: '/auth-files', element: <AuthFilesPage /> },
{ path: '/oauth', element: <OAuthPage /> },
{ path: '/quota', element: <QuotaPage /> },
{ path: '/usage', element: <UsagePage /> },
{ path: '/config', element: <ConfigPage /> },
{ path: '/logs', element: <LogsPage /> },
{ path: '/system', element: <SystemPage /> },
{ path: '*', element: <Navigate to="/" replace /> },
];
export function MainRoutes({ location }: { location?: Location }) {
return useRoutes(mainRoutes, location);
}

View File

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

View File

@@ -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}`;
}
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(
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) {
const match = normalized[key.toLowerCase()];
const match = normalizeValue(normalized[key.toLowerCase()]);
if (match) return match;
}
return null;
@@ -82,6 +107,10 @@ class ApiClient {
(config) => {
// 设置 baseURL
config.baseURL = this.apiBase;
if (config.url) {
// Normalize deprecated Gemini endpoint to the current path.
config.url = config.url.replace(/\/generative-language-api-key\b/g, '/gemini-api-key');
}
// 添加认证头
if (this.managementKey) {

View File

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

View File

@@ -3,6 +3,7 @@
*/
import { apiClient } from './client';
import { LOGS_TIMEOUT_MS } from '@/utils/constants';
export interface LogsQuery {
after?: number;
@@ -14,16 +15,34 @@ export interface LogsResponse {
'latest-timestamp': number;
}
export interface ErrorLogFile {
name: string;
size?: number;
modified?: number;
}
export interface ErrorLogsResponse {
files?: ErrorLogFile[];
}
export const logsApi = {
fetchLogs: (params: LogsQuery = {}): Promise<LogsResponse> =>
apiClient.get('/logs', { params }),
apiClient.get('/logs', { params, timeout: LOGS_TIMEOUT_MS }),
clearLogs: () => apiClient.delete('/logs'),
fetchErrorLogs: () => apiClient.get('/request-error-logs'),
fetchErrorLogs: (): Promise<ErrorLogsResponse> =>
apiClient.get('/request-error-logs', { timeout: LOGS_TIMEOUT_MS }),
downloadErrorLog: (filename: string) =>
apiClient.getRaw(`/request-error-logs/${encodeURIComponent(filename)}`, {
responseType: 'blob'
})
responseType: 'blob',
timeout: LOGS_TIMEOUT_MS
}),
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 { normalizeModelList } from '@/utils/models';
import { apiCallApi, getApiCallErrorMessage } from './apiCall';
const normalizeBaseUrl = (baseUrl: string): string => {
let normalized = String(baseUrl || '').trim();
@@ -39,5 +40,35 @@ export const modelsApi = {
});
const payload = response.data?.data ?? response.data?.models ?? response.data;
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

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

View File

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

View File

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

View File

@@ -5,11 +5,39 @@
import { apiClient } from './client';
import { computeKeyStats, KeyStats } from '@/utils/usage';
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 = {
/**
* 获取使用统计原始数据
*/
getUsage: () => apiClient.get('/usage'),
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 数据
@@ -17,7 +45,7 @@ export const usageApi = {
async getKeyStats(usageData?: any): Promise<KeyStats> {
let payload = usageData;
if (!payload) {
const response = await apiClient.get('/usage');
const response = await apiClient.get('/usage', { timeout: USAGE_TIMEOUT_MS });
payload = response?.usage ?? response;
}
return computeKeyStats(payload);

View File

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

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