Compare commits
98 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
db376c7504 | ||
|
|
8232812ac2 | ||
|
|
2ae06a8860 | ||
|
|
dc58a0701f | ||
|
|
3446280987 | ||
|
|
82bf1806ed | ||
|
|
47f0042bf0 | ||
|
|
58154063ed | ||
|
|
cc467889d0 | ||
|
|
469e5d2ed4 | ||
|
|
6ce301d7e0 | ||
|
|
8461de124f | ||
|
|
276f416ec9 | ||
|
|
583a844771 | ||
|
|
62fa437285 | ||
|
|
daab589c49 | ||
|
|
e18e9b25ce | ||
|
|
4cfb77dd44 | ||
|
|
7cab1e8782 | ||
|
|
079f37ec93 | ||
|
|
7ce97a616f | ||
|
|
946ed36af0 | ||
|
|
f139598526 | ||
|
|
40ddd3c066 | ||
|
|
3a66dc225d | ||
|
|
eadfd7a957 | ||
|
|
f739e0b372 | ||
|
|
23fb88e5fd | ||
|
|
49b9259452 | ||
|
|
4e26b6c92d | ||
|
|
215ce61b48 | ||
|
|
a48e06a28c | ||
|
|
8a59ab73a1 | ||
|
|
66d58288b4 | ||
|
|
be3f58f0a8 | ||
|
|
c299e403cc | ||
|
|
769c05e459 | ||
|
|
5ef3406068 | ||
|
|
95cbfb8c59 | ||
|
|
c17217875c | ||
|
|
981f7ac9b2 | ||
|
|
762db81252 | ||
|
|
79f6d87d7b | ||
|
|
c5d4356d6c | ||
|
|
c989dbf1b6 | ||
|
|
3cffa19319 | ||
|
|
2367f122a8 | ||
|
|
69a8e1657e | ||
|
|
987ce0ec4b | ||
|
|
03bf58671e | ||
|
|
cb6b810d6d | ||
|
|
408e6e5872 | ||
|
|
b3808add0f | ||
|
|
0b2e6efe28 | ||
|
|
8ca6d31a26 | ||
|
|
66c6073bbc | ||
|
|
2dd3f233d3 | ||
|
|
7a65e03ad3 | ||
|
|
589a5bad4c | ||
|
|
bcaa0c8545 | ||
|
|
312a06a8b8 | ||
|
|
24861dabd2 | ||
|
|
ea1bdc3ac1 | ||
|
|
46701b40ad | ||
|
|
c9fc22bae5 | ||
|
|
ff9bd8a33b | ||
|
|
d0c376fc31 | ||
|
|
d09db34c34 | ||
|
|
9dd37245bd | ||
|
|
834ba43231 | ||
|
|
684502c8b6 | ||
|
|
0aee78c072 | ||
|
|
8780ea7ec5 | ||
|
|
40fe33aeae | ||
|
|
2a94be08fa | ||
|
|
0758cfe08a | ||
|
|
02a01e5afc | ||
|
|
961cc802b2 | ||
|
|
5f7df33469 | ||
|
|
39847fa56d | ||
|
|
561e06503c | ||
|
|
94962158ef | ||
|
|
68974ffc68 | ||
|
|
f8ed787f92 | ||
|
|
dea106cf47 | ||
|
|
76ef1b68af | ||
|
|
39a003bdd4 | ||
|
|
b1426ccefc | ||
|
|
a9df58cba7 | ||
|
|
f6563490a6 | ||
|
|
18c1ba6c3c | ||
|
|
c2627cac3e | ||
|
|
df472119e7 | ||
|
|
10f2262753 | ||
|
|
39d86d133a | ||
|
|
ddbd7d00bd | ||
|
|
e44beb541f | ||
|
|
aecd5875d6 |
35
.github/workflows/release.yml
vendored
@@ -15,6 +15,9 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
fetch-tags: true
|
||||||
|
|
||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
@@ -36,27 +39,25 @@ jobs:
|
|||||||
mv index.html management.html
|
mv index.html management.html
|
||||||
ls -lh management.html
|
ls -lh management.html
|
||||||
|
|
||||||
|
- name: Generate release notes
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
current_tag="${GITHUB_REF_NAME}"
|
||||||
|
previous_tag="$(git tag --list 'v*' --sort=-v:refname | grep -v "^${current_tag}$" | head -n 1 || true)"
|
||||||
|
if [ -n "${previous_tag}" ]; then
|
||||||
|
range="${previous_tag}..${current_tag}"
|
||||||
|
else
|
||||||
|
range="${current_tag}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
: > release-notes.md
|
||||||
|
git log --pretty=format:"- %h %s" "${range}" >> release-notes.md
|
||||||
|
|
||||||
- name: Create Release
|
- name: Create Release
|
||||||
uses: softprops/action-gh-release@v1
|
uses: softprops/action-gh-release@v1
|
||||||
with:
|
with:
|
||||||
files: dist/management.html
|
files: dist/management.html
|
||||||
body: |
|
body_path: release-notes.md
|
||||||
## CLI Proxy API Management Center - ${{ github.ref_name }}
|
|
||||||
|
|
||||||
### Download and Usage
|
|
||||||
1. Download the `management.html` file
|
|
||||||
2. Open it directly in your browser
|
|
||||||
3. All assets (CSS, JavaScript, images) are bundled into this single file
|
|
||||||
|
|
||||||
### Features
|
|
||||||
- Single file, no external dependencies required
|
|
||||||
- Complete management interface for CLI Proxy API
|
|
||||||
- Support for local and remote connections
|
|
||||||
- Multi-language support (Chinese/English)
|
|
||||||
- Dark/Light theme support
|
|
||||||
|
|
||||||
---
|
|
||||||
🤖 Generated with GitHub Actions
|
|
||||||
draft: false
|
draft: false
|
||||||
prerelease: false
|
prerelease: false
|
||||||
env:
|
env:
|
||||||
|
|||||||
6
.gitignore
vendored
@@ -8,6 +8,11 @@ pnpm-debug.log*
|
|||||||
lerna-debug.log*
|
lerna-debug.log*
|
||||||
api.md
|
api.md
|
||||||
usage.json
|
usage.json
|
||||||
|
CLAUDE.md
|
||||||
|
AGENTS.md
|
||||||
|
antigravity_usage.json
|
||||||
|
codex_usage.json
|
||||||
|
style.md
|
||||||
|
|
||||||
node_modules
|
node_modules
|
||||||
dist
|
dist
|
||||||
@@ -15,6 +20,7 @@ dist-ssr
|
|||||||
*.local
|
*.local
|
||||||
|
|
||||||
# Editor directories and files
|
# Editor directories and files
|
||||||
|
settings.local.json
|
||||||
.vscode/*
|
.vscode/*
|
||||||
!.vscode/extensions.json
|
!.vscode/extensions.json
|
||||||
.idea
|
.idea
|
||||||
|
|||||||
212
README.md
@@ -1,6 +1,6 @@
|
|||||||
# CLI Proxy API Management Center
|
# CLI Proxy API Management Center
|
||||||
|
|
||||||
A modern React-based WebUI for managing the CLI Proxy API, completely refactored with a modern tech stack for enhanced maintainability, type safety, and user experience.
|
A single-file WebUI (React + TypeScript) for operating and troubleshooting the **CLI Proxy API** via its **Management API** (config, credentials, logs, and usage).
|
||||||
|
|
||||||
[中文文档](README_CN.md)
|
[中文文档](README_CN.md)
|
||||||
|
|
||||||
@@ -10,181 +10,121 @@ A modern React-based WebUI for managing the CLI Proxy API, completely refactored
|
|||||||
|
|
||||||
Since version 6.0.19, the WebUI ships with the main program; access it via `/management.html` on the API port once the service is running.
|
Since version 6.0.19, the WebUI ships with the main program; access it via `/management.html` on the API port once the service is running.
|
||||||
|
|
||||||
## Features
|
## What this is (and isn’t)
|
||||||
|
|
||||||
### Core Capabilities
|
- This repository is the WebUI only. It talks to the CLI Proxy API **Management API** (`/v0/management`) to read/update config, upload credentials, view logs, and inspect usage.
|
||||||
|
- It is **not** a proxy and does not forward traffic.
|
||||||
|
|
||||||
- **Login & Authentication**: Auto-detects current address (manual override supported), encrypted auto-login with secure localStorage, session persistence
|
## Quick start
|
||||||
- **Basic Settings**: Debug mode, proxy URL, request retries with custom config, quota fallback (auto-switch project/preview models), usage statistics toggle, request logging & file logging, WebSocket `/ws/*` authentication
|
|
||||||
- **API Keys Management**: Manage proxy auth keys with add/edit/delete operations
|
|
||||||
- **AI Providers**: Configure Gemini/Codex/Claude settings, OpenAI-compatible providers with custom base URLs/headers/proxy/model aliases, Vertex AI credential import from service-account JSON
|
|
||||||
- **Auth Files & OAuth**: Upload/download/search/paginate JSON credentials; type filters (Qwen/Gemini/GeminiCLI/AIStudio/Claude/Codex/Antigravity/iFlow/Vertex/Empty); bulk delete; OAuth/Device flows for multiple providers
|
|
||||||
- **Logs Viewer**: Real-time log viewer with auto-refresh, download and clear capabilities (appears when logging-to-file is enabled)
|
|
||||||
- **Usage Analytics**: Overview cards, hourly/daily toggles, interactive charts with multiple model lines, per-API statistics table
|
|
||||||
- **Config Management**: In-browser YAML editor for `/config.yaml` with syntax highlighting, reload/save functionality
|
|
||||||
- **System Information**: Connection status, config cache, server version/build date, UI version in footer
|
|
||||||
|
|
||||||
### User Experience
|
### Option A: Use the WebUI bundled in CLIProxyAPI (recommended)
|
||||||
|
|
||||||
- **Responsive Design**: Full mobile support with collapsible sidebar
|
1. Start your CLI Proxy API service.
|
||||||
- **Theme System**: Light/dark mode with persistent preference
|
2. Open: `http://<host>:<api_port>/management.html`
|
||||||
- **Internationalization**: English and Simplified Chinese (zh-CN) with seamless switching
|
3. Enter your **management key** and connect.
|
||||||
- **Real-time Feedback**: Toast notifications for all operations
|
|
||||||
- **Security**: Masked secrets, encrypted local storage
|
|
||||||
|
|
||||||
## Tech Stack
|
The address is auto-detected from the current page URL; manual override is supported.
|
||||||
|
|
||||||
- **Frontend Framework**: React 19 with TypeScript
|
### Option B: Run the dev server
|
||||||
- **Build Tool**: Vite 7 with single-file output ([vite-plugin-singlefile](https://github.com/nicknisi/vite-plugin-singlefile))
|
|
||||||
- **State Management**: [Zustand](https://github.com/pmndrs/zustand) for global stores
|
|
||||||
- **Routing**: React Router 7 with HashRouter
|
|
||||||
- **HTTP Client**: Axios with interceptors for auth & error handling
|
|
||||||
- **Internationalization**: i18next + react-i18next
|
|
||||||
- **Styling**: SCSS with CSS Modules, CSS Variables for theming
|
|
||||||
- **Charts**: Chart.js + react-chartjs-2
|
|
||||||
- **Code Editor**: @uiw/react-codemirror with YAML support
|
|
||||||
|
|
||||||
## Getting Started
|
|
||||||
|
|
||||||
### Prerequisites
|
|
||||||
|
|
||||||
- Node.js 18+ (LTS recommended)
|
|
||||||
- npm 9+
|
|
||||||
|
|
||||||
### Installation
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Clone the repository
|
|
||||||
git clone https://github.com/router-for-me/Cli-Proxy-API-Management-Center.git
|
|
||||||
cd Cli-Proxy-API-Management-Center
|
|
||||||
|
|
||||||
# Install dependencies
|
|
||||||
npm install
|
npm install
|
||||||
|
npm run dev
|
||||||
```
|
```
|
||||||
|
|
||||||
### Development
|
Open `http://localhost:5173`, then connect to your CLI Proxy API instance.
|
||||||
|
|
||||||
|
### Option C: Build a single HTML file
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm run dev # Start Vite dev server (default: http://localhost:5173)
|
npm install
|
||||||
|
npm run build
|
||||||
```
|
```
|
||||||
|
|
||||||
### Build
|
- Output: `dist/index.html` (all assets are inlined).
|
||||||
|
- For CLIProxyAPI bundling, the release workflow renames it to `management.html`.
|
||||||
|
- To preview locally: `npm run preview`
|
||||||
|
|
||||||
```bash
|
Tip: opening `dist/index.html` via `file://` may be blocked by browser CORS; serving it (preview/static server) is more reliable.
|
||||||
npm run build # TypeScript check + Vite production build
|
|
||||||
```
|
|
||||||
|
|
||||||
The build outputs a single `dist/index.html` file with all assets inlined.
|
## Connecting to the server
|
||||||
|
|
||||||
### Other Commands
|
### API address
|
||||||
|
|
||||||
```bash
|
You can enter any of the following; the UI will normalize it:
|
||||||
npm run preview # Preview production build locally
|
|
||||||
npm run lint # ESLint with strict mode (--max-warnings 0)
|
|
||||||
npm run format # Prettier formatting for src/**/*.{ts,tsx,css,scss}
|
|
||||||
npm run type-check # TypeScript type checking only (tsc --noEmit)
|
|
||||||
```
|
|
||||||
|
|
||||||
## Usage
|
- `localhost:8317`
|
||||||
|
- `http://192.168.1.10:8317`
|
||||||
|
- `https://example.com:8317`
|
||||||
|
- `http://example.com:8317/v0/management` (also accepted; the suffix is removed internally)
|
||||||
|
|
||||||
### Access Methods
|
### Management key (not the same as API keys)
|
||||||
|
|
||||||
1. **Integrated with CLI Proxy API (Recommended)**
|
The management key is sent with every request as:
|
||||||
After starting the CLI Proxy API service, visit `http://your-server:8317/management.html`
|
|
||||||
|
|
||||||
2. **Standalone (Built file)**
|
- `Authorization: Bearer <MANAGEMENT_KEY>` (default)
|
||||||
Open the built `dist/index.html` directly in a browser, or host it on any static file server
|
|
||||||
|
|
||||||
3. **Development Server**
|
This is different from the proxy `api-keys` you manage inside the UI (those are for client requests to the proxy endpoints).
|
||||||
Run `npm run dev` and open `http://localhost:5173`
|
|
||||||
|
|
||||||
### Initial Configuration
|
### Remote management
|
||||||
|
|
||||||
1. The login page auto-detects the current address; you can modify it if needed
|
If you connect from a non-localhost browser, the server must allow remote management (e.g. `allow-remote-management: true`).
|
||||||
2. Enter your management key
|
See `api.md` for the full authentication rules, server-side limits, and edge cases.
|
||||||
3. Click Connect to authenticate
|
|
||||||
4. Credentials are encrypted and saved locally for auto-login
|
|
||||||
|
|
||||||
> **Tip**: The Logs navigation item appears only after enabling "Logging to file" in Basic Settings.
|
## What you can manage (mapped to the UI pages)
|
||||||
|
|
||||||
## Project Structure
|
- **Dashboard**: connection status, server version/build date, quick counts, model availability snapshot.
|
||||||
|
- **Basic Settings**: debug, proxy URL, request retry, quota fallback (switch project/preview models), usage statistics, request logging, file logging, WebSocket auth.
|
||||||
|
- **API Keys**: manage proxy `api-keys` (add/edit/delete).
|
||||||
|
- **AI Providers**:
|
||||||
|
- Gemini/Codex/Claude key entries (base URL, headers, proxy, model aliases, excluded models, prefix).
|
||||||
|
- OpenAI-compatible providers (multiple API keys, custom headers, model alias import via `/v1/models`, optional browser-side “chat/completions” test).
|
||||||
|
- Ampcode integration (upstream URL/key, force mappings, model mapping table).
|
||||||
|
- **Auth Files**: upload/download/delete JSON credentials, filter/search/pagination, runtime-only indicators, view supported models per credential (when the server supports it), manage OAuth excluded models (supports `*` wildcards).
|
||||||
|
- **OAuth**: start OAuth/device flows for supported providers, poll status, optionally submit callback `redirect_url`; includes iFlow cookie import.
|
||||||
|
- **Usage**: requests/tokens charts (hour/day), per-API & per-model breakdown, cached/reasoning token breakdown, RPM/TPM window, optional cost estimation with locally-saved model pricing.
|
||||||
|
- **Config**: edit `/config.yaml` in-browser with YAML highlighting + search, then save/reload.
|
||||||
|
- **Logs**: tail logs with incremental polling, auto-refresh, search, hide management traffic, clear logs; download request error log files.
|
||||||
|
- **System**: quick links + fetch `/v1/models` (grouped view). Requires at least one proxy API key to query models.
|
||||||
|
|
||||||
```
|
## Build & release notes
|
||||||
├── src/
|
|
||||||
│ ├── components/
|
|
||||||
│ │ ├── common/ # Shared components (NotificationContainer)
|
|
||||||
│ │ ├── layout/ # App shell (MainLayout with sidebar)
|
|
||||||
│ │ └── ui/ # Reusable UI primitives (Button, Input, Modal, etc.)
|
|
||||||
│ ├── hooks/ # Custom hooks (useApi, useDebounce, usePagination, etc.)
|
|
||||||
│ ├── i18n/
|
|
||||||
│ │ ├── locales/ # Translation files (zh-CN.json, en.json)
|
|
||||||
│ │ └── index.ts # i18next configuration
|
|
||||||
│ ├── pages/ # Route page components with co-located .module.scss
|
|
||||||
│ ├── router/ # ProtectedRoute wrapper
|
|
||||||
│ ├── services/
|
|
||||||
│ │ ├── api/ # API layer (client.ts singleton, feature modules)
|
|
||||||
│ │ └── storage/ # Secure storage utilities
|
|
||||||
│ ├── stores/ # Zustand stores (auth, config, theme, language, notification)
|
|
||||||
│ ├── styles/ # Global SCSS (variables, mixins, themes, components)
|
|
||||||
│ ├── types/ # TypeScript type definitions
|
|
||||||
│ ├── utils/ # Utility functions (constants, format, validation, etc.)
|
|
||||||
│ ├── App.tsx # Root component with routing
|
|
||||||
│ └── main.tsx # Entry point
|
|
||||||
├── dist/ # Build output (single-file bundle)
|
|
||||||
├── vite.config.ts # Vite configuration
|
|
||||||
├── tsconfig.json # TypeScript configuration
|
|
||||||
└── package.json
|
|
||||||
```
|
|
||||||
|
|
||||||
### Key Architecture Patterns
|
- Vite produces a **single HTML** output (`dist/index.html`) with all assets inlined (via `vite-plugin-singlefile`).
|
||||||
|
- Tagging `vX.Y.Z` triggers `.github/workflows/release.yml` to publish `dist/management.html`.
|
||||||
|
- The UI version shown in the footer is injected at build time (env `VERSION`, git tag, or `package.json` fallback).
|
||||||
|
|
||||||
- **Path Alias**: Use `@/` to import from `src/` (configured in vite.config.ts and tsconfig.json)
|
## Security notes
|
||||||
- **API Client**: Singleton `apiClient` in `src/services/api/client.ts` with auth interceptors
|
|
||||||
- **State Management**: Zustand stores with localStorage persistence for auth/theme/language
|
- The management key is stored in browser `localStorage` using a lightweight obfuscation format (`enc::v1::...`) to avoid plaintext storage; treat it as sensitive.
|
||||||
- **Styling**: SCSS variables auto-injected; CSS Modules for component-scoped styles
|
- Use a dedicated browser profile/device for management. Be cautious when enabling remote management and evaluate its exposure surface.
|
||||||
- **Build Output**: Single-file bundle for easy distribution (all assets inlined)
|
|
||||||
|
|
||||||
## Troubleshooting
|
## Troubleshooting
|
||||||
|
|
||||||
### Connection Issues
|
- **Can’t connect / 401**: confirm the API address and management key; remote access may require enabling remote management in the server config.
|
||||||
|
- **Repeated auth failures**: the server may temporarily block remote IPs.
|
||||||
|
- **Logs page missing**: enable “Logging to file” in Basic Settings; the navigation item is shown only when file logging is enabled.
|
||||||
|
- **Some features show “unsupported”**: the backend may be too old or the endpoint is disabled/absent (common for model lists per auth file, excluded models, logs).
|
||||||
|
- **OpenAI provider test fails**: the test runs in the browser and depends on network/CORS of the provider endpoint; a failure here does not always mean the server cannot reach it.
|
||||||
|
|
||||||
1. Confirm the CLI Proxy API service is running
|
## Development
|
||||||
2. Check if the API address is correct
|
|
||||||
3. Verify that the management key is valid
|
|
||||||
4. Ensure your firewall allows the connection
|
|
||||||
|
|
||||||
### Data Not Updating
|
```bash
|
||||||
|
npm run dev # Vite dev server
|
||||||
1. Click the "Refresh All" button in the header
|
npm run build # tsc + Vite build
|
||||||
2. Check your network connection
|
npm run preview # serve dist locally
|
||||||
3. Open browser DevTools console for error details
|
npm run lint # ESLint (fails on warnings)
|
||||||
|
npm run format # Prettier
|
||||||
### Logs & Config Editor
|
npm run type-check # tsc --noEmit
|
||||||
|
```
|
||||||
- **Logs**: Requires server-side logging-to-file enabled; 404 indicates old server version or logging disabled
|
|
||||||
- **Config Editor**: Requires `/config.yaml` endpoint; ensure valid YAML syntax before saving
|
|
||||||
|
|
||||||
### Usage Statistics
|
|
||||||
|
|
||||||
- If charts are empty, enable "Usage statistics" in settings; data resets on server restart
|
|
||||||
|
|
||||||
## Contributing
|
## Contributing
|
||||||
|
|
||||||
We welcome Issues and Pull Requests! Please follow these guidelines:
|
Issues and PRs are welcome. Please include:
|
||||||
|
|
||||||
1. Fork the repository
|
- Reproduction steps (server version + UI version)
|
||||||
2. Create a feature branch (`git checkout -b feature/amazing-feature`)
|
- Screenshots for UI changes
|
||||||
3. Commit your changes with clear messages
|
- Verification notes (`npm run lint`, `npm run type-check`)
|
||||||
4. Push to your branch
|
|
||||||
5. Open a Pull Request
|
|
||||||
|
|
||||||
### Development Guidelines
|
|
||||||
|
|
||||||
- Run `npm run lint` and `npm run type-check` before committing
|
|
||||||
- Follow existing code patterns and naming conventions
|
|
||||||
- Use TypeScript strict mode
|
|
||||||
- Write meaningful commit messages
|
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
This project is licensed under the MIT License.
|
MIT
|
||||||
|
|||||||
250
README_CN.md
@@ -1,6 +1,6 @@
|
|||||||
# CLI Proxy API 管理中心
|
# CLI Proxy API 管理中心
|
||||||
|
|
||||||
用于管理 CLI Proxy API 的现代化 React Web 界面,采用全新技术栈重构,提供更好的可维护性、类型安全性和用户体验。
|
用于管理与排障 **CLI Proxy API** 的单文件 WebUI(React + TypeScript),通过 **Management API** 完成配置、凭据、日志与统计等运维工作。
|
||||||
|
|
||||||
[English](README.md)
|
[English](README.md)
|
||||||
|
|
||||||
@@ -8,183 +8,123 @@
|
|||||||
**示例地址**: https://remote.router-for.me/
|
**示例地址**: https://remote.router-for.me/
|
||||||
**最低版本要求**: ≥ 6.3.0(推荐 ≥ 6.5.0)
|
**最低版本要求**: ≥ 6.3.0(推荐 ≥ 6.5.0)
|
||||||
|
|
||||||
自 6.0.19 版本起,WebUI 已集成到主程序中,启动服务后可通过 `/management.html` 访问。
|
Since version 6.0.19, the WebUI ships with the main program; access it via `/management.html` on the API port once the service is running.
|
||||||
|
|
||||||
## 功能特点
|
## 这是什么(以及不是什么)
|
||||||
|
|
||||||
### 核心功能
|
- 本仓库只包含 Web 管理界面本身,通过 CLI Proxy API 的 **Management API**(`/v0/management`)读取/修改配置、上传凭据、查看日志与使用统计。
|
||||||
|
- 它 **不是** 代理本体,不参与流量转发。
|
||||||
- **登录与认证**: 自动检测当前地址(支持手动修改),加密自动登录,会话持久化
|
|
||||||
- **基础设置**: 调试模式、代理 URL、请求重试配置、配额溢出自动切换项目/预览模型、使用统计开关、请求日志与文件日志、WebSocket `/ws/*` 鉴权
|
|
||||||
- **API 密钥管理**: 管理代理认证密钥,支持添加/编辑/删除操作
|
|
||||||
- **AI 提供商**: 配置 Gemini/Codex/Claude,OpenAI 兼容提供商(自定义 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 Modules,CSS 变量主题
|
|
||||||
- **图表**: Chart.js + react-chartjs-2
|
|
||||||
- **代码编辑器**: @uiw/react-codemirror(YAML 支持)
|
|
||||||
|
|
||||||
## 快速开始
|
## 快速开始
|
||||||
|
|
||||||
### 环境要求
|
### 方式 A:使用 CLIProxyAPI 自带的 WebUI(推荐)
|
||||||
|
|
||||||
- Node.js 18+(推荐 LTS 版本)
|
1. 启动 CLI Proxy API 服务。
|
||||||
- npm 9+
|
2. 打开:`http://<host>:<api_port>/management.html`
|
||||||
|
3. 输入 **管理密钥** 并连接。
|
||||||
|
|
||||||
### 安装
|
页面会根据当前地址自动推断 API 地址,也支持手动修改。
|
||||||
|
|
||||||
|
### 方式 B:开发调试
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 克隆仓库
|
|
||||||
git clone https://github.com/router-for-me/Cli-Proxy-API-Management-Center.git
|
|
||||||
cd Cli-Proxy-API-Management-Center
|
|
||||||
|
|
||||||
# 安装依赖
|
|
||||||
npm install
|
npm install
|
||||||
|
npm run dev
|
||||||
```
|
```
|
||||||
|
|
||||||
### 开发
|
打开 `http://localhost:5173`,然后连接到你的 CLI Proxy API 实例。
|
||||||
|
|
||||||
|
### 方式 C:构建单文件 HTML
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm run dev # 启动 Vite 开发服务器(默认: http://localhost:5173)
|
npm install
|
||||||
|
npm run build
|
||||||
```
|
```
|
||||||
|
|
||||||
### 构建
|
- 构建产物:`dist/index.html`(资源已全部内联)。
|
||||||
|
- 在 CLIProxyAPI 的发布流程里会重命名为 `management.html`。
|
||||||
|
- 本地预览:`npm run preview`
|
||||||
|
|
||||||
|
提示:直接用 `file://` 打开 `dist/index.html` 可能遇到浏览器 CORS 限制;更稳妥的方式是用预览/静态服务器打开。
|
||||||
|
|
||||||
|
## 连接说明
|
||||||
|
|
||||||
|
### API 地址怎么填
|
||||||
|
|
||||||
|
以下格式均可,WebUI 会自动归一化:
|
||||||
|
|
||||||
|
- `localhost:8317`
|
||||||
|
- `http://192.168.1.10:8317`
|
||||||
|
- `https://example.com:8317`
|
||||||
|
- `http://example.com:8317/v0/management`(也可填写,后缀会被自动去除)
|
||||||
|
|
||||||
|
### 管理密钥(注意:不是 API Keys)
|
||||||
|
|
||||||
|
管理密钥会以如下方式随请求发送:
|
||||||
|
|
||||||
|
- `Authorization: Bearer <MANAGEMENT_KEY>`(默认)
|
||||||
|
|
||||||
|
这与 WebUI 中“API Keys”页面管理的 `api-keys` 不同:后者是代理对外接口(如 OpenAI 兼容接口)给客户端使用的鉴权 key。
|
||||||
|
|
||||||
|
### 远程管理
|
||||||
|
|
||||||
|
当你从非 localhost 的浏览器访问时,服务端通常需要开启远程管理(例如 `allow-remote-management: true`)。
|
||||||
|
完整鉴权规则、限制与边界情况请查看 `api.md`。
|
||||||
|
|
||||||
|
## 功能一览(按页面对应)
|
||||||
|
|
||||||
|
- **仪表盘**:连接状态、服务版本/构建时间、关键数量概览、可用模型概览。
|
||||||
|
- **基础设置**:调试开关、代理 URL、请求重试、配额回退(切项目/切预览模型)、使用统计、请求日志、文件日志、WebSocket 鉴权。
|
||||||
|
- **API Keys**:管理代理 `api-keys`(增/改/删)。
|
||||||
|
- **AI 提供商**:
|
||||||
|
- Gemini/Codex/Claude 配置(Base URL、Headers、代理、模型别名、排除模型、Prefix)。
|
||||||
|
- OpenAI 兼容提供商(多 Key、Header、自助从 `/v1/models` 拉取并导入模型别名、可选浏览器侧 `chat/completions` 测试)。
|
||||||
|
- Ampcode 集成(上游地址/密钥、强制映射、模型映射表)。
|
||||||
|
- **认证文件**:上传/下载/删除 JSON 凭据,筛选/搜索/分页,标记 runtime-only;查看单个凭据可用模型(依赖后端支持);管理 OAuth 排除模型(支持 `*` 通配符)。
|
||||||
|
- **OAuth**:对支持的提供商发起 OAuth/设备码流程,轮询状态;可选提交回调 `redirect_url`;包含 iFlow Cookie 导入。
|
||||||
|
- **使用统计**:按小时/天图表、按 API 与按模型统计、缓存/推理 Token 拆分、RPM/TPM 时间窗、可选本地保存的模型价格用于费用估算。
|
||||||
|
- **配置文件**:浏览器内编辑 `/config.yaml`(YAML 高亮 + 搜索),保存/重载。
|
||||||
|
- **日志**:增量拉取日志、自动刷新、搜索、隐藏管理端流量、清空日志;下载请求错误日志文件。
|
||||||
|
- **系统信息**:快捷链接 + 拉取 `/v1/models` 并分组展示(需要至少一个代理 API Key 才能查询模型)。
|
||||||
|
|
||||||
|
## 构建与发布说明
|
||||||
|
|
||||||
|
- 使用 Vite 输出 **单文件 HTML**(`dist/index.html`),资源全部内联(`vite-plugin-singlefile`)。
|
||||||
|
- 打 `vX.Y.Z` 标签会触发 `.github/workflows/release.yml`,发布 `dist/management.html`。
|
||||||
|
- 页脚显示的 UI 版本在构建期注入(优先使用环境变量 `VERSION`,否则使用 git tag / `package.json`)。
|
||||||
|
|
||||||
|
## 安全提示
|
||||||
|
|
||||||
|
- 管理密钥会存入浏览器 `localStorage`,并使用轻量混淆格式(`enc::v1::...`)避免明文;仍应视为敏感信息。
|
||||||
|
- 建议使用独立浏览器配置/设备进行管理;开启远程管理时请谨慎评估暴露面。
|
||||||
|
|
||||||
|
## 常见问题
|
||||||
|
|
||||||
|
- **无法连接 / 401**:确认 API 地址与管理密钥;远程访问可能需要服务端开启远程管理。
|
||||||
|
- **反复输错密钥**:服务端可能对远程 IP 进行临时封禁。
|
||||||
|
- **日志页面不显示**:需要在“基础设置”里开启“写入日志文件”,导航项才会出现。
|
||||||
|
- **功能提示不支持**:多为后端版本较旧或接口未启用/不存在(如:认证文件模型列表、排除模型、日志相关接口)。
|
||||||
|
- **OpenAI 提供商测试失败**:测试在浏览器侧执行,会受网络与 CORS 影响;这里失败不一定代表服务端不可用。
|
||||||
|
|
||||||
|
## 开发命令
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm run build # TypeScript 检查 + Vite 生产构建
|
npm run dev # 启动开发服务器
|
||||||
|
npm run build # tsc + Vite 构建
|
||||||
|
npm run preview # 本地预览 dist
|
||||||
|
npm run lint # ESLint(warnings 视为失败)
|
||||||
|
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/ # 自定义 Hooks(useApi、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/ # 全局 SCSS(variables、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 stores,auth/theme/language 持久化到 localStorage
|
|
||||||
- **样式**: SCSS 变量自动注入;CSS Modules 实现组件作用域样式
|
|
||||||
- **构建输出**: 单文件打包,便于分发(所有资源内联)
|
|
||||||
|
|
||||||
## 故障排除
|
|
||||||
|
|
||||||
### 连接问题
|
|
||||||
|
|
||||||
1. 确认 CLI Proxy API 服务正在运行
|
|
||||||
2. 检查 API 地址是否正确
|
|
||||||
3. 验证管理密钥是否有效
|
|
||||||
4. 确认防火墙设置允许连接
|
|
||||||
|
|
||||||
### 数据不更新
|
|
||||||
|
|
||||||
1. 点击顶栏的"刷新全部"按钮
|
|
||||||
2. 检查网络连接
|
|
||||||
3. 打开浏览器开发者工具控制台查看错误信息
|
|
||||||
|
|
||||||
### 日志与配置编辑
|
|
||||||
|
|
||||||
- **日志**: 需要服务端启用写文件日志;返回 404 说明服务器版本过旧或未启用日志
|
|
||||||
- **配置编辑**: 依赖 `/config.yaml` 接口;保存前请确保 YAML 语法正确
|
|
||||||
|
|
||||||
### 使用统计
|
|
||||||
|
|
||||||
- 若图表为空,请在设置中启用"使用统计";数据在服务重启后会清空
|
|
||||||
|
|
||||||
## 贡献
|
## 贡献
|
||||||
|
|
||||||
欢迎提交 Issue 和 Pull Request!请遵循以下指南:
|
欢迎提 Issue 与 PR。建议附上:
|
||||||
|
|
||||||
1. Fork 本仓库
|
- 复现步骤(服务端版本 + UI 版本)
|
||||||
2. 创建功能分支(`git checkout -b feature/amazing-feature`)
|
- UI 改动截图
|
||||||
3. 提交更改,使用清晰的提交信息
|
- 验证记录(`npm run lint`、`npm run type-check`)
|
||||||
4. 推送到分支
|
|
||||||
5. 开启 Pull Request
|
|
||||||
|
|
||||||
### 开发规范
|
|
||||||
|
|
||||||
- 提交前运行 `npm run lint` 和 `npm run type-check`
|
|
||||||
- 遵循现有代码模式和命名规范
|
|
||||||
- 使用 TypeScript 严格模式
|
|
||||||
- 编写有意义的提交信息
|
|
||||||
|
|
||||||
## 许可证
|
## 许可证
|
||||||
|
|
||||||
本项目采用 MIT 许可证。
|
MIT
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html lang="en">
|
<html lang="zh-CN">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20xmlns%3Axlink%3D%22http%3A%2F%2Fwww.w3.org%2F1999%2Fxlink%22%20aria-hidden%3D%22true%22%20role%3D%22img%22%20class%3D%22iconify%20iconify--logos%22%20width%3D%2231.88%22%20height%3D%2232%22%20preserveAspectRatio%3D%22xMidYMid%20meet%22%20viewBox%3D%220%200%20256%20257%22%3E%3Cdefs%3E%3ClinearGradient%20id%3D%22IconifyId1813088fe1fbc01fb466%22%20x1%3D%22-.828%25%22%20x2%3D%2257.636%25%22%20y1%3D%227.652%25%22%20y2%3D%2278.411%25%22%3E%3Cstop%20offset%3D%220%25%22%20stop-color%3D%22%2341D1FF%22%3E%3C%2Fstop%3E%3Cstop%20offset%3D%22100%25%22%20stop-color%3D%22%23BD34FE%22%3E%3C%2Fstop%3E%3C%2FlinearGradient%3E%3ClinearGradient%20id%3D%22IconifyId1813088fe1fbc01fb467%22%20x1%3D%2243.376%25%22%20x2%3D%2250.316%25%22%20y1%3D%222.242%25%22%20y2%3D%2289.03%25%22%3E%3Cstop%20offset%3D%220%25%22%20stop-color%3D%22%23FFEA83%22%3E%3C%2Fstop%3E%3Cstop%20offset%3D%228.333%25%22%20stop-color%3D%22%23FFDD35%22%3E%3C%2Fstop%3E%3Cstop%20offset%3D%22100%25%22%20stop-color%3D%22%23FFA800%22%3E%3C%2Fstop%3E%3C%2FlinearGradient%3E%3C%2Fdefs%3E%3Cpath%20fill%3D%22url(%23IconifyId1813088fe1fbc01fb466)%22%20d%3D%22M255.153%2037.938L134.897%20252.976c-2.483%204.44-8.862%204.466-11.382.048L.875%2037.958c-2.746-4.814%201.371-10.646%206.827-9.67l120.385%2021.517a6.537%206.537%200%200%200%202.322-.004l117.867-21.483c5.438-.991%209.574%204.796%206.877%209.62Z%22%3E%3C%2Fpath%3E%3Cpath%20fill%3D%22url(%23IconifyId1813088fe1fbc01fb467)%22%20d%3D%22M185.432.063L96.44%2017.501a3.268%203.268%200%200%200-2.634%203.014l-5.474%2092.456a3.268%203.268%200%200%200%203.997%203.378l24.777-5.718c2.318-.535%204.413%201.507%203.936%203.838l-7.361%2036.047c-.495%202.426%201.782%204.5%204.151%203.78l15.304-4.649c2.372-.72%204.652%201.36%204.15%203.788l-11.698%2056.621c-.732%203.542%203.979%205.473%205.943%202.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505%204.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z%22%3E%3C%2Fpath%3E%3C%2Fsvg%3E" />
|
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20xmlns%3Axlink%3D%22http%3A%2F%2Fwww.w3.org%2F1999%2Fxlink%22%20aria-hidden%3D%22true%22%20role%3D%22img%22%20class%3D%22iconify%20iconify--logos%22%20width%3D%2231.88%22%20height%3D%2232%22%20preserveAspectRatio%3D%22xMidYMid%20meet%22%20viewBox%3D%220%200%20256%20257%22%3E%3Cdefs%3E%3ClinearGradient%20id%3D%22IconifyId1813088fe1fbc01fb466%22%20x1%3D%22-.828%25%22%20x2%3D%2257.636%25%22%20y1%3D%227.652%25%22%20y2%3D%2278.411%25%22%3E%3Cstop%20offset%3D%220%25%22%20stop-color%3D%22%2341D1FF%22%3E%3C%2Fstop%3E%3Cstop%20offset%3D%22100%25%22%20stop-color%3D%22%23BD34FE%22%3E%3C%2Fstop%3E%3C%2FlinearGradient%3E%3ClinearGradient%20id%3D%22IconifyId1813088fe1fbc01fb467%22%20x1%3D%2243.376%25%22%20x2%3D%2250.316%25%22%20y1%3D%222.242%25%22%20y2%3D%2289.03%25%22%3E%3Cstop%20offset%3D%220%25%22%20stop-color%3D%22%23FFEA83%22%3E%3C%2Fstop%3E%3Cstop%20offset%3D%228.333%25%22%20stop-color%3D%22%23FFDD35%22%3E%3C%2Fstop%3E%3Cstop%20offset%3D%22100%25%22%20stop-color%3D%22%23FFA800%22%3E%3C%2Fstop%3E%3C%2FlinearGradient%3E%3C%2Fdefs%3E%3Cpath%20fill%3D%22url(%23IconifyId1813088fe1fbc01fb466)%22%20d%3D%22M255.153%2037.938L134.897%20252.976c-2.483%204.44-8.862%204.466-11.382.048L.875%2037.958c-2.746-4.814%201.371-10.646%206.827-9.67l120.385%2021.517a6.537%206.537%200%200%200%202.322-.004l117.867-21.483c5.438-.991%209.574%204.796%206.877%209.62Z%22%3E%3C%2Fpath%3E%3Cpath%20fill%3D%22url(%23IconifyId1813088fe1fbc01fb467)%22%20d%3D%22M185.432.063L96.44%2017.501a3.268%203.268%200%200%200-2.634%203.014l-5.474%2092.456a3.268%203.268%200%200%200%203.997%203.378l24.777-5.718c2.318-.535%204.413%201.507%203.936%203.838l-7.361%2036.047c-.495%202.426%201.782%204.5%204.151%203.78l15.304-4.649c2.372-.72%204.652%201.36%204.15%203.788l-11.698%2056.621c-.732%203.542%203.979%205.473%205.943%202.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505%204.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z%22%3E%3C%2Fpath%3E%3C%2Fsvg%3E" />
|
||||||
|
|||||||
7
package-lock.json
generated
@@ -12,6 +12,7 @@
|
|||||||
"@uiw/react-codemirror": "^4.25.3",
|
"@uiw/react-codemirror": "^4.25.3",
|
||||||
"axios": "^1.13.2",
|
"axios": "^1.13.2",
|
||||||
"chart.js": "^4.5.1",
|
"chart.js": "^4.5.1",
|
||||||
|
"gsap": "^3.14.2",
|
||||||
"i18next": "^25.7.1",
|
"i18next": "^25.7.1",
|
||||||
"react": "^19.2.1",
|
"react": "^19.2.1",
|
||||||
"react-chartjs-2": "^5.3.1",
|
"react-chartjs-2": "^5.3.1",
|
||||||
@@ -3194,6 +3195,12 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"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": {
|
"node_modules/has-flag": {
|
||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
|||||||
@@ -16,6 +16,7 @@
|
|||||||
"@uiw/react-codemirror": "^4.25.3",
|
"@uiw/react-codemirror": "^4.25.3",
|
||||||
"axios": "^1.13.2",
|
"axios": "^1.13.2",
|
||||||
"chart.js": "^4.5.1",
|
"chart.js": "^4.5.1",
|
||||||
|
"gsap": "^3.14.2",
|
||||||
"i18next": "^25.7.1",
|
"i18next": "^25.7.1",
|
||||||
"react": "^19.2.1",
|
"react": "^19.2.1",
|
||||||
"react-chartjs-2": "^5.3.1",
|
"react-chartjs-2": "^5.3.1",
|
||||||
|
|||||||
69
src/App.tsx
@@ -1,29 +1,31 @@
|
|||||||
import { useEffect } from 'react';
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
import { HashRouter, Navigate, Route, Routes } from 'react-router-dom';
|
import { HashRouter, Route, Routes } from 'react-router-dom';
|
||||||
import { LoginPage } from '@/pages/LoginPage';
|
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 { NotificationContainer } from '@/components/common/NotificationContainer';
|
||||||
|
import { SplashScreen } from '@/components/common/SplashScreen';
|
||||||
import { MainLayout } from '@/components/layout/MainLayout';
|
import { MainLayout } from '@/components/layout/MainLayout';
|
||||||
import { ProtectedRoute } from '@/router/ProtectedRoute';
|
import { ProtectedRoute } from '@/router/ProtectedRoute';
|
||||||
import { useAuthStore, useLanguageStore, useThemeStore } from '@/stores';
|
import { useAuthStore, useLanguageStore, useThemeStore } from '@/stores';
|
||||||
|
|
||||||
|
const SPLASH_DURATION = 1500;
|
||||||
|
const SPLASH_FADE_DURATION = 400;
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
const initializeTheme = useThemeStore((state) => state.initializeTheme);
|
const initializeTheme = useThemeStore((state) => state.initializeTheme);
|
||||||
const language = useLanguageStore((state) => state.language);
|
const language = useLanguageStore((state) => state.language);
|
||||||
const setLanguage = useLanguageStore((state) => state.setLanguage);
|
const setLanguage = useLanguageStore((state) => state.setLanguage);
|
||||||
const restoreSession = useAuthStore((state) => state.restoreSession);
|
const restoreSession = useAuthStore((state) => state.restoreSession);
|
||||||
|
|
||||||
|
const [splashReadyToFade, setSplashReadyToFade] = useState(false);
|
||||||
|
const [showSplash, setShowSplash] = useState(true);
|
||||||
|
const [authReady, setAuthReady] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
initializeTheme();
|
const cleanupTheme = initializeTheme();
|
||||||
restoreSession();
|
void restoreSession().finally(() => {
|
||||||
|
setAuthReady(true);
|
||||||
|
});
|
||||||
|
return cleanupTheme;
|
||||||
}, [initializeTheme, restoreSession]);
|
}, [initializeTheme, restoreSession]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -31,31 +33,44 @@ function App() {
|
|||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, []); // 仅用于首屏同步 i18n 语言
|
}, []); // 仅用于首屏同步 i18n 语言
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
document.documentElement.lang = language;
|
||||||
|
}, [language]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
setSplashReadyToFade(true);
|
||||||
|
}, SPLASH_DURATION - SPLASH_FADE_DURATION);
|
||||||
|
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleSplashFinish = useCallback(() => {
|
||||||
|
setShowSplash(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (showSplash) {
|
||||||
|
return (
|
||||||
|
<SplashScreen
|
||||||
|
fadeOut={splashReadyToFade && authReady}
|
||||||
|
onFinish={handleSplashFinish}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<HashRouter>
|
<HashRouter>
|
||||||
<NotificationContainer />
|
<NotificationContainer />
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/login" element={<LoginPage />} />
|
<Route path="/login" element={<LoginPage />} />
|
||||||
<Route
|
<Route
|
||||||
path="/"
|
path="/*"
|
||||||
element={
|
element={
|
||||||
<ProtectedRoute>
|
<ProtectedRoute>
|
||||||
<MainLayout />
|
<MainLayout />
|
||||||
</ProtectedRoute>
|
</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>
|
</Routes>
|
||||||
</HashRouter>
|
</HashRouter>
|
||||||
);
|
);
|
||||||
|
|||||||
6
src/assets/icons/amp.svg
Normal 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 |
28
src/assets/icons/antigravity.svg
Normal 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 |
1
src/assets/icons/claude.svg
Normal 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 |
1
src/assets/icons/gemini.svg
Normal 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 |
1
src/assets/icons/iflow.svg
Normal 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 |
1
src/assets/icons/openai-dark.svg
Normal 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 |
1
src/assets/icons/openai-light.svg
Normal 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 |
1
src/assets/icons/qwen.svg
Normal 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 |
1
src/assets/icons/vertex.svg
Normal 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 |
39
src/components/common/PageTransition.scss
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
171
src/components/common/PageTransition.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
106
src/components/common/SplashScreen.scss
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
36
src/components/common/SplashScreen.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,32 +1,55 @@
|
|||||||
import { ReactNode, SVGProps, useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react';
|
import {
|
||||||
import { NavLink, Outlet } from 'react-router-dom';
|
ReactNode,
|
||||||
|
SVGProps,
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useLayoutEffect,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
} from 'react';
|
||||||
|
import { NavLink, useLocation } from 'react-router-dom';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Button } from '@/components/ui/Button';
|
import { Button } from '@/components/ui/Button';
|
||||||
|
import { Modal } from '@/components/ui/Modal';
|
||||||
|
import { ToggleSwitch } from '@/components/ui/ToggleSwitch';
|
||||||
|
import { PageTransition } from '@/components/common/PageTransition';
|
||||||
|
import { MainRoutes } from '@/router/MainRoutes';
|
||||||
import {
|
import {
|
||||||
IconBot,
|
IconBot,
|
||||||
IconChartLine,
|
IconChartLine,
|
||||||
IconFileText,
|
IconFileText,
|
||||||
IconInfo,
|
IconInfo,
|
||||||
IconKey,
|
IconKey,
|
||||||
|
IconLayoutDashboard,
|
||||||
IconScrollText,
|
IconScrollText,
|
||||||
IconSettings,
|
IconSettings,
|
||||||
IconShield,
|
IconShield,
|
||||||
IconSlidersHorizontal
|
IconSlidersHorizontal,
|
||||||
|
IconTimer,
|
||||||
} from '@/components/ui/icons';
|
} from '@/components/ui/icons';
|
||||||
import { INLINE_LOGO_JPEG } from '@/assets/logoInline';
|
import { INLINE_LOGO_JPEG } from '@/assets/logoInline';
|
||||||
import { useAuthStore, useConfigStore, useLanguageStore, useNotificationStore, useThemeStore } from '@/stores';
|
import {
|
||||||
import { versionApi } from '@/services/api';
|
useAuthStore,
|
||||||
|
useConfigStore,
|
||||||
|
useLanguageStore,
|
||||||
|
useNotificationStore,
|
||||||
|
useThemeStore,
|
||||||
|
} from '@/stores';
|
||||||
|
import { configApi, versionApi } from '@/services/api';
|
||||||
|
import { triggerHeaderRefresh } from '@/hooks/useHeaderRefresh';
|
||||||
|
|
||||||
const sidebarIcons: Record<string, ReactNode> = {
|
const sidebarIcons: Record<string, ReactNode> = {
|
||||||
|
dashboard: <IconLayoutDashboard size={18} />,
|
||||||
settings: <IconSlidersHorizontal size={18} />,
|
settings: <IconSlidersHorizontal size={18} />,
|
||||||
apiKeys: <IconKey size={18} />,
|
apiKeys: <IconKey size={18} />,
|
||||||
aiProviders: <IconBot size={18} />,
|
aiProviders: <IconBot size={18} />,
|
||||||
authFiles: <IconFileText size={18} />,
|
authFiles: <IconFileText size={18} />,
|
||||||
oauth: <IconShield size={18} />,
|
oauth: <IconShield size={18} />,
|
||||||
|
quota: <IconTimer size={18} />,
|
||||||
usage: <IconChartLine size={18} />,
|
usage: <IconChartLine size={18} />,
|
||||||
config: <IconSettings size={18} />,
|
config: <IconSettings size={18} />,
|
||||||
logs: <IconScrollText size={18} />,
|
logs: <IconScrollText size={18} />,
|
||||||
system: <IconInfo size={18} />
|
system: <IconInfo size={18} />,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Header action icons - smaller size for header buttons
|
// Header action icons - smaller size for header buttons
|
||||||
@@ -40,7 +63,7 @@ const headerIconProps: SVGProps<SVGSVGElement> = {
|
|||||||
strokeLinecap: 'round',
|
strokeLinecap: 'round',
|
||||||
strokeLinejoin: 'round',
|
strokeLinejoin: 'round',
|
||||||
'aria-hidden': 'true',
|
'aria-hidden': 'true',
|
||||||
focusable: 'false'
|
focusable: 'false',
|
||||||
};
|
};
|
||||||
|
|
||||||
const headerIcons = {
|
const headerIcons = {
|
||||||
@@ -98,13 +121,32 @@ const headerIcons = {
|
|||||||
<path d="M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9z" />
|
<path d="M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9z" />
|
||||||
</svg>
|
</svg>
|
||||||
),
|
),
|
||||||
|
autoTheme: (
|
||||||
|
<svg {...headerIconProps}>
|
||||||
|
<defs>
|
||||||
|
<clipPath id="mainLayoutAutoThemeSunLeftHalf">
|
||||||
|
<rect x="0" y="0" width="12" height="24" />
|
||||||
|
</clipPath>
|
||||||
|
</defs>
|
||||||
|
<circle cx="12" cy="12" r="4" />
|
||||||
|
<circle cx="12" cy="12" r="4" clipPath="url(#mainLayoutAutoThemeSunLeftHalf)" fill="currentColor" />
|
||||||
|
<path d="M12 2v2" />
|
||||||
|
<path d="M12 20v2" />
|
||||||
|
<path d="M4.93 4.93l1.41 1.41" />
|
||||||
|
<path d="M17.66 17.66l1.41 1.41" />
|
||||||
|
<path d="M2 12h2" />
|
||||||
|
<path d="M20 12h2" />
|
||||||
|
<path d="M6.34 17.66l-1.41 1.41" />
|
||||||
|
<path d="M19.07 4.93l-1.41 1.41" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
logout: (
|
logout: (
|
||||||
<svg {...headerIconProps}>
|
<svg {...headerIconProps}>
|
||||||
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" />
|
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" />
|
||||||
<path d="m16 17 5-5-5-5" />
|
<path d="m16 17 5-5-5-5" />
|
||||||
<path d="M21 12H9" />
|
<path d="M21 12H9" />
|
||||||
</svg>
|
</svg>
|
||||||
)
|
),
|
||||||
};
|
};
|
||||||
|
|
||||||
const parseVersionSegments = (version?: string | null) => {
|
const parseVersionSegments = (version?: string | null) => {
|
||||||
@@ -136,6 +178,7 @@ const compareVersions = (latest?: string | null, current?: string | null) => {
|
|||||||
export function MainLayout() {
|
export function MainLayout() {
|
||||||
const { t, i18n } = useTranslation();
|
const { t, i18n } = useTranslation();
|
||||||
const { showNotification } = useNotificationStore();
|
const { showNotification } = useNotificationStore();
|
||||||
|
const location = useLocation();
|
||||||
|
|
||||||
const apiBase = useAuthStore((state) => state.apiBase);
|
const apiBase = useAuthStore((state) => state.apiBase);
|
||||||
const serverVersion = useAuthStore((state) => state.serverVersion);
|
const serverVersion = useAuthStore((state) => state.serverVersion);
|
||||||
@@ -146,20 +189,32 @@ export function MainLayout() {
|
|||||||
const config = useConfigStore((state) => state.config);
|
const config = useConfigStore((state) => state.config);
|
||||||
const fetchConfig = useConfigStore((state) => state.fetchConfig);
|
const fetchConfig = useConfigStore((state) => state.fetchConfig);
|
||||||
const clearCache = useConfigStore((state) => state.clearCache);
|
const clearCache = useConfigStore((state) => state.clearCache);
|
||||||
|
const updateConfigValue = useConfigStore((state) => state.updateConfigValue);
|
||||||
|
|
||||||
const theme = useThemeStore((state) => state.theme);
|
const theme = useThemeStore((state) => state.theme);
|
||||||
const toggleTheme = useThemeStore((state) => state.toggleTheme);
|
const cycleTheme = useThemeStore((state) => state.cycleTheme);
|
||||||
const toggleLanguage = useLanguageStore((state) => state.toggleLanguage);
|
const toggleLanguage = useLanguageStore((state) => state.toggleLanguage);
|
||||||
|
|
||||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||||
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
|
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
|
||||||
const [checkingVersion, setCheckingVersion] = useState(false);
|
const [checkingVersion, setCheckingVersion] = useState(false);
|
||||||
const [brandExpanded, setBrandExpanded] = useState(true);
|
const [brandExpanded, setBrandExpanded] = useState(true);
|
||||||
|
const [requestLogModalOpen, setRequestLogModalOpen] = useState(false);
|
||||||
|
const [requestLogDraft, setRequestLogDraft] = useState(false);
|
||||||
|
const [requestLogTouched, setRequestLogTouched] = useState(false);
|
||||||
|
const [requestLogSaving, setRequestLogSaving] = useState(false);
|
||||||
|
const contentRef = useRef<HTMLDivElement | null>(null);
|
||||||
const brandCollapseTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const brandCollapseTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
const headerRef = useRef<HTMLElement | null>(null);
|
const headerRef = useRef<HTMLElement | null>(null);
|
||||||
|
const versionTapCount = useRef(0);
|
||||||
|
const versionTapTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
const fullBrandName = 'CLI Proxy API Management Center';
|
const fullBrandName = 'CLI Proxy API Management Center';
|
||||||
const abbrBrandName = t('title.abbr');
|
const abbrBrandName = t('title.abbr');
|
||||||
|
const requestLogEnabled = config?.requestLog ?? false;
|
||||||
|
const requestLogDirty = requestLogDraft !== requestLogEnabled;
|
||||||
|
const canEditRequestLog = connectionStatus === 'connected' && Boolean(config);
|
||||||
|
const isLogsPage = location.pathname.startsWith('/logs');
|
||||||
|
|
||||||
// 将顶栏高度写入 CSS 变量,确保侧栏/内容区计算一致,防止滚动时抖动
|
// 将顶栏高度写入 CSS 变量,确保侧栏/内容区计算一致,防止滚动时抖动
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
@@ -173,7 +228,9 @@ export function MainLayout() {
|
|||||||
updateHeaderHeight();
|
updateHeaderHeight();
|
||||||
|
|
||||||
const resizeObserver =
|
const resizeObserver =
|
||||||
typeof ResizeObserver !== 'undefined' && headerRef.current ? new ResizeObserver(updateHeaderHeight) : null;
|
typeof ResizeObserver !== 'undefined' && headerRef.current
|
||||||
|
? new ResizeObserver(updateHeaderHeight)
|
||||||
|
: null;
|
||||||
if (resizeObserver && headerRef.current) {
|
if (resizeObserver && headerRef.current) {
|
||||||
resizeObserver.observe(headerRef.current);
|
resizeObserver.observe(headerRef.current);
|
||||||
}
|
}
|
||||||
@@ -201,6 +258,20 @@ export function MainLayout() {
|
|||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (requestLogModalOpen && !requestLogTouched) {
|
||||||
|
setRequestLogDraft(requestLogEnabled);
|
||||||
|
}
|
||||||
|
}, [requestLogModalOpen, requestLogTouched, requestLogEnabled]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (versionTapTimer.current) {
|
||||||
|
clearTimeout(versionTapTimer.current);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
const handleBrandClick = useCallback(() => {
|
const handleBrandClick = useCallback(() => {
|
||||||
if (!brandExpanded) {
|
if (!brandExpanded) {
|
||||||
setBrandExpanded(true);
|
setBrandExpanded(true);
|
||||||
@@ -214,12 +285,67 @@ export function MainLayout() {
|
|||||||
}
|
}
|
||||||
}, [brandExpanded]);
|
}, [brandExpanded]);
|
||||||
|
|
||||||
|
const openRequestLogModal = useCallback(() => {
|
||||||
|
setRequestLogTouched(false);
|
||||||
|
setRequestLogDraft(requestLogEnabled);
|
||||||
|
setRequestLogModalOpen(true);
|
||||||
|
}, [requestLogEnabled]);
|
||||||
|
|
||||||
|
const handleRequestLogClose = useCallback(() => {
|
||||||
|
setRequestLogModalOpen(false);
|
||||||
|
setRequestLogTouched(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleVersionTap = useCallback(() => {
|
||||||
|
versionTapCount.current += 1;
|
||||||
|
if (versionTapTimer.current) {
|
||||||
|
clearTimeout(versionTapTimer.current);
|
||||||
|
}
|
||||||
|
versionTapTimer.current = setTimeout(() => {
|
||||||
|
versionTapCount.current = 0;
|
||||||
|
}, 1500);
|
||||||
|
|
||||||
|
if (versionTapCount.current >= 7) {
|
||||||
|
versionTapCount.current = 0;
|
||||||
|
if (versionTapTimer.current) {
|
||||||
|
clearTimeout(versionTapTimer.current);
|
||||||
|
versionTapTimer.current = null;
|
||||||
|
}
|
||||||
|
openRequestLogModal();
|
||||||
|
}
|
||||||
|
}, [openRequestLogModal]);
|
||||||
|
|
||||||
|
const handleRequestLogSave = async () => {
|
||||||
|
if (!canEditRequestLog) return;
|
||||||
|
if (!requestLogDirty) {
|
||||||
|
setRequestLogModalOpen(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const previous = requestLogEnabled;
|
||||||
|
setRequestLogSaving(true);
|
||||||
|
updateConfigValue('request-log', requestLogDraft);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await configApi.updateRequestLog(requestLogDraft);
|
||||||
|
clearCache('request-log');
|
||||||
|
showNotification(t('notification.request_log_updated'), 'success');
|
||||||
|
setRequestLogModalOpen(false);
|
||||||
|
} catch (error: any) {
|
||||||
|
updateConfigValue('request-log', previous);
|
||||||
|
showNotification(`${t('notification.update_failed')}: ${error?.message || ''}`, 'error');
|
||||||
|
} finally {
|
||||||
|
setRequestLogSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchConfig().catch(() => {
|
fetchConfig().catch(() => {
|
||||||
// ignore initial failure; login flow会提示
|
// ignore initial failure; login flow会提示
|
||||||
});
|
});
|
||||||
}, [fetchConfig]);
|
}, [fetchConfig]);
|
||||||
|
|
||||||
|
|
||||||
const statusClass =
|
const statusClass =
|
||||||
connectionStatus === 'connected'
|
connectionStatus === 'connected'
|
||||||
? 'success'
|
? 'success'
|
||||||
@@ -230,25 +356,51 @@ export function MainLayout() {
|
|||||||
: 'muted';
|
: 'muted';
|
||||||
|
|
||||||
const navItems = [
|
const navItems = [
|
||||||
|
{ path: '/', label: t('nav.dashboard'), icon: sidebarIcons.dashboard },
|
||||||
{ path: '/settings', label: t('nav.basic_settings'), icon: sidebarIcons.settings },
|
{ path: '/settings', label: t('nav.basic_settings'), icon: sidebarIcons.settings },
|
||||||
{ path: '/api-keys', label: t('nav.api_keys'), icon: sidebarIcons.apiKeys },
|
{ path: '/api-keys', label: t('nav.api_keys'), icon: sidebarIcons.apiKeys },
|
||||||
{ path: '/ai-providers', label: t('nav.ai_providers'), icon: sidebarIcons.aiProviders },
|
{ path: '/ai-providers', label: t('nav.ai_providers'), icon: sidebarIcons.aiProviders },
|
||||||
{ path: '/auth-files', label: t('nav.auth_files'), icon: sidebarIcons.authFiles },
|
{ path: '/auth-files', label: t('nav.auth_files'), icon: sidebarIcons.authFiles },
|
||||||
{ path: '/oauth', label: t('nav.oauth', { defaultValue: 'OAuth' }), icon: sidebarIcons.oauth },
|
{ path: '/oauth', label: t('nav.oauth', { defaultValue: 'OAuth' }), icon: sidebarIcons.oauth },
|
||||||
|
{ path: '/quota', label: t('nav.quota_management'), icon: sidebarIcons.quota },
|
||||||
{ path: '/usage', label: t('nav.usage_stats'), icon: sidebarIcons.usage },
|
{ path: '/usage', label: t('nav.usage_stats'), icon: sidebarIcons.usage },
|
||||||
{ path: '/config', label: t('nav.config_management'), icon: sidebarIcons.config },
|
{ path: '/config', label: t('nav.config_management'), icon: sidebarIcons.config },
|
||||||
...(config?.loggingToFile ? [{ path: '/logs', label: t('nav.logs'), icon: sidebarIcons.logs }] : []),
|
...(config?.loggingToFile
|
||||||
{ path: '/system', label: t('nav.system_info'), icon: sidebarIcons.system }
|
? [{ path: '/logs', label: t('nav.logs'), icon: sidebarIcons.logs }]
|
||||||
|
: []),
|
||||||
|
{ path: '/system', label: t('nav.system_info'), icon: sidebarIcons.system },
|
||||||
];
|
];
|
||||||
|
const 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 () => {
|
const handleRefreshAll = async () => {
|
||||||
clearCache();
|
clearCache();
|
||||||
try {
|
const results = await Promise.allSettled([
|
||||||
await fetchConfig(undefined, true);
|
fetchConfig(undefined, true),
|
||||||
showNotification(t('notification.data_refreshed'), 'success');
|
triggerHeaderRefresh()
|
||||||
} catch (error: any) {
|
]);
|
||||||
showNotification(`${t('notification.refresh_failed')}: ${error?.message || ''}`, 'error');
|
const rejected = results.find((result) => result.status === 'rejected');
|
||||||
|
if (rejected && rejected.status === 'rejected') {
|
||||||
|
const reason = rejected.reason;
|
||||||
|
const message =
|
||||||
|
typeof reason === 'string' ? reason : reason instanceof Error ? reason.message : '';
|
||||||
|
showNotification(
|
||||||
|
`${t('notification.refresh_failed')}${message ? `: ${message}` : ''}`,
|
||||||
|
'error'
|
||||||
|
);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
showNotification(t('notification.data_refreshed'), 'success');
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleVersionCheck = async () => {
|
const handleVersionCheck = async () => {
|
||||||
@@ -287,7 +439,11 @@ export function MainLayout() {
|
|||||||
<button
|
<button
|
||||||
className="sidebar-toggle-header"
|
className="sidebar-toggle-header"
|
||||||
onClick={() => setSidebarCollapsed((prev) => !prev)}
|
onClick={() => setSidebarCollapsed((prev) => !prev)}
|
||||||
title={sidebarCollapsed ? t('sidebar.expand', { defaultValue: '展开' }) : t('sidebar.collapse', { defaultValue: '收起' })}
|
title={
|
||||||
|
sidebarCollapsed
|
||||||
|
? t('sidebar.expand', { defaultValue: '展开' })
|
||||||
|
: t('sidebar.collapse', { defaultValue: '收起' })
|
||||||
|
}
|
||||||
>
|
>
|
||||||
{sidebarCollapsed ? headerIcons.chevronRight : headerIcons.chevronLeft}
|
{sidebarCollapsed ? headerIcons.chevronRight : headerIcons.chevronLeft}
|
||||||
</button>
|
</button>
|
||||||
@@ -317,20 +473,40 @@ export function MainLayout() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="header-actions">
|
<div className="header-actions">
|
||||||
<Button className="mobile-menu-btn" variant="ghost" size="sm" onClick={() => setSidebarOpen((prev) => !prev)}>
|
<Button
|
||||||
|
className="mobile-menu-btn"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setSidebarOpen((prev) => !prev)}
|
||||||
|
>
|
||||||
{headerIcons.menu}
|
{headerIcons.menu}
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="ghost" size="sm" onClick={handleRefreshAll} title={t('header.refresh_all')}>
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleRefreshAll}
|
||||||
|
title={t('header.refresh_all')}
|
||||||
|
>
|
||||||
{headerIcons.refresh}
|
{headerIcons.refresh}
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="ghost" size="sm" onClick={handleVersionCheck} loading={checkingVersion} title={t('system_info.version_check_button')}>
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleVersionCheck}
|
||||||
|
loading={checkingVersion}
|
||||||
|
title={t('system_info.version_check_button')}
|
||||||
|
>
|
||||||
{headerIcons.update}
|
{headerIcons.update}
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="ghost" size="sm" onClick={toggleLanguage} title={t('language.switch')}>
|
<Button variant="ghost" size="sm" onClick={toggleLanguage} title={t('language.switch')}>
|
||||||
{headerIcons.language}
|
{headerIcons.language}
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="ghost" size="sm" onClick={toggleTheme} title={t('theme.switch')}>
|
<Button variant="ghost" size="sm" onClick={cycleTheme} title={t('theme.switch')}>
|
||||||
{theme === 'dark' ? headerIcons.sun : headerIcons.moon}
|
{theme === 'auto'
|
||||||
|
? headerIcons.autoTheme
|
||||||
|
: theme === 'dark'
|
||||||
|
? headerIcons.moon
|
||||||
|
: headerIcons.sun}
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="ghost" size="sm" onClick={logout} title={t('header.logout')}>
|
<Button variant="ghost" size="sm" onClick={logout} title={t('header.logout')}>
|
||||||
{headerIcons.logout}
|
{headerIcons.logout}
|
||||||
@@ -340,7 +516,9 @@ export function MainLayout() {
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div className="main-body">
|
<div className="main-body">
|
||||||
<aside className={`sidebar ${sidebarOpen ? 'open' : ''} ${sidebarCollapsed ? 'collapsed' : ''}`}>
|
<aside
|
||||||
|
className={`sidebar ${sidebarOpen ? 'open' : ''} ${sidebarCollapsed ? 'collapsed' : ''}`}
|
||||||
|
>
|
||||||
<div className="nav-section">
|
<div className="nav-section">
|
||||||
{navItems.map((item) => (
|
{navItems.map((item) => (
|
||||||
<NavLink
|
<NavLink
|
||||||
@@ -357,25 +535,65 @@ export function MainLayout() {
|
|||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<div className="content">
|
<div className={`content${isLogsPage ? ' content-logs' : ''}`} ref={contentRef}>
|
||||||
<main className="main-content">
|
<main className={`main-content${isLogsPage ? ' main-content-logs' : ''}`}>
|
||||||
<Outlet />
|
<PageTransition
|
||||||
|
render={(location) => <MainRoutes location={location} />}
|
||||||
|
getRouteOrder={getRouteOrder}
|
||||||
|
scrollContainerRef={contentRef}
|
||||||
|
/>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<footer className="footer">
|
<footer className="footer">
|
||||||
<span>
|
<span>
|
||||||
{t('footer.api_version')}: {serverVersion || t('system_info.version_unknown')}
|
{t('footer.api_version')}: {serverVersion || t('system_info.version_unknown')}
|
||||||
</span>
|
</span>
|
||||||
<span>
|
<span className="footer-version" onClick={handleVersionTap}>
|
||||||
{t('footer.version')}: {__APP_VERSION__ || t('system_info.version_unknown')}
|
{t('footer.version')}: {__APP_VERSION__ || t('system_info.version_unknown')}
|
||||||
</span>
|
</span>
|
||||||
<span>
|
<span>
|
||||||
{t('footer.build_date')}:{' '}
|
{t('footer.build_date')}:{' '}
|
||||||
{serverBuildDate ? new Date(serverBuildDate).toLocaleString(i18n.language) : t('system_info.version_unknown')}
|
{serverBuildDate
|
||||||
|
? new Date(serverBuildDate).toLocaleString(i18n.language)
|
||||||
|
: t('system_info.version_unknown')}
|
||||||
</span>
|
</span>
|
||||||
</footer>
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
open={requestLogModalOpen}
|
||||||
|
onClose={handleRequestLogClose}
|
||||||
|
title={t('basic_settings.request_log_title')}
|
||||||
|
footer={
|
||||||
|
<>
|
||||||
|
<Button variant="secondary" onClick={handleRequestLogClose} disabled={requestLogSaving}>
|
||||||
|
{t('common.cancel')}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleRequestLogSave}
|
||||||
|
loading={requestLogSaving}
|
||||||
|
disabled={!canEditRequestLog || !requestLogDirty}
|
||||||
|
>
|
||||||
|
{t('common.save')}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="request-log-modal">
|
||||||
|
<div className="status-badge warning">{t('basic_settings.request_log_warning')}</div>
|
||||||
|
<ToggleSwitch
|
||||||
|
label={t('basic_settings.request_log_enable')}
|
||||||
|
labelPosition="left"
|
||||||
|
checked={requestLogDraft}
|
||||||
|
disabled={!canEditRequestLog || requestLogSaving}
|
||||||
|
onChange={(value) => {
|
||||||
|
setRequestLogDraft(value);
|
||||||
|
setRequestLogTouched(true);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
264
src/components/providers/AmpcodeSection/AmpcodeModal.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
111
src/components/providers/AmpcodeSection/AmpcodeSection.tsx
Normal 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}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
1
src/components/providers/AmpcodeSection/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { AmpcodeSection } from './AmpcodeSection';
|
||||||
128
src/components/providers/ClaudeSection/ClaudeModal.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
202
src/components/providers/ClaudeSection/ClaudeSection.tsx
Normal 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}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
1
src/components/providers/ClaudeSection/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { ClaudeSection } from './ClaudeSection';
|
||||||
117
src/components/providers/CodexSection/CodexModal.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
194
src/components/providers/CodexSection/CodexSection.tsx
Normal 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}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
1
src/components/providers/CodexSection/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { CodexSection } from './CodexSection';
|
||||||
113
src/components/providers/GeminiSection/GeminiModal.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
183
src/components/providers/GeminiSection/GeminiSection.tsx
Normal 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}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
1
src/components/providers/GeminiSection/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { GeminiSection } from './GeminiSection';
|
||||||
194
src/components/providers/OpenAISection/OpenAIDiscoveryModal.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
432
src/components/providers/OpenAISection/OpenAIModal.tsx
Normal 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}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
206
src/components/providers/OpenAISection/OpenAISection.tsx
Normal 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}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
1
src/components/providers/OpenAISection/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { OpenAISection } from './OpenAISection';
|
||||||
80
src/components/providers/ProviderList.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
38
src/components/providers/ProviderStatusBar.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
37
src/components/providers/hooks/useProviderStats.ts
Normal 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 };
|
||||||
|
};
|
||||||
10
src/components/providers/index.ts
Normal 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';
|
||||||
59
src/components/providers/types.ts
Normal 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;
|
||||||
|
}
|
||||||
132
src/components/providers/utils.ts
Normal 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),
|
||||||
|
});
|
||||||
145
src/components/quota/QuotaCard.tsx
Normal 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;
|
||||||
|
};
|
||||||
258
src/components/quota/QuotaSection.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
9
src/components/quota/index.ts
Normal 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';
|
||||||
553
src/components/quota/quotaConfigs.ts
Normal 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
|
||||||
|
};
|
||||||
98
src/components/quota/useQuotaLoader.ts
Normal 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 };
|
||||||
|
}
|
||||||
@@ -3,11 +3,12 @@ import type { PropsWithChildren, ReactNode } from 'react';
|
|||||||
interface CardProps {
|
interface CardProps {
|
||||||
title?: ReactNode;
|
title?: ReactNode;
|
||||||
extra?: ReactNode;
|
extra?: ReactNode;
|
||||||
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Card({ title, extra, children }: PropsWithChildren<CardProps>) {
|
export function Card({ title, extra, children, className }: PropsWithChildren<CardProps>) {
|
||||||
return (
|
return (
|
||||||
<div className="card">
|
<div className={className ? `card ${className}` : 'card'}>
|
||||||
{(title || extra) && (
|
{(title || extra) && (
|
||||||
<div className="card-header">
|
<div className="card-header">
|
||||||
<div className="title">{title}</div>
|
<div className="title">{title}</div>
|
||||||
|
|||||||
@@ -1,7 +1,13 @@
|
|||||||
export function LoadingSpinner({ size = 20 }: { size?: number }) {
|
export function LoadingSpinner({
|
||||||
|
size = 20,
|
||||||
|
className = ''
|
||||||
|
}: {
|
||||||
|
size?: number;
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="loading-spinner"
|
className={`loading-spinner${className ? ` ${className}` : ''}`}
|
||||||
style={{ width: size, height: size, borderWidth: size / 7 }}
|
style={{ width: size, height: size, borderWidth: size / 7 }}
|
||||||
role="status"
|
role="status"
|
||||||
aria-live="polite"
|
aria-live="polite"
|
||||||
|
|||||||
@@ -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';
|
import { IconX } from './icons';
|
||||||
|
|
||||||
interface ModalProps {
|
interface ModalProps {
|
||||||
@@ -9,27 +10,108 @@ interface ModalProps {
|
|||||||
width?: number | string;
|
width?: number | string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Modal({ open, title, onClose, footer, width = 520, children }: PropsWithChildren<ModalProps>) {
|
const CLOSE_ANIMATION_DURATION = 350;
|
||||||
if (!open) return null;
|
const MODAL_LOCK_CLASS = 'modal-open';
|
||||||
|
let activeModalCount = 0;
|
||||||
|
|
||||||
const handleMaskClick = (event: React.MouseEvent<HTMLDivElement>) => {
|
const lockScroll = () => {
|
||||||
if (event.target === event.currentTarget) {
|
if (typeof document === 'undefined') return;
|
||||||
onClose();
|
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);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
export function Modal({ open, title, onClose, footer, width = 520, children }: PropsWithChildren<ModalProps>) {
|
||||||
<div className="modal-overlay" onClick={handleMaskClick}>
|
const [isVisible, setIsVisible] = useState(false);
|
||||||
<div className="modal" style={{ width }} role="dialog" aria-modal="true">
|
const [isClosing, setIsClosing] = useState(false);
|
||||||
|
const closeTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
|
const startClose = useCallback(
|
||||||
|
(notifyParent: boolean) => {
|
||||||
|
if (closeTimerRef.current !== null) return;
|
||||||
|
setIsClosing(true);
|
||||||
|
closeTimerRef.current = window.setTimeout(() => {
|
||||||
|
setIsVisible(false);
|
||||||
|
setIsClosing(false);
|
||||||
|
closeTimerRef.current = null;
|
||||||
|
if (notifyParent) {
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
}, CLOSE_ANIMATION_DURATION);
|
||||||
|
},
|
||||||
|
[onClose]
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
if (closeTimerRef.current !== null) {
|
||||||
|
window.clearTimeout(closeTimerRef.current);
|
||||||
|
closeTimerRef.current = null;
|
||||||
|
}
|
||||||
|
setIsVisible(true);
|
||||||
|
setIsClosing(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isVisible) {
|
||||||
|
startClose(false);
|
||||||
|
}
|
||||||
|
}, [open, isVisible, startClose]);
|
||||||
|
|
||||||
|
const handleClose = useCallback(() => {
|
||||||
|
startClose(true);
|
||||||
|
}, [startClose]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (closeTimerRef.current !== null) {
|
||||||
|
window.clearTimeout(closeTimerRef.current);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
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-header">
|
||||||
<div className="modal-title">{title}</div>
|
<div className="modal-title">{title}</div>
|
||||||
<button className="modal-close" onClick={onClose} aria-label="Close">
|
|
||||||
<IconX size={18} />
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="modal-body">{children}</div>
|
<div className="modal-body">{children}</div>
|
||||||
{footer && <div className="modal-footer">{footer}</div>}
|
{footer && <div className="modal-footer">{footer}</div>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (typeof document === 'undefined') {
|
||||||
|
return modalContent;
|
||||||
|
}
|
||||||
|
|
||||||
|
return createPortal(modalContent, document.body);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,15 +5,26 @@ interface ToggleSwitchProps {
|
|||||||
onChange: (value: boolean) => void;
|
onChange: (value: boolean) => void;
|
||||||
label?: ReactNode;
|
label?: ReactNode;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
|
labelPosition?: 'left' | 'right';
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ToggleSwitch({ checked, onChange, label, disabled = false }: ToggleSwitchProps) {
|
export function ToggleSwitch({
|
||||||
|
checked,
|
||||||
|
onChange,
|
||||||
|
label,
|
||||||
|
disabled = false,
|
||||||
|
labelPosition = 'right'
|
||||||
|
}: ToggleSwitchProps) {
|
||||||
const handleChange = (event: ChangeEvent<HTMLInputElement>) => {
|
const handleChange = (event: ChangeEvent<HTMLInputElement>) => {
|
||||||
onChange(event.target.checked);
|
onChange(event.target.checked);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const className = ['switch', labelPosition === 'left' ? 'switch-label-left' : '']
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' ');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<label className="switch">
|
<label className={className}>
|
||||||
<input type="checkbox" checked={checked} onChange={handleChange} disabled={disabled} />
|
<input type="checkbox" checked={checked} onChange={handleChange} disabled={disabled} />
|
||||||
<span className="track">
|
<span className="track">
|
||||||
<span className="thumb" />
|
<span className="thumb" />
|
||||||
|
|||||||
@@ -303,3 +303,14 @@ export function IconCode({ size = 20, ...props }: IconProps) {
|
|||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function IconLayoutDashboard({ size = 20, ...props }: IconProps) {
|
||||||
|
return (
|
||||||
|
<svg {...baseSvgProps} width={size} height={size} {...props}>
|
||||||
|
<rect width="7" height="9" x="3" y="3" rx="1" />
|
||||||
|
<rect width="7" height="5" x="14" y="3" rx="1" />
|
||||||
|
<rect width="7" height="9" x="14" y="12" rx="1" />
|
||||||
|
<rect width="7" height="5" x="3" y="16" rx="1" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
79
src/components/usage/ApiDetailsCard.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
92
src/components/usage/ChartLineSelector.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
54
src/components/usage/ModelStatsCard.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
164
src/components/usage/PriceSettingsCard.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
184
src/components/usage/StatCards.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
91
src/components/usage/UsageChart.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
8
src/components/usage/hooks/index.ts
Normal 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';
|
||||||
76
src/components/usage/hooks/useChartData.ts
Normal 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
|
||||||
|
};
|
||||||
|
}
|
||||||
138
src/components/usage/hooks/useSparklines.ts
Normal 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
|
||||||
|
};
|
||||||
|
}
|
||||||
153
src/components/usage/hooks/useUsageData.ts
Normal 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
|
||||||
|
};
|
||||||
|
}
|
||||||
28
src/components/usage/index.ts
Normal 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';
|
||||||
@@ -8,3 +8,4 @@ export { useLocalStorage } from './useLocalStorage';
|
|||||||
export { useInterval } from './useInterval';
|
export { useInterval } from './useInterval';
|
||||||
export { useMediaQuery } from './useMediaQuery';
|
export { useMediaQuery } from './useMediaQuery';
|
||||||
export { usePagination } from './usePagination';
|
export { usePagination } from './usePagination';
|
||||||
|
export { useHeaderRefresh } from './useHeaderRefresh';
|
||||||
|
|||||||
24
src/hooks/useHeaderRefresh.ts
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import { useEffect } from 'react';
|
||||||
|
|
||||||
|
export type HeaderRefreshHandler = () => void | Promise<void>;
|
||||||
|
|
||||||
|
let activeHeaderRefreshHandler: HeaderRefreshHandler | null = null;
|
||||||
|
|
||||||
|
export const triggerHeaderRefresh = async () => {
|
||||||
|
if (!activeHeaderRefreshHandler) return;
|
||||||
|
await activeHeaderRefreshHandler();
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useHeaderRefresh = (handler?: HeaderRefreshHandler | null) => {
|
||||||
|
useEffect(() => {
|
||||||
|
if (!handler) return;
|
||||||
|
|
||||||
|
activeHeaderRefreshHandler = handler;
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (activeHeaderRefreshHandler === handler) {
|
||||||
|
activeHeaderRefreshHandler = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [handler]);
|
||||||
|
};
|
||||||
@@ -6,14 +6,14 @@ import i18n from 'i18next';
|
|||||||
import { initReactI18next } from 'react-i18next';
|
import { initReactI18next } from 'react-i18next';
|
||||||
import zhCN from './locales/zh-CN.json';
|
import zhCN from './locales/zh-CN.json';
|
||||||
import en from './locales/en.json';
|
import en from './locales/en.json';
|
||||||
import { STORAGE_KEY_LANGUAGE } from '@/utils/constants';
|
import { getInitialLanguage } from '@/utils/language';
|
||||||
|
|
||||||
i18n.use(initReactI18next).init({
|
i18n.use(initReactI18next).init({
|
||||||
resources: {
|
resources: {
|
||||||
'zh-CN': { translation: zhCN },
|
'zh-CN': { translation: zhCN },
|
||||||
en: { translation: en }
|
en: { translation: en }
|
||||||
},
|
},
|
||||||
lng: localStorage.getItem(STORAGE_KEY_LANGUAGE) || 'zh-CN',
|
lng: getInitialLanguage(),
|
||||||
fallbackLng: 'zh-CN',
|
fallbackLng: 'zh-CN',
|
||||||
interpolation: {
|
interpolation: {
|
||||||
escapeValue: false // React 已经转义
|
escapeValue: false // React 已经转义
|
||||||
|
|||||||
@@ -29,10 +29,13 @@
|
|||||||
"required": "Required",
|
"required": "Required",
|
||||||
"api_key": "Key",
|
"api_key": "Key",
|
||||||
"base_url": "Address",
|
"base_url": "Address",
|
||||||
|
"prefix": "Prefix",
|
||||||
"proxy_url": "Proxy",
|
"proxy_url": "Proxy",
|
||||||
"alias": "Alias",
|
"alias": "Alias",
|
||||||
"failure": "Failure",
|
"failure": "Failure",
|
||||||
"unknown_error": "Unknown error",
|
"unknown_error": "Unknown error",
|
||||||
|
"quota_update_required": "Please update the CPA version or check for updates",
|
||||||
|
"quota_check_credential": "Please check the credential status",
|
||||||
"copy": "Copy",
|
"copy": "Copy",
|
||||||
"custom_headers_label": "Custom Headers",
|
"custom_headers_label": "Custom Headers",
|
||||||
"custom_headers_hint": "Optional HTTP headers to send with the request. Leave blank to remove.",
|
"custom_headers_hint": "Optional HTTP headers to send with the request. Leave blank to remove.",
|
||||||
@@ -60,6 +63,7 @@
|
|||||||
"custom_connection_placeholder": "Eg: https://example.com:8317",
|
"custom_connection_placeholder": "Eg: https://example.com:8317",
|
||||||
"custom_connection_hint": "By default the current URL is used. Override it here if needed.",
|
"custom_connection_hint": "By default the current URL is used. Override it here if needed.",
|
||||||
"use_current_address": "Use Current URL",
|
"use_current_address": "Use Current URL",
|
||||||
|
"remember_password_label": "Remember password",
|
||||||
"management_key_label": "Management Key:",
|
"management_key_label": "Management Key:",
|
||||||
"management_key_placeholder": "Enter the management key",
|
"management_key_placeholder": "Enter the management key",
|
||||||
"connect_button": "Connect",
|
"connect_button": "Connect",
|
||||||
@@ -81,16 +85,39 @@
|
|||||||
"status": "Connection Status:"
|
"status": "Connection Status:"
|
||||||
},
|
},
|
||||||
"nav": {
|
"nav": {
|
||||||
|
"dashboard": "Dashboard",
|
||||||
"basic_settings": "Basic Settings",
|
"basic_settings": "Basic Settings",
|
||||||
"api_keys": "API Keys",
|
"api_keys": "API Keys",
|
||||||
"ai_providers": "AI Providers",
|
"ai_providers": "AI Providers",
|
||||||
"auth_files": "Auth Files",
|
"auth_files": "Auth Files",
|
||||||
"oauth": "OAuth Login",
|
"oauth": "OAuth Login",
|
||||||
|
"quota_management": "Quota Management",
|
||||||
"usage_stats": "Usage Statistics",
|
"usage_stats": "Usage Statistics",
|
||||||
"config_management": "Config Management",
|
"config_management": "Config Management",
|
||||||
"logs": "Logs Viewer",
|
"logs": "Logs Viewer",
|
||||||
"system_info": "Management Center Info"
|
"system_info": "Management Center Info"
|
||||||
},
|
},
|
||||||
|
"dashboard": {
|
||||||
|
"title": "Dashboard",
|
||||||
|
"subtitle": "Welcome to CLI Proxy API Management Center",
|
||||||
|
"openai_providers": "OpenAI Providers",
|
||||||
|
"quick_actions": "Quick Actions",
|
||||||
|
"current_config": "Current Configuration",
|
||||||
|
"management_keys": "Management Keys",
|
||||||
|
"provider_keys_detail": "G:{{gemini}} C:{{codex}} Cl:{{claude}} O:{{openai}}",
|
||||||
|
"oauth_credentials": "OAuth Credentials",
|
||||||
|
"usage_overview": "Usage Overview",
|
||||||
|
"total_requests": "Total Requests",
|
||||||
|
"total_tokens": "Total Tokens",
|
||||||
|
"rpm_30min": "RPM (30min)",
|
||||||
|
"tpm_30min": "TPM (30min)",
|
||||||
|
"models_used": "Models Used",
|
||||||
|
"no_usage_data": "No usage data available",
|
||||||
|
"view_detailed_usage": "View Detailed Stats",
|
||||||
|
"edit_settings": "Edit Settings",
|
||||||
|
"available_models": "Available Models",
|
||||||
|
"available_models_desc": "Total models from all providers"
|
||||||
|
},
|
||||||
"basic_settings": {
|
"basic_settings": {
|
||||||
"title": "Basic Settings",
|
"title": "Basic Settings",
|
||||||
"debug_title": "Debug Mode",
|
"debug_title": "Debug Mode",
|
||||||
@@ -110,7 +137,9 @@
|
|||||||
"usage_statistics_enable": "Enable usage statistics",
|
"usage_statistics_enable": "Enable usage statistics",
|
||||||
"logging_title": "Logging",
|
"logging_title": "Logging",
|
||||||
"logging_to_file_enable": "Enable logging to file",
|
"logging_to_file_enable": "Enable logging to file",
|
||||||
|
"request_log_title": "Request Logging",
|
||||||
"request_log_enable": "Enable request logging",
|
"request_log_enable": "Enable request logging",
|
||||||
|
"request_log_warning": "Keep this off unless you need detailed troubleshooting.",
|
||||||
"ws_auth_title": "WebSocket Authentication",
|
"ws_auth_title": "WebSocket Authentication",
|
||||||
"ws_auth_enable": "Require auth for /ws/*"
|
"ws_auth_enable": "Require auth for /ws/*"
|
||||||
},
|
},
|
||||||
@@ -149,6 +178,9 @@
|
|||||||
"excluded_models_placeholder": "Comma or newline separated, e.g. gemini-1.5-pro, gemini-1.5-flash",
|
"excluded_models_placeholder": "Comma or newline separated, e.g. gemini-1.5-pro, gemini-1.5-flash",
|
||||||
"excluded_models_hint": "Leave empty to allow all models; values are trimmed and deduplicated automatically.",
|
"excluded_models_hint": "Leave empty to allow all models; values are trimmed and deduplicated automatically.",
|
||||||
"excluded_models_count": "Excluding {{count}} models",
|
"excluded_models_count": "Excluding {{count}} models",
|
||||||
|
"prefix_label": "Prefix (Optional):",
|
||||||
|
"prefix_placeholder": "e.g.: team-a",
|
||||||
|
"prefix_hint": "When set, call models as prefix/<model> to target this entry.",
|
||||||
"config_toggle_label": "Enabled",
|
"config_toggle_label": "Enabled",
|
||||||
"config_disabled_badge": "Disabled",
|
"config_disabled_badge": "Disabled",
|
||||||
"codex_title": "Codex API Configuration",
|
"codex_title": "Codex API Configuration",
|
||||||
@@ -200,8 +232,6 @@
|
|||||||
"ampcode_upstream_api_key_current": "Current Amp official key: {{key}}",
|
"ampcode_upstream_api_key_current": "Current Amp official key: {{key}}",
|
||||||
"ampcode_clear_upstream_api_key": "Clear official key",
|
"ampcode_clear_upstream_api_key": "Clear official key",
|
||||||
"ampcode_clear_upstream_api_key_confirm": "Are you sure you want to clear the Ampcode upstream API key (Amp official)?",
|
"ampcode_clear_upstream_api_key_confirm": "Are you sure you want to clear the Ampcode upstream API key (Amp official)?",
|
||||||
"ampcode_restrict_management_label": "Restrict Amp management routes to localhost",
|
|
||||||
"ampcode_restrict_management_hint": "When enabled, Amp management routes (/api/auth, /api/user, /api/threads, etc.) only accept 127.0.0.1/::1 (recommended).",
|
|
||||||
"ampcode_force_model_mappings_label": "Force model mappings",
|
"ampcode_force_model_mappings_label": "Force model mappings",
|
||||||
"ampcode_force_model_mappings_hint": "When enabled, mappings override local API-key availability checks.",
|
"ampcode_force_model_mappings_hint": "When enabled, mappings override local API-key availability checks.",
|
||||||
"ampcode_model_mappings_label": "Model mappings (from → to)",
|
"ampcode_model_mappings_label": "Model mappings (from → to)",
|
||||||
@@ -331,8 +361,55 @@
|
|||||||
"models_excluded_badge": "Excluded",
|
"models_excluded_badge": "Excluded",
|
||||||
"models_excluded_hint": "This model is excluded by OAuth"
|
"models_excluded_hint": "This model is excluded by OAuth"
|
||||||
},
|
},
|
||||||
|
"antigravity_quota": {
|
||||||
|
"title": "Antigravity Quota",
|
||||||
|
"empty_title": "No Antigravity Auth Files",
|
||||||
|
"empty_desc": "Upload an Antigravity credential to view remaining quota.",
|
||||||
|
"idle": "Not loaded. Click Refresh Button.",
|
||||||
|
"loading": "Loading quota...",
|
||||||
|
"load_failed": "Failed to load quota: {{message}}",
|
||||||
|
"missing_auth_index": "Auth file missing auth_index",
|
||||||
|
"empty_models": "No quota data available",
|
||||||
|
"refresh_button": "Refresh Quota",
|
||||||
|
"fetch_all": "Fetch All"
|
||||||
|
},
|
||||||
|
"codex_quota": {
|
||||||
|
"title": "Codex Quota",
|
||||||
|
"empty_title": "No Codex Auth Files",
|
||||||
|
"empty_desc": "Upload a Codex credential to view quota.",
|
||||||
|
"idle": "Not loaded. Click Refresh Button.",
|
||||||
|
"loading": "Loading quota...",
|
||||||
|
"load_failed": "Failed to load quota: {{message}}",
|
||||||
|
"missing_auth_index": "Auth file missing auth_index",
|
||||||
|
"missing_account_id": "Codex credential missing ChatGPT account ID",
|
||||||
|
"empty_windows": "No quota data available",
|
||||||
|
"no_access": "This credential has no Codex access (plan: free).",
|
||||||
|
"refresh_button": "Refresh Quota",
|
||||||
|
"fetch_all": "Fetch All",
|
||||||
|
"primary_window": "5-hour limit",
|
||||||
|
"secondary_window": "Weekly limit",
|
||||||
|
"code_review_window": "Code review limit",
|
||||||
|
"plan_label": "Plan",
|
||||||
|
"plan_plus": "Plus",
|
||||||
|
"plan_team": "Team",
|
||||||
|
"plan_free": "Free"
|
||||||
|
},
|
||||||
|
"gemini_cli_quota": {
|
||||||
|
"title": "Gemini CLI Quota",
|
||||||
|
"empty_title": "No Gemini CLI Auth Files",
|
||||||
|
"empty_desc": "Upload a Gemini CLI credential to view remaining quota.",
|
||||||
|
"idle": "Not loaded. Click Refresh Button.",
|
||||||
|
"loading": "Loading quota...",
|
||||||
|
"load_failed": "Failed to load quota: {{message}}",
|
||||||
|
"missing_auth_index": "Auth file missing auth_index",
|
||||||
|
"missing_project_id": "Gemini CLI credential missing project ID",
|
||||||
|
"empty_buckets": "No quota data available",
|
||||||
|
"refresh_button": "Refresh Quota",
|
||||||
|
"fetch_all": "Fetch All",
|
||||||
|
"remaining_amount": "Remaining {{count}}"
|
||||||
|
},
|
||||||
"vertex_import": {
|
"vertex_import": {
|
||||||
"title": "Vertex AI Credential Import",
|
"title": "Vertex JSON Login",
|
||||||
"description": "Upload a Google service account JSON to store it as auth-dir/vertex-<project>.json using the same rules as the CLI vertex-import helper.",
|
"description": "Upload a Google service account JSON to store it as auth-dir/vertex-<project>.json using the same rules as the CLI vertex-import helper.",
|
||||||
"location_label": "Region (optional)",
|
"location_label": "Region (optional)",
|
||||||
"location_placeholder": "us-central1",
|
"location_placeholder": "us-central1",
|
||||||
@@ -425,8 +502,9 @@
|
|||||||
"gemini_cli_oauth_button": "Start Gemini CLI Login",
|
"gemini_cli_oauth_button": "Start Gemini CLI Login",
|
||||||
"gemini_cli_oauth_hint": "Login to Google Gemini CLI service through OAuth flow, automatically obtain and save authentication files.",
|
"gemini_cli_oauth_hint": "Login to Google Gemini CLI service through OAuth flow, automatically obtain and save authentication files.",
|
||||||
"gemini_cli_project_id_label": "Google Cloud Project ID (Optional):",
|
"gemini_cli_project_id_label": "Google Cloud Project ID (Optional):",
|
||||||
"gemini_cli_project_id_placeholder": "Enter Google Cloud Project ID (optional)",
|
"gemini_cli_project_id_placeholder": "Leave blank to auto-select first available project",
|
||||||
"gemini_cli_project_id_hint": "If a project ID is specified, authentication information for that project will be used.",
|
"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_oauth_url_label": "Authorization URL:",
|
||||||
"gemini_cli_open_link": "Open Link",
|
"gemini_cli_open_link": "Open Link",
|
||||||
"gemini_cli_copy_link": "Copy Link",
|
"gemini_cli_copy_link": "Copy Link",
|
||||||
@@ -446,6 +524,16 @@
|
|||||||
"qwen_oauth_status_error": "Authentication failed:",
|
"qwen_oauth_status_error": "Authentication failed:",
|
||||||
"qwen_oauth_start_error": "Failed to start Qwen OAuth:",
|
"qwen_oauth_start_error": "Failed to start Qwen OAuth:",
|
||||||
"qwen_oauth_polling_error": "Failed to check authentication status:",
|
"qwen_oauth_polling_error": "Failed to check authentication status:",
|
||||||
|
"oauth_callback_label": "Callback URL",
|
||||||
|
"oauth_callback_placeholder": "http://localhost:1455/auth/callback?code=...&state=...",
|
||||||
|
"oauth_callback_hint": "Remote browser mode: after the provider redirects to http://localhost:..., copy the full URL and submit it here.",
|
||||||
|
"oauth_callback_button": "Submit Callback URL",
|
||||||
|
"oauth_callback_required": "Please paste the full redirect URL first.",
|
||||||
|
"oauth_callback_success": "Callback URL submitted. Continue waiting for authentication.",
|
||||||
|
"oauth_callback_error": "Failed to submit callback URL:",
|
||||||
|
"oauth_callback_upgrade_hint": "Please update CLI Proxy API or check the connection.",
|
||||||
|
"oauth_callback_status_success": "Callback URL submitted, waiting for authentication...",
|
||||||
|
"oauth_callback_status_error": "Callback URL submission failed:",
|
||||||
"missing_state": "Unable to retrieve authentication state parameter",
|
"missing_state": "Unable to retrieve authentication state parameter",
|
||||||
"iflow_oauth_title": "iFlow OAuth",
|
"iflow_oauth_title": "iFlow OAuth",
|
||||||
"iflow_oauth_button": "Start iFlow Login",
|
"iflow_oauth_button": "Start iFlow Login",
|
||||||
@@ -497,6 +585,11 @@
|
|||||||
"by_hour": "By Hour",
|
"by_hour": "By Hour",
|
||||||
"by_day": "By Day",
|
"by_day": "By Day",
|
||||||
"refresh": "Refresh",
|
"refresh": "Refresh",
|
||||||
|
"export": "Export",
|
||||||
|
"import": "Import",
|
||||||
|
"export_success": "Usage export downloaded",
|
||||||
|
"import_success": "Import complete: added {{added}}, skipped {{skipped}}, total {{total}}, failed {{failed}}",
|
||||||
|
"import_invalid": "Invalid usage export file",
|
||||||
"chart_line_label_1": "Line 1",
|
"chart_line_label_1": "Line 1",
|
||||||
"chart_line_label_2": "Line 2",
|
"chart_line_label_2": "Line 2",
|
||||||
"chart_line_label_3": "Line 3",
|
"chart_line_label_3": "Line 3",
|
||||||
@@ -552,12 +645,16 @@
|
|||||||
"error_log_button": "Select Error Log",
|
"error_log_button": "Select Error Log",
|
||||||
"error_logs_modal_title": "Error Request Logs",
|
"error_logs_modal_title": "Error Request Logs",
|
||||||
"error_logs_description": "Pick an error request log file to download (only generated when request logging is off).",
|
"error_logs_description": "Pick an error request log file to download (only generated when request logging is off).",
|
||||||
|
"error_logs_request_log_enabled": "Request logging is enabled, so this list will always be empty. Disable request logging and refresh to view error logs.",
|
||||||
"error_logs_empty": "No error request log files found",
|
"error_logs_empty": "No error request log files found",
|
||||||
"error_logs_load_error": "Failed to load error log list",
|
"error_logs_load_error": "Failed to load error log list",
|
||||||
"error_logs_size": "Size",
|
"error_logs_size": "Size",
|
||||||
"error_logs_modified": "Last modified",
|
"error_logs_modified": "Last modified",
|
||||||
"error_logs_download": "Download",
|
"error_logs_download": "Download",
|
||||||
"error_log_download_success": "Error log downloaded successfully",
|
"error_log_download_success": "Error log downloaded successfully",
|
||||||
|
"request_log_download_title": "Download Request Log",
|
||||||
|
"request_log_download_confirm": "Download request log for ID {{id}}?",
|
||||||
|
"request_log_download_success": "Request log downloaded successfully",
|
||||||
"empty_title": "No Logs Available",
|
"empty_title": "No Logs Available",
|
||||||
"empty_desc": "When \"Enable logging to file\" is enabled, logs will be displayed here",
|
"empty_desc": "When \"Enable logging to file\" is enabled, logs will be displayed here",
|
||||||
"log_content": "Log Content",
|
"log_content": "Log Content",
|
||||||
@@ -571,11 +668,17 @@
|
|||||||
"auto_refresh_disabled": "Auto refresh disabled",
|
"auto_refresh_disabled": "Auto refresh disabled",
|
||||||
"load_more_hint": "Scroll up to load more",
|
"load_more_hint": "Scroll up to load more",
|
||||||
"hidden_lines": "Hidden: {{count}} lines",
|
"hidden_lines": "Hidden: {{count}} lines",
|
||||||
|
"loaded_lines": "Loaded: {{count}} lines",
|
||||||
|
"filtered_lines": "Filtered: {{count}} lines",
|
||||||
|
"hide_management_logs": "Hide {{prefix}} logs",
|
||||||
"search_placeholder": "Search logs by content or keyword",
|
"search_placeholder": "Search logs by content or keyword",
|
||||||
"search_empty_title": "No matching logs found",
|
"search_empty_title": "No matching logs found",
|
||||||
"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",
|
"lines": "lines",
|
||||||
"removed": "Removed",
|
"removed": "Filtered",
|
||||||
"upgrade_required_title": "Please Upgrade CLI Proxy API",
|
"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."
|
"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_prev": "Previous",
|
||||||
"search_next": "Next"
|
"search_next": "Next"
|
||||||
},
|
},
|
||||||
|
"quota_management": {
|
||||||
|
"title": "Quota Management",
|
||||||
|
"description": "Monitor OAuth quota status for Antigravity, Codex, and Gemini CLI credentials.",
|
||||||
|
"refresh_files": "Refresh auth files"
|
||||||
|
},
|
||||||
"system_info": {
|
"system_info": {
|
||||||
"title": "Management Center Info",
|
"title": "Management Center Info",
|
||||||
"connection_status_title": "Connection Status",
|
"connection_status_title": "Connection Status",
|
||||||
@@ -638,7 +746,11 @@
|
|||||||
"link_webui_repo": "WebUI Repository",
|
"link_webui_repo": "WebUI Repository",
|
||||||
"link_webui_repo_desc": "Management Center frontend source code",
|
"link_webui_repo_desc": "Management Center frontend source code",
|
||||||
"link_docs": "Documentation",
|
"link_docs": "Documentation",
|
||||||
"link_docs_desc": "Usage tutorials and configuration guides"
|
"link_docs_desc": "Usage tutorials and configuration guides",
|
||||||
|
"clear_login_title": "Local Login Data",
|
||||||
|
"clear_login_desc": "Clear locally saved login data and sign out. Usage stats pricing settings will remain untouched.",
|
||||||
|
"clear_login_button": "Clear login data",
|
||||||
|
"clear_login_confirm": "Clear local login data and sign out now?"
|
||||||
},
|
},
|
||||||
"notification": {
|
"notification": {
|
||||||
"debug_updated": "Debug settings updated",
|
"debug_updated": "Debug settings updated",
|
||||||
@@ -651,6 +763,7 @@
|
|||||||
"logging_to_file_updated": "Logging settings updated",
|
"logging_to_file_updated": "Logging settings updated",
|
||||||
"request_log_updated": "Request logging setting updated",
|
"request_log_updated": "Request logging setting updated",
|
||||||
"ws_auth_updated": "WebSocket authentication setting updated",
|
"ws_auth_updated": "WebSocket authentication setting updated",
|
||||||
|
"login_storage_cleared": "Local login data cleared",
|
||||||
"api_key_added": "API key added successfully",
|
"api_key_added": "API key added successfully",
|
||||||
"api_key_updated": "API key updated successfully",
|
"api_key_updated": "API key updated successfully",
|
||||||
"api_key_deleted": "API key deleted successfully",
|
"api_key_deleted": "API key deleted successfully",
|
||||||
|
|||||||
@@ -29,10 +29,13 @@
|
|||||||
"required": "必填",
|
"required": "必填",
|
||||||
"api_key": "密钥",
|
"api_key": "密钥",
|
||||||
"base_url": "地址",
|
"base_url": "地址",
|
||||||
|
"prefix": "前缀",
|
||||||
"proxy_url": "代理",
|
"proxy_url": "代理",
|
||||||
"alias": "别名",
|
"alias": "别名",
|
||||||
"failure": "失败",
|
"failure": "失败",
|
||||||
"unknown_error": "未知错误",
|
"unknown_error": "未知错误",
|
||||||
|
"quota_update_required": "请更新 CPA 版本或检查更新",
|
||||||
|
"quota_check_credential": "请检查凭证状态",
|
||||||
"copy": "复制",
|
"copy": "复制",
|
||||||
"custom_headers_label": "自定义请求头",
|
"custom_headers_label": "自定义请求头",
|
||||||
"custom_headers_hint": "可选,设置需要附带到请求中的 HTTP 头,名称和值均不能为空。",
|
"custom_headers_hint": "可选,设置需要附带到请求中的 HTTP 头,名称和值均不能为空。",
|
||||||
@@ -60,6 +63,7 @@
|
|||||||
"custom_connection_placeholder": "例如: https://example.com:8317",
|
"custom_connection_placeholder": "例如: https://example.com:8317",
|
||||||
"custom_connection_hint": "默认使用当前访问地址,若需要可手动输入其他地址。",
|
"custom_connection_hint": "默认使用当前访问地址,若需要可手动输入其他地址。",
|
||||||
"use_current_address": "使用当前地址",
|
"use_current_address": "使用当前地址",
|
||||||
|
"remember_password_label": "记住密码",
|
||||||
"management_key_label": "管理密钥:",
|
"management_key_label": "管理密钥:",
|
||||||
"management_key_placeholder": "请输入管理密钥",
|
"management_key_placeholder": "请输入管理密钥",
|
||||||
"connect_button": "连接",
|
"connect_button": "连接",
|
||||||
@@ -81,16 +85,39 @@
|
|||||||
"status": "连接状态:"
|
"status": "连接状态:"
|
||||||
},
|
},
|
||||||
"nav": {
|
"nav": {
|
||||||
|
"dashboard": "仪表盘",
|
||||||
"basic_settings": "基础设置",
|
"basic_settings": "基础设置",
|
||||||
"api_keys": "API 密钥",
|
"api_keys": "API 密钥",
|
||||||
"ai_providers": "AI 提供商",
|
"ai_providers": "AI 提供商",
|
||||||
"auth_files": "认证文件",
|
"auth_files": "认证文件",
|
||||||
"oauth": "OAuth 登录",
|
"oauth": "OAuth 登录",
|
||||||
|
"quota_management": "配额管理",
|
||||||
"usage_stats": "使用统计",
|
"usage_stats": "使用统计",
|
||||||
"config_management": "配置管理",
|
"config_management": "配置管理",
|
||||||
"logs": "日志查看",
|
"logs": "日志查看",
|
||||||
"system_info": "中心信息"
|
"system_info": "中心信息"
|
||||||
},
|
},
|
||||||
|
"dashboard": {
|
||||||
|
"title": "仪表盘",
|
||||||
|
"subtitle": "欢迎使用 CLI Proxy API 管理中心",
|
||||||
|
"openai_providers": "OpenAI 提供商",
|
||||||
|
"quick_actions": "快捷操作",
|
||||||
|
"current_config": "当前配置",
|
||||||
|
"management_keys": "管理密钥",
|
||||||
|
"provider_keys_detail": "G:{{gemini}} C:{{codex}} Cl:{{claude}} O:{{openai}}",
|
||||||
|
"oauth_credentials": "OAuth 凭证",
|
||||||
|
"usage_overview": "使用概览",
|
||||||
|
"total_requests": "总请求数",
|
||||||
|
"total_tokens": "总 Token 数",
|
||||||
|
"rpm_30min": "RPM (30分钟)",
|
||||||
|
"tpm_30min": "TPM (30分钟)",
|
||||||
|
"models_used": "使用模型数",
|
||||||
|
"no_usage_data": "暂无使用数据",
|
||||||
|
"view_detailed_usage": "查看详细统计",
|
||||||
|
"edit_settings": "编辑设置",
|
||||||
|
"available_models": "可用模型",
|
||||||
|
"available_models_desc": "所有提供商的模型总数"
|
||||||
|
},
|
||||||
"basic_settings": {
|
"basic_settings": {
|
||||||
"title": "基础设置",
|
"title": "基础设置",
|
||||||
"debug_title": "调试模式",
|
"debug_title": "调试模式",
|
||||||
@@ -110,7 +137,9 @@
|
|||||||
"usage_statistics_enable": "启用使用统计",
|
"usage_statistics_enable": "启用使用统计",
|
||||||
"logging_title": "日志记录",
|
"logging_title": "日志记录",
|
||||||
"logging_to_file_enable": "启用日志记录到文件",
|
"logging_to_file_enable": "启用日志记录到文件",
|
||||||
|
"request_log_title": "请求日志",
|
||||||
"request_log_enable": "启用请求日志",
|
"request_log_enable": "启用请求日志",
|
||||||
|
"request_log_warning": "仅在需要排查问题时开启,日常请保持关闭。",
|
||||||
"ws_auth_title": "WebSocket 鉴权",
|
"ws_auth_title": "WebSocket 鉴权",
|
||||||
"ws_auth_enable": "启用 /ws/* 鉴权"
|
"ws_auth_enable": "启用 /ws/* 鉴权"
|
||||||
},
|
},
|
||||||
@@ -149,6 +178,9 @@
|
|||||||
"excluded_models_placeholder": "用逗号或换行分隔,例如: gemini-1.5-pro, gemini-1.5-flash",
|
"excluded_models_placeholder": "用逗号或换行分隔,例如: gemini-1.5-pro, gemini-1.5-flash",
|
||||||
"excluded_models_hint": "留空表示不过滤;保存时会自动去重并忽略空白。",
|
"excluded_models_hint": "留空表示不过滤;保存时会自动去重并忽略空白。",
|
||||||
"excluded_models_count": "排除 {{count}} 个模型",
|
"excluded_models_count": "排除 {{count}} 个模型",
|
||||||
|
"prefix_label": "前缀 (可选):",
|
||||||
|
"prefix_placeholder": "例如: team-a",
|
||||||
|
"prefix_hint": "设置后可用 prefix/<model> 选择该条目。",
|
||||||
"config_toggle_label": "启用",
|
"config_toggle_label": "启用",
|
||||||
"config_disabled_badge": "已停用",
|
"config_disabled_badge": "已停用",
|
||||||
"codex_title": "Codex API 配置",
|
"codex_title": "Codex API 配置",
|
||||||
@@ -200,8 +232,6 @@
|
|||||||
"ampcode_upstream_api_key_current": "当前Amp官方密钥: {{key}}",
|
"ampcode_upstream_api_key_current": "当前Amp官方密钥: {{key}}",
|
||||||
"ampcode_clear_upstream_api_key": "清除官方密钥",
|
"ampcode_clear_upstream_api_key": "清除官方密钥",
|
||||||
"ampcode_clear_upstream_api_key_confirm": "确定要清除 Ampcode 的 upstream API key(Amp官方)吗?",
|
"ampcode_clear_upstream_api_key_confirm": "确定要清除 Ampcode 的 upstream API key(Amp官方)吗?",
|
||||||
"ampcode_restrict_management_label": "仅允许本机访问 Amp 管理路由",
|
|
||||||
"ampcode_restrict_management_hint": "开启后,/api/auth、/api/user、/api/threads 等 Amp 管理路由仅允许 127.0.0.1/::1 访问(推荐)。",
|
|
||||||
"ampcode_force_model_mappings_label": "强制应用模型映射",
|
"ampcode_force_model_mappings_label": "强制应用模型映射",
|
||||||
"ampcode_force_model_mappings_hint": "开启后,模型映射将覆盖本地 API Key 可用性判断。",
|
"ampcode_force_model_mappings_hint": "开启后,模型映射将覆盖本地 API Key 可用性判断。",
|
||||||
"ampcode_model_mappings_label": "模型映射 (from → to)",
|
"ampcode_model_mappings_label": "模型映射 (from → to)",
|
||||||
@@ -331,8 +361,55 @@
|
|||||||
"models_excluded_badge": "已排除",
|
"models_excluded_badge": "已排除",
|
||||||
"models_excluded_hint": "此模型已被 OAuth 排除"
|
"models_excluded_hint": "此模型已被 OAuth 排除"
|
||||||
},
|
},
|
||||||
|
"antigravity_quota": {
|
||||||
|
"title": "Antigravity 额度",
|
||||||
|
"empty_title": "暂无 Antigravity 认证",
|
||||||
|
"empty_desc": "上传 Antigravity 认证文件后即可查看额度。",
|
||||||
|
"idle": "尚未加载额度,请点击刷新按钮。",
|
||||||
|
"loading": "正在加载额度...",
|
||||||
|
"load_failed": "额度获取失败:{{message}}",
|
||||||
|
"missing_auth_index": "认证文件缺少 auth_index",
|
||||||
|
"empty_models": "暂无额度数据",
|
||||||
|
"refresh_button": "刷新额度",
|
||||||
|
"fetch_all": "获取全部"
|
||||||
|
},
|
||||||
|
"codex_quota": {
|
||||||
|
"title": "Codex 额度",
|
||||||
|
"empty_title": "暂无 Codex 认证",
|
||||||
|
"empty_desc": "上传 Codex 认证文件后即可查看额度。",
|
||||||
|
"idle": "尚未加载额度,请点击刷新按钮。",
|
||||||
|
"loading": "正在加载额度...",
|
||||||
|
"load_failed": "额度获取失败:{{message}}",
|
||||||
|
"missing_auth_index": "认证文件缺少 auth_index",
|
||||||
|
"missing_account_id": "Codex 凭证缺少 ChatGPT 账号 ID",
|
||||||
|
"empty_windows": "暂无额度数据",
|
||||||
|
"no_access": "该凭证已无 Codex 访问权限(free)。",
|
||||||
|
"refresh_button": "刷新额度",
|
||||||
|
"fetch_all": "获取全部",
|
||||||
|
"primary_window": "5 小时限额",
|
||||||
|
"secondary_window": "周限额",
|
||||||
|
"code_review_window": "代码审查限额",
|
||||||
|
"plan_label": "套餐",
|
||||||
|
"plan_plus": "Plus",
|
||||||
|
"plan_team": "Team",
|
||||||
|
"plan_free": "Free"
|
||||||
|
},
|
||||||
|
"gemini_cli_quota": {
|
||||||
|
"title": "Gemini CLI 额度",
|
||||||
|
"empty_title": "暂无 Gemini CLI 认证",
|
||||||
|
"empty_desc": "上传 Gemini CLI 认证文件后即可查看额度。",
|
||||||
|
"idle": "尚未加载额度,请点击刷新按钮。",
|
||||||
|
"loading": "正在加载额度...",
|
||||||
|
"load_failed": "额度获取失败:{{message}}",
|
||||||
|
"missing_auth_index": "认证文件缺少 auth_index",
|
||||||
|
"missing_project_id": "Gemini CLI 凭证缺少 Project ID",
|
||||||
|
"empty_buckets": "暂无额度数据",
|
||||||
|
"refresh_button": "刷新额度",
|
||||||
|
"fetch_all": "获取全部",
|
||||||
|
"remaining_amount": "剩余 {{count}}"
|
||||||
|
},
|
||||||
"vertex_import": {
|
"vertex_import": {
|
||||||
"title": "Vertex AI 凭证导入",
|
"title": "Vertex JSON 登录",
|
||||||
"description": "上传 Google 服务账号 JSON,使用 CLI vertex-import 同步规则写入 auth-dir/vertex-<project>.json。",
|
"description": "上传 Google 服务账号 JSON,使用 CLI vertex-import 同步规则写入 auth-dir/vertex-<project>.json。",
|
||||||
"location_label": "目标区域 (可选)",
|
"location_label": "目标区域 (可选)",
|
||||||
"location_placeholder": "us-central1",
|
"location_placeholder": "us-central1",
|
||||||
@@ -425,8 +502,9 @@
|
|||||||
"gemini_cli_oauth_button": "开始 Gemini CLI 登录",
|
"gemini_cli_oauth_button": "开始 Gemini CLI 登录",
|
||||||
"gemini_cli_oauth_hint": "通过 OAuth 流程登录 Google Gemini CLI 服务,自动获取并保存认证文件。",
|
"gemini_cli_oauth_hint": "通过 OAuth 流程登录 Google Gemini CLI 服务,自动获取并保存认证文件。",
|
||||||
"gemini_cli_project_id_label": "Google Cloud 项目 ID (可选):",
|
"gemini_cli_project_id_label": "Google Cloud 项目 ID (可选):",
|
||||||
"gemini_cli_project_id_placeholder": "输入 Google Cloud 项目 ID (可选)",
|
"gemini_cli_project_id_placeholder": "留空将自动选择第一个可用项目",
|
||||||
"gemini_cli_project_id_hint": "如果指定了项目 ID,将使用该项目的认证信息。",
|
"gemini_cli_project_id_hint": "可选填写项目 ID。如不填写,系统将自动选择您账号下的第一个可用项目。",
|
||||||
|
"gemini_cli_project_id_required": "请填写 Google Cloud 项目 ID。",
|
||||||
"gemini_cli_oauth_url_label": "授权链接:",
|
"gemini_cli_oauth_url_label": "授权链接:",
|
||||||
"gemini_cli_open_link": "打开链接",
|
"gemini_cli_open_link": "打开链接",
|
||||||
"gemini_cli_copy_link": "复制链接",
|
"gemini_cli_copy_link": "复制链接",
|
||||||
@@ -446,6 +524,16 @@
|
|||||||
"qwen_oauth_status_error": "认证失败:",
|
"qwen_oauth_status_error": "认证失败:",
|
||||||
"qwen_oauth_start_error": "启动 Qwen OAuth 失败:",
|
"qwen_oauth_start_error": "启动 Qwen OAuth 失败:",
|
||||||
"qwen_oauth_polling_error": "检查认证状态失败:",
|
"qwen_oauth_polling_error": "检查认证状态失败:",
|
||||||
|
"oauth_callback_label": "回调 URL",
|
||||||
|
"oauth_callback_placeholder": "http://localhost:1455/auth/callback?code=...&state=...",
|
||||||
|
"oauth_callback_hint": "远程浏览器模式:当授权跳转到 http://localhost:... 后,复制完整 URL 并提交到这里。",
|
||||||
|
"oauth_callback_button": "提交回调 URL",
|
||||||
|
"oauth_callback_required": "请先粘贴完整的回调 URL。",
|
||||||
|
"oauth_callback_success": "回调 URL 已提交,请继续等待认证。",
|
||||||
|
"oauth_callback_error": "提交回调 URL 失败:",
|
||||||
|
"oauth_callback_upgrade_hint": "请更新CLI Proxy API或检查连接",
|
||||||
|
"oauth_callback_status_success": "回调 URL 已提交,等待认证中...",
|
||||||
|
"oauth_callback_status_error": "回调 URL 提交失败:",
|
||||||
"missing_state": "无法获取认证状态参数",
|
"missing_state": "无法获取认证状态参数",
|
||||||
"iflow_oauth_title": "iFlow OAuth",
|
"iflow_oauth_title": "iFlow OAuth",
|
||||||
"iflow_oauth_button": "开始 iFlow 登录",
|
"iflow_oauth_button": "开始 iFlow 登录",
|
||||||
@@ -497,6 +585,11 @@
|
|||||||
"by_hour": "按小时",
|
"by_hour": "按小时",
|
||||||
"by_day": "按天",
|
"by_day": "按天",
|
||||||
"refresh": "刷新",
|
"refresh": "刷新",
|
||||||
|
"export": "导出数据",
|
||||||
|
"import": "导入数据",
|
||||||
|
"export_success": "使用统计已导出",
|
||||||
|
"import_success": "导入完成:新增 {{added}},跳过 {{skipped}},总请求 {{total}},失败 {{failed}}",
|
||||||
|
"import_invalid": "导入文件格式不正确",
|
||||||
"chart_line_label_1": "曲线 1",
|
"chart_line_label_1": "曲线 1",
|
||||||
"chart_line_label_2": "曲线 2",
|
"chart_line_label_2": "曲线 2",
|
||||||
"chart_line_label_3": "曲线 3",
|
"chart_line_label_3": "曲线 3",
|
||||||
@@ -552,12 +645,16 @@
|
|||||||
"error_log_button": "选择错误日志",
|
"error_log_button": "选择错误日志",
|
||||||
"error_logs_modal_title": "错误请求日志",
|
"error_logs_modal_title": "错误请求日志",
|
||||||
"error_logs_description": "请选择要下载的错误请求日志文件(仅在关闭请求日志时生成)。",
|
"error_logs_description": "请选择要下载的错误请求日志文件(仅在关闭请求日志时生成)。",
|
||||||
|
"error_logs_request_log_enabled": "当前已开启请求日志,按接口约定错误请求日志列表会始终为空。关闭请求日志后再刷新即可查看。",
|
||||||
"error_logs_empty": "暂无错误请求日志文件",
|
"error_logs_empty": "暂无错误请求日志文件",
|
||||||
"error_logs_load_error": "加载错误日志列表失败",
|
"error_logs_load_error": "加载错误日志列表失败",
|
||||||
"error_logs_size": "大小",
|
"error_logs_size": "大小",
|
||||||
"error_logs_modified": "最后修改",
|
"error_logs_modified": "最后修改",
|
||||||
"error_logs_download": "下载",
|
"error_logs_download": "下载",
|
||||||
"error_log_download_success": "错误日志下载成功",
|
"error_log_download_success": "错误日志下载成功",
|
||||||
|
"request_log_download_title": "下载报文",
|
||||||
|
"request_log_download_confirm": "是否要下载id为{{id}}的报文?",
|
||||||
|
"request_log_download_success": "报文下载成功",
|
||||||
"empty_title": "暂无日志记录",
|
"empty_title": "暂无日志记录",
|
||||||
"empty_desc": "当启用\"日志记录到文件\"功能后,日志将显示在这里",
|
"empty_desc": "当启用\"日志记录到文件\"功能后,日志将显示在这里",
|
||||||
"log_content": "日志内容",
|
"log_content": "日志内容",
|
||||||
@@ -571,11 +668,17 @@
|
|||||||
"auto_refresh_disabled": "自动刷新已关闭",
|
"auto_refresh_disabled": "自动刷新已关闭",
|
||||||
"load_more_hint": "向上滚动加载更多",
|
"load_more_hint": "向上滚动加载更多",
|
||||||
"hidden_lines": "已隐藏 {{count}} 行",
|
"hidden_lines": "已隐藏 {{count}} 行",
|
||||||
|
"loaded_lines": "已载入 {{count}} 行",
|
||||||
|
"filtered_lines": "已过滤 {{count}} 行",
|
||||||
|
"hide_management_logs": "屏蔽 {{prefix}} 日志",
|
||||||
"search_placeholder": "搜索日志内容或关键字",
|
"search_placeholder": "搜索日志内容或关键字",
|
||||||
"search_empty_title": "未找到匹配的日志",
|
"search_empty_title": "未找到匹配的日志",
|
||||||
"search_empty_desc": "尝试更换关键字或清空搜索条件。",
|
"search_empty_desc": "尝试更换关键字或清空筛选条件。",
|
||||||
|
"double_click_copy_hint": "双击复制日志原文",
|
||||||
|
"copy_success": "已复制日志原文",
|
||||||
|
"copy_failed": "复制失败",
|
||||||
"lines": "行",
|
"lines": "行",
|
||||||
"removed": "已删除",
|
"removed": "已过滤",
|
||||||
"upgrade_required_title": "需要升级 CLI Proxy API",
|
"upgrade_required_title": "需要升级 CLI Proxy API",
|
||||||
"upgrade_required_desc": "当前服务器版本不支持日志查看功能,请升级到最新版本的 CLI Proxy API 以使用此功能。"
|
"upgrade_required_desc": "当前服务器版本不支持日志查看功能,请升级到最新版本的 CLI Proxy API 以使用此功能。"
|
||||||
},
|
},
|
||||||
@@ -603,6 +706,11 @@
|
|||||||
"search_prev": "上一个",
|
"search_prev": "上一个",
|
||||||
"search_next": "下一个"
|
"search_next": "下一个"
|
||||||
},
|
},
|
||||||
|
"quota_management": {
|
||||||
|
"title": "配额管理",
|
||||||
|
"description": "集中查看 OAuth 额度与剩余情况",
|
||||||
|
"refresh_files": "刷新认证文件"
|
||||||
|
},
|
||||||
"system_info": {
|
"system_info": {
|
||||||
"title": "管理中心信息",
|
"title": "管理中心信息",
|
||||||
"connection_status_title": "连接状态",
|
"connection_status_title": "连接状态",
|
||||||
@@ -638,7 +746,11 @@
|
|||||||
"link_webui_repo": "WebUI 仓库",
|
"link_webui_repo": "WebUI 仓库",
|
||||||
"link_webui_repo_desc": "管理中心前端界面源代码",
|
"link_webui_repo_desc": "管理中心前端界面源代码",
|
||||||
"link_docs": "使用教程",
|
"link_docs": "使用教程",
|
||||||
"link_docs_desc": "配置指南和使用说明"
|
"link_docs_desc": "配置指南和使用说明",
|
||||||
|
"clear_login_title": "本地登录信息",
|
||||||
|
"clear_login_desc": "清理本地保存的登录信息并退出登录,不会影响使用统计中的价格设置。",
|
||||||
|
"clear_login_button": "清理登录信息",
|
||||||
|
"clear_login_confirm": "确认清理本地登录信息并退出登录?"
|
||||||
},
|
},
|
||||||
"notification": {
|
"notification": {
|
||||||
"debug_updated": "调试设置已更新",
|
"debug_updated": "调试设置已更新",
|
||||||
@@ -651,6 +763,7 @@
|
|||||||
"logging_to_file_updated": "日志记录设置已更新",
|
"logging_to_file_updated": "日志记录设置已更新",
|
||||||
"request_log_updated": "请求日志设置已更新",
|
"request_log_updated": "请求日志设置已更新",
|
||||||
"ws_auth_updated": "WebSocket 鉴权设置已更新",
|
"ws_auth_updated": "WebSocket 鉴权设置已更新",
|
||||||
|
"login_storage_cleared": "本地登录信息已清理",
|
||||||
"api_key_added": "API密钥添加成功",
|
"api_key_added": "API密钥添加成功",
|
||||||
"api_key_updated": "API密钥更新成功",
|
"api_key_updated": "API密钥更新成功",
|
||||||
"api_key_deleted": "API密钥删除成功",
|
"api_key_deleted": "API密钥删除成功",
|
||||||
|
|||||||
@@ -5,6 +5,17 @@
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.cardTitle {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: $spacing-sm;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cardTitleIcon {
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
.pageTitle {
|
.pageTitle {
|
||||||
font-size: 28px;
|
font-size: 28px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
@@ -386,6 +397,79 @@
|
|||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 状态监测栏
|
||||||
|
.statusBar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 8px;
|
||||||
|
padding: 8px 0;
|
||||||
|
max-width: 280px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.statusBlocks {
|
||||||
|
display: flex;
|
||||||
|
gap: 2px;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 180px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.statusBlock {
|
||||||
|
flex: 1;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 2px;
|
||||||
|
min-width: 6px;
|
||||||
|
transition: transform 0.15s ease, opacity 0.15s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
transform: scaleY(1.5);
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.statusBlockSuccess {
|
||||||
|
background-color: var(--success-color, #22c55e);
|
||||||
|
}
|
||||||
|
|
||||||
|
.statusBlockFailure {
|
||||||
|
background-color: var(--danger-color, #ef4444);
|
||||||
|
}
|
||||||
|
|
||||||
|
.statusBlockMixed {
|
||||||
|
background-color: var(--warning-color, #f59e0b);
|
||||||
|
}
|
||||||
|
|
||||||
|
.statusBlockIdle {
|
||||||
|
background-color: var(--border-secondary, #e5e7eb);
|
||||||
|
}
|
||||||
|
|
||||||
|
.statusRate {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
white-space: nowrap;
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.statusRateHigh {
|
||||||
|
color: var(--success-badge-text, #065f46);
|
||||||
|
background: var(--success-badge-bg, #d1fae5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.statusRateMedium {
|
||||||
|
color: var(--warning-text, #92400e);
|
||||||
|
background: var(--warning-bg, #fef3c7);
|
||||||
|
}
|
||||||
|
|
||||||
|
.statusRateLow {
|
||||||
|
color: var(--failure-badge-text, #991b1b);
|
||||||
|
background: var(--failure-badge-bg, #fee2e2);
|
||||||
|
}
|
||||||
|
|
||||||
// 暗色主题适配
|
// 暗色主题适配
|
||||||
:global([data-theme='dark']) {
|
:global([data-theme='dark']) {
|
||||||
.headerBadge {
|
.headerBadge {
|
||||||
@@ -404,8 +488,17 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.excludedModelTag {
|
.excludedModelTag {
|
||||||
background: rgba(251, 191, 36, 0.2);
|
background: rgba(251, 191, 36, 0.22);
|
||||||
border-color: rgba(251, 191, 36, 0.4);
|
border-color: rgba(251, 191, 36, 0.55);
|
||||||
|
color: #fde68a;
|
||||||
|
|
||||||
|
.modelName {
|
||||||
|
color: #fde68a;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.excludedModelsLabel {
|
||||||
|
color: #fde68a;
|
||||||
}
|
}
|
||||||
|
|
||||||
.apiKeyEntryCard {
|
.apiKeyEntryCard {
|
||||||
@@ -416,4 +509,23 @@
|
|||||||
.apiKeyEntryIndex {
|
.apiKeyEntryIndex {
|
||||||
background: var(--primary-color);
|
background: var(--primary-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.statusBlockIdle {
|
||||||
|
background-color: var(--border-primary, #374151);
|
||||||
|
}
|
||||||
|
|
||||||
|
.statusRateHigh {
|
||||||
|
background: rgba(34, 197, 94, 0.2);
|
||||||
|
color: #86efac;
|
||||||
|
}
|
||||||
|
|
||||||
|
.statusRateMedium {
|
||||||
|
background: rgba(251, 191, 36, 0.2);
|
||||||
|
color: #fde68a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.statusRateLow {
|
||||||
|
background: rgba(239, 68, 68, 0.2);
|
||||||
|
color: #fca5a5;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -124,7 +124,7 @@
|
|||||||
background-color: var(--bg-primary);
|
background-color: var(--bg-primary);
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
cursor: pointer;
|
cursor: text;
|
||||||
height: 38px;
|
height: 38px;
|
||||||
box-sizing: border-box;
|
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 {
|
.fileCard {
|
||||||
background-color: var(--bg-primary);
|
background-color: var(--bg-primary);
|
||||||
@@ -250,6 +516,78 @@
|
|||||||
border-color: var(--failure-badge-border, #fca5a5);
|
border-color: var(--failure-badge-border, #fca5a5);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 状态监测栏
|
||||||
|
.statusBar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 4px 0;
|
||||||
|
max-width: 280px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.statusBlocks {
|
||||||
|
display: flex;
|
||||||
|
gap: 2px;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 180px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.statusBlock {
|
||||||
|
flex: 1;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 2px;
|
||||||
|
min-width: 6px;
|
||||||
|
transition: transform 0.15s ease, opacity 0.15s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
transform: scaleY(1.5);
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.statusBlockSuccess {
|
||||||
|
background-color: var(--success-color, #22c55e);
|
||||||
|
}
|
||||||
|
|
||||||
|
.statusBlockFailure {
|
||||||
|
background-color: var(--danger-color, #ef4444);
|
||||||
|
}
|
||||||
|
|
||||||
|
.statusBlockMixed {
|
||||||
|
background-color: var(--warning-color, #f59e0b);
|
||||||
|
}
|
||||||
|
|
||||||
|
.statusBlockIdle {
|
||||||
|
background-color: var(--border-secondary, #e5e7eb);
|
||||||
|
}
|
||||||
|
|
||||||
|
.statusRate {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
white-space: nowrap;
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.statusRateHigh {
|
||||||
|
color: var(--success-badge-text, #065f46);
|
||||||
|
background: var(--success-badge-bg, #d1fae5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.statusRateMedium {
|
||||||
|
color: var(--warning-text, #92400e);
|
||||||
|
background: var(--warning-bg, #fef3c7);
|
||||||
|
}
|
||||||
|
|
||||||
|
.statusRateLow {
|
||||||
|
color: var(--failure-badge-text, #991b1b);
|
||||||
|
background: var(--failure-badge-bg, #fee2e2);
|
||||||
|
}
|
||||||
|
|
||||||
.cardActions {
|
.cardActions {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: $spacing-xs;
|
gap: $spacing-xs;
|
||||||
@@ -350,6 +688,60 @@
|
|||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// OAuth 排除列表表单:提供商快捷标签
|
||||||
|
.providerField {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: $spacing-xs;
|
||||||
|
|
||||||
|
:global(.form-group) {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.providerTagList {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: $spacing-xs;
|
||||||
|
}
|
||||||
|
|
||||||
|
.providerTag {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 4px 10px;
|
||||||
|
border-radius: $radius-full;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
background-color: var(--bg-secondary);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all $transition-fast;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
border-color: var(--primary-color);
|
||||||
|
color: var(--text-primary);
|
||||||
|
background-color: var(--bg-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.providerTagActive {
|
||||||
|
background-color: var(--primary-color);
|
||||||
|
border-color: var(--primary-color);
|
||||||
|
color: #fff;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: var(--primary-color);
|
||||||
|
border-color: var(--primary-color);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 详情弹窗
|
// 详情弹窗
|
||||||
.detailContent {
|
.detailContent {
|
||||||
max-height: 400px;
|
max-height: 400px;
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { useEffect, useMemo, useRef, useState, useCallback } from 'react';
|
import { useEffect, useMemo, useRef, useState, useCallback } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { useInterval } from '@/hooks/useInterval';
|
||||||
|
import { useHeaderRefresh } from '@/hooks/useHeaderRefresh';
|
||||||
import { Card } from '@/components/ui/Card';
|
import { Card } from '@/components/ui/Card';
|
||||||
import { Button } from '@/components/ui/Button';
|
import { Button } from '@/components/ui/Button';
|
||||||
import { LoadingSpinner } from '@/components/ui/LoadingSpinner';
|
import { LoadingSpinner } from '@/components/ui/LoadingSpinner';
|
||||||
@@ -11,12 +13,14 @@ import { useAuthStore, useNotificationStore, useThemeStore } from '@/stores';
|
|||||||
import { authFilesApi, usageApi } from '@/services/api';
|
import { authFilesApi, usageApi } from '@/services/api';
|
||||||
import { apiClient } from '@/services/api/client';
|
import { apiClient } from '@/services/api/client';
|
||||||
import type { AuthFileItem } from '@/types';
|
import type { AuthFileItem } from '@/types';
|
||||||
import type { KeyStats, KeyStatBucket } from '@/utils/usage';
|
import type { KeyStats, KeyStatBucket, UsageDetail } from '@/utils/usage';
|
||||||
|
import { collectUsageDetails, calculateStatusBarData } from '@/utils/usage';
|
||||||
import { formatFileSize } from '@/utils/format';
|
import { formatFileSize } from '@/utils/format';
|
||||||
import styles from './AuthFilesPage.module.scss';
|
import styles from './AuthFilesPage.module.scss';
|
||||||
|
|
||||||
type ThemeColors = { bg: string; text: string; border?: string };
|
type ThemeColors = { bg: string; text: string; border?: string };
|
||||||
type TypeColorSet = { light: ThemeColors; dark?: ThemeColors };
|
type TypeColorSet = { light: ThemeColors; dark?: ThemeColors };
|
||||||
|
type ResolvedTheme = 'light' | 'dark';
|
||||||
|
|
||||||
// 标签类型颜色配置(对齐重构前 styles.css 的 file-type-badge 颜色)
|
// 标签类型颜色配置(对齐重构前 styles.css 的 file-type-badge 颜色)
|
||||||
const TYPE_COLORS: Record<string, TypeColorSet> = {
|
const TYPE_COLORS: Record<string, TypeColorSet> = {
|
||||||
@@ -62,11 +66,29 @@ const TYPE_COLORS: Record<string, TypeColorSet> = {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const OAUTH_PROVIDER_PRESETS = [
|
||||||
|
'gemini',
|
||||||
|
'gemini-cli',
|
||||||
|
'vertex',
|
||||||
|
'aistudio',
|
||||||
|
'antigravity',
|
||||||
|
'claude',
|
||||||
|
'codex',
|
||||||
|
'qwen',
|
||||||
|
'iflow'
|
||||||
|
];
|
||||||
|
|
||||||
|
const OAUTH_PROVIDER_EXCLUDES = new Set(['all', 'unknown', 'empty']);
|
||||||
|
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 ExcludedFormState {
|
interface ExcludedFormState {
|
||||||
provider: string;
|
provider: string;
|
||||||
modelsText: string;
|
modelsText: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 标准化 auth_index 值(与 usage.ts 中的 normalizeAuthIndex 保持一致)
|
// 标准化 auth_index 值(与 usage.ts 中的 normalizeAuthIndex 保持一致)
|
||||||
function normalizeAuthIndexValue(value: unknown): string | null {
|
function normalizeAuthIndexValue(value: unknown): string | null {
|
||||||
if (typeof value === 'number' && Number.isFinite(value)) {
|
if (typeof value === 'number' && Number.isFinite(value)) {
|
||||||
@@ -129,7 +151,7 @@ export function AuthFilesPage() {
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { showNotification } = useNotificationStore();
|
const { showNotification } = useNotificationStore();
|
||||||
const connectionStatus = useAuthStore((state) => state.connectionStatus);
|
const connectionStatus = useAuthStore((state) => state.connectionStatus);
|
||||||
const theme = useThemeStore((state) => state.theme);
|
const resolvedTheme: ResolvedTheme = useThemeStore((state) => state.resolvedTheme);
|
||||||
|
|
||||||
const [files, setFiles] = useState<AuthFileItem[]>([]);
|
const [files, setFiles] = useState<AuthFileItem[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
@@ -142,6 +164,7 @@ export function AuthFilesPage() {
|
|||||||
const [deleting, setDeleting] = useState<string | null>(null);
|
const [deleting, setDeleting] = useState<string | null>(null);
|
||||||
const [deletingAll, setDeletingAll] = useState(false);
|
const [deletingAll, setDeletingAll] = useState(false);
|
||||||
const [keyStats, setKeyStats] = useState<KeyStats>({ bySource: {}, byAuthIndex: {} });
|
const [keyStats, setKeyStats] = useState<KeyStats>({ bySource: {}, byAuthIndex: {} });
|
||||||
|
const [usageDetails, setUsageDetails] = useState<UsageDetail[]>([]);
|
||||||
|
|
||||||
// 详情弹窗相关
|
// 详情弹窗相关
|
||||||
const [detailModalOpen, setDetailModalOpen] = useState(false);
|
const [detailModalOpen, setDetailModalOpen] = useState(false);
|
||||||
@@ -163,10 +186,18 @@ export function AuthFilesPage() {
|
|||||||
const [savingExcluded, setSavingExcluded] = useState(false);
|
const [savingExcluded, setSavingExcluded] = useState(false);
|
||||||
|
|
||||||
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
||||||
|
const loadingKeyStatsRef = useRef(false);
|
||||||
const excludedUnsupportedRef = useRef(false);
|
const excludedUnsupportedRef = useRef(false);
|
||||||
|
|
||||||
const disableControls = connectionStatus !== 'connected';
|
const disableControls = connectionStatus !== 'connected';
|
||||||
|
|
||||||
|
const handlePageSizeChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const value = event.currentTarget.valueAsNumber;
|
||||||
|
if (!Number.isFinite(value)) return;
|
||||||
|
setPageSize(clampCardPageSize(value));
|
||||||
|
setPage(1);
|
||||||
|
};
|
||||||
|
|
||||||
// 格式化修改时间
|
// 格式化修改时间
|
||||||
const formatModified = (item: AuthFileItem): string => {
|
const formatModified = (item: AuthFileItem): string => {
|
||||||
const raw = item['modtime'] ?? item.modified;
|
const raw = item['modtime'] ?? item.modified;
|
||||||
@@ -194,13 +225,23 @@ export function AuthFilesPage() {
|
|||||||
}
|
}
|
||||||
}, [t]);
|
}, [t]);
|
||||||
|
|
||||||
// 加载 key 统计
|
// 加载 key 统计和 usage 明细(API 层已有60秒超时)
|
||||||
const loadKeyStats = useCallback(async () => {
|
const loadKeyStats = useCallback(async () => {
|
||||||
|
// 防止重复请求
|
||||||
|
if (loadingKeyStatsRef.current) return;
|
||||||
|
loadingKeyStatsRef.current = true;
|
||||||
try {
|
try {
|
||||||
const stats = await usageApi.getKeyStats();
|
const usageResponse = await usageApi.getUsage();
|
||||||
|
const usageData = usageResponse?.usage ?? usageResponse;
|
||||||
|
const stats = await usageApi.getKeyStats(usageData);
|
||||||
setKeyStats(stats);
|
setKeyStats(stats);
|
||||||
|
// 收集 usage 明细用于状态栏
|
||||||
|
const details = collectUsageDetails(usageData);
|
||||||
|
setUsageDetails(details);
|
||||||
} catch {
|
} catch {
|
||||||
// 静默失败
|
// 静默失败
|
||||||
|
} finally {
|
||||||
|
loadingKeyStatsRef.current = false;
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -230,12 +271,21 @@ export function AuthFilesPage() {
|
|||||||
}
|
}
|
||||||
}, [showNotification, t]);
|
}, [showNotification, t]);
|
||||||
|
|
||||||
|
const handleHeaderRefresh = useCallback(async () => {
|
||||||
|
await Promise.all([loadFiles(), loadKeyStats(), loadExcluded()]);
|
||||||
|
}, [loadFiles, loadKeyStats, loadExcluded]);
|
||||||
|
|
||||||
|
useHeaderRefresh(handleHeaderRefresh);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadFiles();
|
loadFiles();
|
||||||
loadKeyStats();
|
loadKeyStats();
|
||||||
loadExcluded();
|
loadExcluded();
|
||||||
}, [loadFiles, loadKeyStats, loadExcluded]);
|
}, [loadFiles, loadKeyStats, loadExcluded]);
|
||||||
|
|
||||||
|
// 定时刷新状态数据(每240秒)
|
||||||
|
useInterval(loadKeyStats, 240_000);
|
||||||
|
|
||||||
// 提取所有存在的类型
|
// 提取所有存在的类型
|
||||||
const existingTypes = useMemo(() => {
|
const existingTypes = useMemo(() => {
|
||||||
const types = new Set<string>(['all']);
|
const types = new Set<string>(['all']);
|
||||||
@@ -247,6 +297,45 @@ export function AuthFilesPage() {
|
|||||||
return Array.from(types);
|
return Array.from(types);
|
||||||
}, [files]);
|
}, [files]);
|
||||||
|
|
||||||
|
|
||||||
|
const excludedProviderLookup = useMemo(() => {
|
||||||
|
const lookup = new Map<string, string>();
|
||||||
|
Object.keys(excluded).forEach((provider) => {
|
||||||
|
const key = provider.trim().toLowerCase();
|
||||||
|
if (key && !lookup.has(key)) {
|
||||||
|
lookup.set(key, provider);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return lookup;
|
||||||
|
}, [excluded]);
|
||||||
|
|
||||||
|
const providerOptions = useMemo(() => {
|
||||||
|
const extraProviders = new Set<string>();
|
||||||
|
|
||||||
|
Object.keys(excluded).forEach((provider) => {
|
||||||
|
extraProviders.add(provider);
|
||||||
|
});
|
||||||
|
files.forEach((file) => {
|
||||||
|
if (typeof file.type === 'string') {
|
||||||
|
extraProviders.add(file.type);
|
||||||
|
}
|
||||||
|
if (typeof file.provider === 'string') {
|
||||||
|
extraProviders.add(file.provider);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const normalizedExtras = Array.from(extraProviders)
|
||||||
|
.map((value) => value.trim())
|
||||||
|
.filter((value) => value && !OAUTH_PROVIDER_EXCLUDES.has(value.toLowerCase()));
|
||||||
|
|
||||||
|
const baseSet = new Set(OAUTH_PROVIDER_PRESETS.map((value) => value.toLowerCase()));
|
||||||
|
const extraList = normalizedExtras
|
||||||
|
.filter((value) => !baseSet.has(value.toLowerCase()))
|
||||||
|
.sort((a, b) => a.localeCompare(b));
|
||||||
|
|
||||||
|
return [...OAUTH_PROVIDER_PRESETS, ...extraList];
|
||||||
|
}, [excluded, files]);
|
||||||
|
|
||||||
// 过滤和搜索
|
// 过滤和搜索
|
||||||
const filtered = useMemo(() => {
|
const filtered = useMemo(() => {
|
||||||
return files.filter((item) => {
|
return files.filter((item) => {
|
||||||
@@ -488,14 +577,19 @@ export function AuthFilesPage() {
|
|||||||
// 获取类型颜色
|
// 获取类型颜色
|
||||||
const getTypeColor = (type: string): ThemeColors => {
|
const getTypeColor = (type: string): ThemeColors => {
|
||||||
const set = TYPE_COLORS[type] || TYPE_COLORS.unknown;
|
const set = TYPE_COLORS[type] || TYPE_COLORS.unknown;
|
||||||
return theme === 'dark' && set.dark ? set.dark : set.light;
|
return resolvedTheme === 'dark' && set.dark ? set.dark : set.light;
|
||||||
};
|
};
|
||||||
|
|
||||||
// OAuth 排除相关方法
|
// OAuth 排除相关方法
|
||||||
const openExcludedModal = (provider?: string) => {
|
const openExcludedModal = (provider?: string) => {
|
||||||
const models = provider ? excluded[provider] : [];
|
const normalizedProvider = (provider || '').trim();
|
||||||
|
const fallbackProvider = normalizedProvider || (filter !== 'all' ? String(filter) : '');
|
||||||
|
const lookupKey = fallbackProvider
|
||||||
|
? excludedProviderLookup.get(fallbackProvider.toLowerCase())
|
||||||
|
: undefined;
|
||||||
|
const models = lookupKey ? excluded[lookupKey] : [];
|
||||||
setExcludedForm({
|
setExcludedForm({
|
||||||
provider: provider || '',
|
provider: lookupKey || fallbackProvider,
|
||||||
modelsText: Array.isArray(models) ? models.join('\n') : ''
|
modelsText: Array.isArray(models) ? models.join('\n') : ''
|
||||||
});
|
});
|
||||||
setExcludedModalOpen(true);
|
setExcludedModalOpen(true);
|
||||||
@@ -547,7 +641,7 @@ export function AuthFilesPage() {
|
|||||||
{existingTypes.map((type) => {
|
{existingTypes.map((type) => {
|
||||||
const isActive = filter === type;
|
const isActive = filter === type;
|
||||||
const color = type === 'all' ? { bg: 'var(--bg-tertiary)', text: 'var(--text-primary)' } : getTypeColor(type);
|
const color = type === 'all' ? { bg: 'var(--bg-tertiary)', text: 'var(--text-primary)' } : getTypeColor(type);
|
||||||
const activeTextColor = theme === 'dark' ? '#111827' : '#fff';
|
const activeTextColor = resolvedTheme === 'dark' ? '#111827' : '#fff';
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={type}
|
key={type}
|
||||||
@@ -569,10 +663,71 @@ export function AuthFilesPage() {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// 预计算所有认证文件的状态栏数据(避免每次渲染重复计算)
|
||||||
|
const statusBarCache = useMemo(() => {
|
||||||
|
const cache = new Map<string, ReturnType<typeof calculateStatusBarData>>();
|
||||||
|
|
||||||
|
files.forEach((file) => {
|
||||||
|
const rawAuthIndex = file['auth_index'] ?? file.authIndex;
|
||||||
|
const authIndexKey = normalizeAuthIndexValue(rawAuthIndex);
|
||||||
|
|
||||||
|
if (authIndexKey) {
|
||||||
|
// 过滤出属于该认证文件的 usage 明细
|
||||||
|
const filteredDetails = usageDetails.filter((detail) => {
|
||||||
|
const detailAuthIndex = normalizeAuthIndexValue(detail.auth_index);
|
||||||
|
return detailAuthIndex !== null && detailAuthIndex === authIndexKey;
|
||||||
|
});
|
||||||
|
cache.set(authIndexKey, calculateStatusBarData(filteredDetails));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return cache;
|
||||||
|
}, [usageDetails, files]);
|
||||||
|
|
||||||
|
// 渲染状态监测栏
|
||||||
|
const renderStatusBar = (item: AuthFileItem) => {
|
||||||
|
// 认证文件使用 authIndex 来匹配 usage 数据
|
||||||
|
const rawAuthIndex = item['auth_index'] ?? item.authIndex;
|
||||||
|
const authIndexKey = normalizeAuthIndexValue(rawAuthIndex);
|
||||||
|
|
||||||
|
const statusData = (authIndexKey && statusBarCache.get(authIndexKey)) || calculateStatusBarData([]);
|
||||||
|
const hasData = statusData.totalSuccess + statusData.totalFailure > 0;
|
||||||
|
const rateClass = !hasData
|
||||||
|
? ''
|
||||||
|
: statusData.successRate >= 90
|
||||||
|
? styles.statusRateHigh
|
||||||
|
: statusData.successRate >= 50
|
||||||
|
? styles.statusRateMedium
|
||||||
|
: styles.statusRateLow;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.statusBar}>
|
||||||
|
<div className={styles.statusBlocks}>
|
||||||
|
{statusData.blocks.map((state, idx) => {
|
||||||
|
const blockClass =
|
||||||
|
state === 'success'
|
||||||
|
? styles.statusBlockSuccess
|
||||||
|
: state === 'failure'
|
||||||
|
? styles.statusBlockFailure
|
||||||
|
: state === 'mixed'
|
||||||
|
? styles.statusBlockMixed
|
||||||
|
: styles.statusBlockIdle;
|
||||||
|
return <div key={idx} className={`${styles.statusBlock} ${blockClass}`} />;
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<span className={`${styles.statusRate} ${rateClass}`}>
|
||||||
|
{hasData ? `${statusData.successRate.toFixed(1)}%` : '--'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
// 渲染单个认证文件卡片
|
// 渲染单个认证文件卡片
|
||||||
const renderFileCard = (item: AuthFileItem) => {
|
const renderFileCard = (item: AuthFileItem) => {
|
||||||
const fileStats = resolveAuthFileStats(item, keyStats);
|
const fileStats = resolveAuthFileStats(item, keyStats);
|
||||||
const isRuntimeOnly = isRuntimeOnlyAuthFile(item);
|
const isRuntimeOnly = isRuntimeOnlyAuthFile(item);
|
||||||
|
const isAistudio = (item.type || '').toLowerCase() === 'aistudio';
|
||||||
|
const showModelsButton = !isRuntimeOnly || isAistudio;
|
||||||
const typeColor = getTypeColor(item.type || 'unknown');
|
const typeColor = getTypeColor(item.type || 'unknown');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -605,11 +760,11 @@ export function AuthFilesPage() {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 状态监测栏 */}
|
||||||
|
{renderStatusBar(item)}
|
||||||
|
|
||||||
<div className={styles.cardActions}>
|
<div className={styles.cardActions}>
|
||||||
{isRuntimeOnly ? (
|
{showModelsButton && (
|
||||||
<div className={styles.virtualBadge}>{t('auth_files.type_virtual') || '虚拟认证文件'}</div>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Button
|
<Button
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -620,6 +775,9 @@ export function AuthFilesPage() {
|
|||||||
>
|
>
|
||||||
<IconBot className={styles.actionIcon} size={16} />
|
<IconBot className={styles.actionIcon} size={16} />
|
||||||
</Button>
|
</Button>
|
||||||
|
)}
|
||||||
|
{!isRuntimeOnly && (
|
||||||
|
<>
|
||||||
<Button
|
<Button
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -656,6 +814,9 @@ export function AuthFilesPage() {
|
|||||||
</Button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
{isRuntimeOnly && (
|
||||||
|
<div className={styles.virtualBadge}>{t('auth_files.type_virtual') || '虚拟认证文件'}</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -672,7 +833,12 @@ export function AuthFilesPage() {
|
|||||||
title={t('auth_files.title_section')}
|
title={t('auth_files.title_section')}
|
||||||
extra={
|
extra={
|
||||||
<div className={styles.headerActions}>
|
<div className={styles.headerActions}>
|
||||||
<Button variant="secondary" size="sm" onClick={() => { loadFiles(); loadKeyStats(); }} disabled={loading}>
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleHeaderRefresh}
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
{t('common.refresh')}
|
{t('common.refresh')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
@@ -718,20 +884,15 @@ export function AuthFilesPage() {
|
|||||||
</div>
|
</div>
|
||||||
<div className={styles.filterItem}>
|
<div className={styles.filterItem}>
|
||||||
<label>{t('auth_files.page_size_label')}</label>
|
<label>{t('auth_files.page_size_label')}</label>
|
||||||
<select
|
<input
|
||||||
className={styles.pageSizeSelect}
|
className={styles.pageSizeSelect}
|
||||||
|
type="number"
|
||||||
|
min={MIN_CARD_PAGE_SIZE}
|
||||||
|
max={MAX_CARD_PAGE_SIZE}
|
||||||
|
step={1}
|
||||||
value={pageSize}
|
value={pageSize}
|
||||||
onChange={(e) => {
|
onChange={handlePageSizeChange}
|
||||||
setPageSize(Number(e.target.value) || 9);
|
/>
|
||||||
setPage(1);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<option value={6}>6</option>
|
|
||||||
<option value={9}>9</option>
|
|
||||||
<option value={12}>12</option>
|
|
||||||
<option value={18}>18</option>
|
|
||||||
<option value={24}>24</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.filterItem}>
|
<div className={styles.filterItem}>
|
||||||
<label>{t('common.info')}</label>
|
<label>{t('common.info')}</label>
|
||||||
@@ -931,12 +1092,41 @@ export function AuthFilesPage() {
|
|||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
|
<div className={styles.providerField}>
|
||||||
<Input
|
<Input
|
||||||
|
id="oauth-excluded-provider"
|
||||||
|
list="oauth-excluded-provider-options"
|
||||||
label={t('oauth_excluded.provider_label')}
|
label={t('oauth_excluded.provider_label')}
|
||||||
|
hint={t('oauth_excluded.provider_hint')}
|
||||||
placeholder={t('oauth_excluded.provider_placeholder')}
|
placeholder={t('oauth_excluded.provider_placeholder')}
|
||||||
value={excludedForm.provider}
|
value={excludedForm.provider}
|
||||||
onChange={(e) => setExcludedForm((prev) => ({ ...prev, provider: e.target.value }))}
|
onChange={(e) => setExcludedForm((prev) => ({ ...prev, provider: e.target.value }))}
|
||||||
/>
|
/>
|
||||||
|
<datalist id="oauth-excluded-provider-options">
|
||||||
|
{providerOptions.map((provider) => (
|
||||||
|
<option key={provider} value={provider} />
|
||||||
|
))}
|
||||||
|
</datalist>
|
||||||
|
{providerOptions.length > 0 && (
|
||||||
|
<div className={styles.providerTagList}>
|
||||||
|
{providerOptions.map((provider) => {
|
||||||
|
const isActive =
|
||||||
|
excludedForm.provider.trim().toLowerCase() === provider.toLowerCase();
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={provider}
|
||||||
|
type="button"
|
||||||
|
className={`${styles.providerTag} ${isActive ? styles.providerTagActive : ''}`}
|
||||||
|
onClick={() => setExcludedForm((prev) => ({ ...prev, provider }))}
|
||||||
|
disabled={savingExcluded}
|
||||||
|
>
|
||||||
|
{getTypeLabel(provider)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
<div className={styles.formGroup}>
|
<div className={styles.formGroup}>
|
||||||
<label>{t('oauth_excluded.models_label')}</label>
|
<label>{t('oauth_excluded.models_label')}</label>
|
||||||
<textarea
|
<textarea
|
||||||
|
|||||||
@@ -2,6 +2,10 @@
|
|||||||
|
|
||||||
.container {
|
.container {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
min-height: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow-y: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.pageTitle {
|
.pageTitle {
|
||||||
@@ -21,6 +25,8 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: $spacing-lg;
|
gap: $spacing-lg;
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.searchInputWrapper {
|
.searchInputWrapper {
|
||||||
@@ -127,7 +133,8 @@
|
|||||||
|
|
||||||
.editorWrapper {
|
.editorWrapper {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 500px;
|
flex: 1;
|
||||||
|
min-height: 800px;
|
||||||
border: 1px solid var(--border-color);
|
border: 1px solid var(--border-color);
|
||||||
border-radius: $radius-lg;
|
border-radius: $radius-lg;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
@@ -159,6 +166,9 @@
|
|||||||
.cm-scroller {
|
.cm-scroller {
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
padding-top: calc(var(--floating-controls-height, 0px) + #{$spacing-md});
|
padding-top: calc(var(--floating-controls-height, 0px) + #{$spacing-md});
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
touch-action: pan-x pan-y;
|
||||||
|
overscroll-behavior: contain;
|
||||||
}
|
}
|
||||||
|
|
||||||
.cm-gutters {
|
.cm-gutters {
|
||||||
@@ -206,6 +216,14 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.configCard {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 1120px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
|
||||||
.actions {
|
.actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: $spacing-sm;
|
gap: $spacing-sm;
|
||||||
@@ -219,3 +237,27 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (max-height: 820px) {
|
||||||
|
.pageTitle {
|
||||||
|
font-size: 24px;
|
||||||
|
margin-bottom: $spacing-sm;
|
||||||
|
}
|
||||||
|
|
||||||
|
.description {
|
||||||
|
margin-bottom: $spacing-lg;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content {
|
||||||
|
gap: $spacing-md;
|
||||||
|
}
|
||||||
|
|
||||||
|
.configCard {
|
||||||
|
height: 880px;
|
||||||
|
padding: $spacing-md;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editorWrapper {
|
||||||
|
min-height: 600px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ export function ConfigPage() {
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { showNotification } = useNotificationStore();
|
const { showNotification } = useNotificationStore();
|
||||||
const connectionStatus = useAuthStore((state) => state.connectionStatus);
|
const connectionStatus = useAuthStore((state) => state.connectionStatus);
|
||||||
const theme = useThemeStore((state) => state.theme);
|
const resolvedTheme = useThemeStore((state) => state.resolvedTheme);
|
||||||
|
|
||||||
const [content, setContent] = useState('');
|
const [content, setContent] = useState('');
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
@@ -224,7 +224,7 @@ export function ConfigPage() {
|
|||||||
<h1 className={styles.pageTitle}>{t('config_management.title')}</h1>
|
<h1 className={styles.pageTitle}>{t('config_management.title')}</h1>
|
||||||
<p className={styles.description}>{t('config_management.description')}</p>
|
<p className={styles.description}>{t('config_management.description')}</p>
|
||||||
|
|
||||||
<Card>
|
<Card className={styles.configCard}>
|
||||||
<div className={styles.content}>
|
<div className={styles.content}>
|
||||||
{/* Editor */}
|
{/* Editor */}
|
||||||
{error && <div className="error-box">{error}</div>}
|
{error && <div className="error-box">{error}</div>}
|
||||||
@@ -289,7 +289,7 @@ export function ConfigPage() {
|
|||||||
value={content}
|
value={content}
|
||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
extensions={extensions}
|
extensions={extensions}
|
||||||
theme={theme === 'dark' ? 'dark' : 'light'}
|
theme={resolvedTheme}
|
||||||
editable={!disableControls && !loading}
|
editable={!disableControls && !loading}
|
||||||
placeholder={t('config_management.editor_placeholder')}
|
placeholder={t('config_management.editor_placeholder')}
|
||||||
height="100%"
|
height="100%"
|
||||||
|
|||||||
320
src/pages/DashboardPage.module.scss
Normal 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
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,10 +1,10 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import { useNavigate, useLocation } from 'react-router-dom';
|
import { Navigate, useNavigate, useLocation } from 'react-router-dom';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Button } from '@/components/ui/Button';
|
import { Button } from '@/components/ui/Button';
|
||||||
import { Input } from '@/components/ui/Input';
|
import { Input } from '@/components/ui/Input';
|
||||||
import { IconEye, IconEyeOff } from '@/components/ui/icons';
|
import { IconEye, IconEyeOff } from '@/components/ui/icons';
|
||||||
import { useAuthStore, useNotificationStore } from '@/stores';
|
import { useAuthStore, useLanguageStore, useNotificationStore } from '@/stores';
|
||||||
import { detectApiBaseFromLocation, normalizeApiBase } from '@/utils/connection';
|
import { detectApiBaseFromLocation, normalizeApiBase } from '@/utils/connection';
|
||||||
|
|
||||||
export function LoginPage() {
|
export function LoginPage() {
|
||||||
@@ -12,21 +12,26 @@ export function LoginPage() {
|
|||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const { showNotification } = useNotificationStore();
|
const { showNotification } = useNotificationStore();
|
||||||
|
const language = useLanguageStore((state) => state.language);
|
||||||
|
const toggleLanguage = useLanguageStore((state) => state.toggleLanguage);
|
||||||
const isAuthenticated = useAuthStore((state) => state.isAuthenticated);
|
const isAuthenticated = useAuthStore((state) => state.isAuthenticated);
|
||||||
const login = useAuthStore((state) => state.login);
|
const login = useAuthStore((state) => state.login);
|
||||||
const restoreSession = useAuthStore((state) => state.restoreSession);
|
const restoreSession = useAuthStore((state) => state.restoreSession);
|
||||||
const storedBase = useAuthStore((state) => state.apiBase);
|
const storedBase = useAuthStore((state) => state.apiBase);
|
||||||
const storedKey = useAuthStore((state) => state.managementKey);
|
const storedKey = useAuthStore((state) => state.managementKey);
|
||||||
|
const storedRememberPassword = useAuthStore((state) => state.rememberPassword);
|
||||||
|
|
||||||
const [apiBase, setApiBase] = useState('');
|
const [apiBase, setApiBase] = useState('');
|
||||||
const [managementKey, setManagementKey] = useState('');
|
const [managementKey, setManagementKey] = useState('');
|
||||||
const [showCustomBase, setShowCustomBase] = useState(false);
|
const [showCustomBase, setShowCustomBase] = useState(false);
|
||||||
const [showKey, setShowKey] = useState(false);
|
const [showKey, setShowKey] = useState(false);
|
||||||
|
const [rememberPassword, setRememberPassword] = useState(false);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [autoLoading, setAutoLoading] = useState(true);
|
const [autoLoading, setAutoLoading] = useState(true);
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
const detectedBase = useMemo(() => detectApiBaseFromLocation(), []);
|
const detectedBase = useMemo(() => detectApiBaseFromLocation(), []);
|
||||||
|
const nextLanguageLabel = language === 'zh-CN' ? t('language.english') : t('language.chinese');
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const init = async () => {
|
const init = async () => {
|
||||||
@@ -35,6 +40,7 @@ export function LoginPage() {
|
|||||||
if (!autoLoggedIn) {
|
if (!autoLoggedIn) {
|
||||||
setApiBase(storedBase || detectedBase);
|
setApiBase(storedBase || detectedBase);
|
||||||
setManagementKey(storedKey || '');
|
setManagementKey(storedKey || '');
|
||||||
|
setRememberPassword(storedRememberPassword || Boolean(storedKey));
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
setAutoLoading(false);
|
setAutoLoading(false);
|
||||||
@@ -42,18 +48,12 @@ export function LoginPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
init();
|
init();
|
||||||
}, [detectedBase, restoreSession, storedBase, storedKey]);
|
}, [detectedBase, restoreSession, storedBase, storedKey, storedRememberPassword]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (isAuthenticated) {
|
if (isAuthenticated) {
|
||||||
const redirect = (location.state as any)?.from?.pathname || '/';
|
const redirect = (location.state as any)?.from?.pathname || '/';
|
||||||
navigate(redirect, { replace: true });
|
return <Navigate to={redirect} replace />;
|
||||||
}
|
}
|
||||||
}, [isAuthenticated, navigate, location.state]);
|
|
||||||
|
|
||||||
const handleUseCurrent = () => {
|
|
||||||
setApiBase(detectedBase);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSubmit = async () => {
|
const handleSubmit = async () => {
|
||||||
if (!managementKey.trim()) {
|
if (!managementKey.trim()) {
|
||||||
@@ -65,7 +65,11 @@ export function LoginPage() {
|
|||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError('');
|
setError('');
|
||||||
try {
|
try {
|
||||||
await login({ apiBase: baseToUse, managementKey: managementKey.trim() });
|
await login({
|
||||||
|
apiBase: baseToUse,
|
||||||
|
managementKey: managementKey.trim(),
|
||||||
|
rememberPassword
|
||||||
|
});
|
||||||
showNotification(t('common.connected_status'), 'success');
|
showNotification(t('common.connected_status'), 'success');
|
||||||
navigate('/', { replace: true });
|
navigate('/', { replace: true });
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
@@ -81,7 +85,20 @@ export function LoginPage() {
|
|||||||
<div className="login-page">
|
<div className="login-page">
|
||||||
<div className="login-card">
|
<div className="login-card">
|
||||||
<div className="login-header">
|
<div className="login-header">
|
||||||
|
<div className="login-title-row">
|
||||||
<div className="title">{t('title.login')}</div>
|
<div className="title">{t('title.login')}</div>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="login-language-btn"
|
||||||
|
onClick={toggleLanguage}
|
||||||
|
title={t('language.switch')}
|
||||||
|
aria-label={t('language.switch')}
|
||||||
|
>
|
||||||
|
{nextLanguageLabel}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
<div className="subtitle">{t('login.subtitle')}</div>
|
<div className="subtitle">{t('login.subtitle')}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -138,14 +155,19 @@ export function LoginPage() {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div style={{ display: 'flex', gap: 12, alignItems: 'center' }}>
|
<div className="toggle-advanced">
|
||||||
<Button variant="secondary" onClick={handleUseCurrent}>
|
<input
|
||||||
{t('login.use_current_address')}
|
id="remember-password-toggle"
|
||||||
</Button>
|
type="checkbox"
|
||||||
|
checked={rememberPassword}
|
||||||
|
onChange={(e) => setRememberPassword(e.target.checked)}
|
||||||
|
/>
|
||||||
|
<label htmlFor="remember-password-toggle">{t('login.remember_password_label')}</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
<Button fullWidth onClick={handleSubmit} loading={loading}>
|
<Button fullWidth onClick={handleSubmit} loading={loading}>
|
||||||
{loading ? t('login.submitting') : t('login.submit_button')}
|
{loading ? t('login.submitting') : t('login.submit_button')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
|
||||||
|
|
||||||
{error && <div className="error-box">{error}</div>}
|
{error && <div className="error-box">{error}</div>}
|
||||||
|
|
||||||
|
|||||||
@@ -2,19 +2,80 @@
|
|||||||
|
|
||||||
.container {
|
.container {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 0;
|
||||||
|
|
||||||
|
@include mobile {
|
||||||
|
min-height: auto;
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.pageTitle {
|
.pageTitle {
|
||||||
font-size: 28px;
|
font-size: 28px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
margin: 0 0 $spacing-xl 0;
|
margin: 0 0 $spacing-lg 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tabBar {
|
||||||
|
display: flex;
|
||||||
|
gap: $spacing-xs;
|
||||||
|
margin-bottom: $spacing-lg;
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tabItem {
|
||||||
|
@include button-reset;
|
||||||
|
padding: 12px 20px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
background: transparent;
|
||||||
|
border-bottom: 2px solid transparent;
|
||||||
|
margin-bottom: -1px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition:
|
||||||
|
color 0.15s ease,
|
||||||
|
border-color 0.15s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.tabActive {
|
||||||
|
color: var(--primary-color);
|
||||||
|
border-bottom-color: var(--primary-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
.content {
|
.content {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: $spacing-lg;
|
gap: $spacing-lg;
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
|
||||||
|
@include mobile {
|
||||||
|
gap: $spacing-md;
|
||||||
|
min-height: auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.logCard {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
@include mobile {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
min-height: auto;
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.toolbar {
|
.toolbar {
|
||||||
@@ -22,12 +83,76 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: $spacing-sm;
|
gap: $spacing-sm;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
|
margin-left: auto;
|
||||||
|
|
||||||
@include mobile {
|
@include mobile {
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
|
margin-left: 0;
|
||||||
|
width: 100%;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.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 {
|
.actionButton {
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
@@ -56,9 +181,33 @@
|
|||||||
background: var(--bg-secondary);
|
background: var(--bg-secondary);
|
||||||
border: 1px solid var(--border-color);
|
border: 1px solid var(--border-color);
|
||||||
border-radius: $radius-md;
|
border-radius: $radius-md;
|
||||||
max-height: 620px;
|
flex: 1 1 auto;
|
||||||
|
min-height: 280px;
|
||||||
|
max-height: calc(100vh - 320px);
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
position: relative;
|
position: relative;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
touch-action: pan-y;
|
||||||
|
overscroll-behavior: contain;
|
||||||
|
|
||||||
|
@include tablet {
|
||||||
|
min-height: 240px;
|
||||||
|
max-height: calc(100vh - 300px);
|
||||||
|
}
|
||||||
|
|
||||||
|
@include mobile {
|
||||||
|
min-height: 360px;
|
||||||
|
max-height: 480px;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.errorPanel {
|
||||||
|
height: 480px;
|
||||||
|
overflow: auto;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
overscroll-behavior: contain;
|
||||||
}
|
}
|
||||||
|
|
||||||
.loadMoreBanner {
|
.loadMoreBanner {
|
||||||
@@ -74,6 +223,17 @@
|
|||||||
background: var(--bg-primary);
|
background: var(--bg-primary);
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
|
|
||||||
|
@include mobile {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: flex-start;
|
||||||
|
gap: $spacing-xs;
|
||||||
|
|
||||||
|
> span {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.loadMoreCount {
|
.loadMoreCount {
|
||||||
@@ -81,6 +241,22 @@
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.loadMoreStats {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: $spacing-md;
|
||||||
|
|
||||||
|
@include mobile {
|
||||||
|
width: 100%;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: $spacing-sm;
|
||||||
|
|
||||||
|
> span {
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.logList {
|
.logList {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -93,7 +269,9 @@
|
|||||||
padding: 10px 12px;
|
padding: 10px 12px;
|
||||||
border-bottom: 1px solid var(--border-color);
|
border-bottom: 1px solid var(--border-color);
|
||||||
border-left: 3px solid transparent;
|
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;
|
monospace;
|
||||||
font-size: 12.5px;
|
font-size: 12.5px;
|
||||||
line-height: 1.45;
|
line-height: 1.45;
|
||||||
@@ -103,9 +281,18 @@
|
|||||||
background: rgba(59, 130, 246, 0.06);
|
background: rgba(59, 130, 246, 0.06);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@include tablet {
|
||||||
|
grid-template-columns: 140px 1fr;
|
||||||
|
gap: $spacing-sm;
|
||||||
|
padding: 8px 10px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
@include mobile {
|
@include mobile {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
gap: $spacing-xs;
|
gap: $spacing-xs;
|
||||||
|
padding: 8px 10px;
|
||||||
|
font-size: 11.5px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -129,15 +316,8 @@
|
|||||||
|
|
||||||
.rowMain {
|
.rowMain {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
|
||||||
gap: 4px;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.rowMeta {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
|
align-items: baseline;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
@@ -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 {
|
.statusBadge {
|
||||||
font-variant-numeric: tabular-nums;
|
font-variant-numeric: tabular-nums;
|
||||||
}
|
}
|
||||||
@@ -245,11 +434,101 @@
|
|||||||
|
|
||||||
@include mobile {
|
@include mobile {
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
|
flex-basis: 100%;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.message {
|
.message {
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
white-space: pre-wrap;
|
|
||||||
word-break: break-word;
|
word-break: break-word;
|
||||||
|
|
||||||
|
@include mobile {
|
||||||
|
flex-basis: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-height: 820px) {
|
||||||
|
.pageTitle {
|
||||||
|
font-size: 24px;
|
||||||
|
margin-bottom: $spacing-md;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tabBar {
|
||||||
|
margin-bottom: $spacing-md;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tabItem {
|
||||||
|
padding: 10px 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content {
|
||||||
|
gap: $spacing-md;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logCard {
|
||||||
|
padding: $spacing-md;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logPanel {
|
||||||
|
min-height: 200px;
|
||||||
|
max-height: calc(100vh - 280px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.logRow {
|
||||||
|
padding: 8px 10px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.errorPanel {
|
||||||
|
height: 360px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-height: 600px) {
|
||||||
|
.pageTitle {
|
||||||
|
font-size: 20px;
|
||||||
|
margin-bottom: $spacing-sm;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tabBar {
|
||||||
|
margin-bottom: $spacing-sm;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tabItem {
|
||||||
|
padding: 8px 12px;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content {
|
||||||
|
gap: $spacing-sm;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filters {
|
||||||
|
margin-bottom: $spacing-sm;
|
||||||
|
gap: $spacing-sm;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logCard {
|
||||||
|
padding: $spacing-sm;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logPanel {
|
||||||
|
min-height: 160px;
|
||||||
|
max-height: calc(100vh - 220px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.logRow {
|
||||||
|
padding: 6px 8px;
|
||||||
|
font-size: 11px;
|
||||||
|
grid-template-columns: 130px 1fr;
|
||||||
|
gap: $spacing-xs;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loadMoreBanner {
|
||||||
|
padding: 6px 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.errorPanel {
|
||||||
|
height: 280px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,25 @@
|
|||||||
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
import { useDeferredValue, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
import type { PointerEvent as ReactPointerEvent } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Card } from '@/components/ui/Card';
|
import { Card } from '@/components/ui/Card';
|
||||||
import { Button } from '@/components/ui/Button';
|
import { Button } from '@/components/ui/Button';
|
||||||
import { EmptyState } from '@/components/ui/EmptyState';
|
import { EmptyState } from '@/components/ui/EmptyState';
|
||||||
|
import { Input } from '@/components/ui/Input';
|
||||||
|
import { Modal } from '@/components/ui/Modal';
|
||||||
import { ToggleSwitch } from '@/components/ui/ToggleSwitch';
|
import { ToggleSwitch } from '@/components/ui/ToggleSwitch';
|
||||||
import { IconDownload, IconRefreshCw, IconTimer, IconTrash2 } from '@/components/ui/icons';
|
import {
|
||||||
import { useNotificationStore, useAuthStore } from '@/stores';
|
IconDownload,
|
||||||
|
IconEyeOff,
|
||||||
|
IconRefreshCw,
|
||||||
|
IconSearch,
|
||||||
|
IconTimer,
|
||||||
|
IconTrash2,
|
||||||
|
IconX,
|
||||||
|
} from '@/components/ui/icons';
|
||||||
|
import { useHeaderRefresh } from '@/hooks/useHeaderRefresh';
|
||||||
|
import { useAuthStore, useConfigStore, useNotificationStore } from '@/stores';
|
||||||
import { logsApi } from '@/services/api/logs';
|
import { logsApi } from '@/services/api/logs';
|
||||||
|
import { MANAGEMENT_API_PREFIX } from '@/utils/constants';
|
||||||
import { formatUnixTimestamp } from '@/utils/format';
|
import { formatUnixTimestamp } from '@/utils/format';
|
||||||
import styles from './LogsPage.module.scss';
|
import styles from './LogsPage.module.scss';
|
||||||
|
|
||||||
@@ -28,25 +41,31 @@ const INITIAL_DISPLAY_LINES = 100;
|
|||||||
const LOAD_MORE_LINES = 200;
|
const LOAD_MORE_LINES = 200;
|
||||||
const MAX_BUFFER_LINES = 10000;
|
const MAX_BUFFER_LINES = 10000;
|
||||||
const LOAD_MORE_THRESHOLD_PX = 72;
|
const LOAD_MORE_THRESHOLD_PX = 72;
|
||||||
|
const LONG_PRESS_MS = 650;
|
||||||
|
const LONG_PRESS_MOVE_THRESHOLD = 10;
|
||||||
|
|
||||||
const HTTP_METHODS = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS', 'HEAD'] as const;
|
const HTTP_METHODS = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS', 'HEAD'] as const;
|
||||||
type HttpMethod = (typeof HTTP_METHODS)[number];
|
type HttpMethod = (typeof HTTP_METHODS)[number];
|
||||||
const HTTP_METHOD_REGEX = new RegExp(`\\b(${HTTP_METHODS.join('|')})\\b`);
|
const HTTP_METHOD_REGEX = new RegExp(`\\b(${HTTP_METHODS.join('|')})\\b`);
|
||||||
|
|
||||||
const LOG_TIMESTAMP_REGEX = /^\[?(\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}:\d{2}(?:\.\d{1,3})?)\]?/;
|
const LOG_TIMESTAMP_REGEX = /^\[?(\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}:\d{2}(?:\.\d{1,3})?)\]?/;
|
||||||
const LOG_LEVEL_REGEX = /^\[?(trace|debug|info|warn|warning|error|fatal)\]?(?=\s|\[|$)\s*/i;
|
const LOG_LEVEL_REGEX = /^\[?(trace|debug|info|warn|warning|error|fatal)\s*\]?(?=\s|\[|$)\s*/i;
|
||||||
const LOG_SOURCE_REGEX = /^\[([^\]]+)\]/;
|
const LOG_SOURCE_REGEX = /^\[([^\]]+)\]/;
|
||||||
const LOG_LATENCY_REGEX = /\b(\d+(?:\.\d+)?)(?:\s*)(µs|us|ms|s)\b/i;
|
const LOG_LATENCY_REGEX =
|
||||||
|
/\b(?:\d+(?:\.\d+)?\s*(?:µs|us|ms|s|m))(?:\s*\d+(?:\.\d+)?\s*(?:µs|us|ms|s|m))*\b/i;
|
||||||
const LOG_IPV4_REGEX = /\b(?:\d{1,3}\.){3}\d{1,3}\b/;
|
const LOG_IPV4_REGEX = /\b(?:\d{1,3}\.){3}\d{1,3}\b/;
|
||||||
const LOG_IPV6_REGEX = /\b(?:[a-f0-9]{0,4}:){2,7}[a-f0-9]{0,4}\b/i;
|
const LOG_IPV6_REGEX = /\b(?:[a-f0-9]{0,4}:){2,7}[a-f0-9]{0,4}\b/i;
|
||||||
|
const LOG_REQUEST_ID_REGEX = /^([a-f0-9]{8}|--------)$/i;
|
||||||
const LOG_TIME_OF_DAY_REGEX = /^\d{1,2}:\d{2}:\d{2}(?:\.\d{1,3})?$/;
|
const LOG_TIME_OF_DAY_REGEX = /^\d{1,2}:\d{2}:\d{2}(?:\.\d{1,3})?$/;
|
||||||
|
const GIN_TIMESTAMP_SEGMENT_REGEX =
|
||||||
|
/^\[GIN\]\s+(\d{4})\/(\d{2})\/(\d{2})\s*-\s*(\d{2}:\d{2}:\d{2}(?:\.\d{1,3})?)\s*$/;
|
||||||
|
|
||||||
const HTTP_STATUS_PATTERNS: RegExp[] = [
|
const HTTP_STATUS_PATTERNS: RegExp[] = [
|
||||||
/\|\s*([1-5]\d{2})\s*\|/,
|
/\|\s*([1-5]\d{2})\s*\|/,
|
||||||
/\b([1-5]\d{2})\s*-/,
|
/\b([1-5]\d{2})\s*-/,
|
||||||
new RegExp(`\\b(?:${HTTP_METHODS.join('|')})\\s+\\S+\\s+([1-5]\\d{2})\\b`),
|
new RegExp(`\\b(?:${HTTP_METHODS.join('|')})\\s+\\S+\\s+([1-5]\\d{2})\\b`),
|
||||||
/\b(?:status|code|http)[:\s]+([1-5]\d{2})\b/i,
|
/\b(?:status|code|http)[:\s]+([1-5]\d{2})\b/i,
|
||||||
/\b([1-5]\d{2})\s+(?:OK|Created|Accepted|No Content|Moved|Found|Bad Request|Unauthorized|Forbidden|Not Found|Method Not Allowed|Internal Server Error|Bad Gateway|Service Unavailable|Gateway Timeout)\b/i
|
/\b([1-5]\d{2})\s+(?:OK|Created|Accepted|No Content|Moved|Found|Bad Request|Unauthorized|Forbidden|Not Found|Method Not Allowed|Internal Server Error|Bad Gateway|Service Unavailable|Gateway Timeout)\b/i,
|
||||||
];
|
];
|
||||||
|
|
||||||
const detectHttpStatusCode = (text: string): number | undefined => {
|
const detectHttpStatusCode = (text: string): number | undefined => {
|
||||||
@@ -78,11 +97,25 @@ const extractIp = (text: string): string | undefined => {
|
|||||||
return candidate;
|
return candidate;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const normalizeTimestampToSeconds = (value: string): string => {
|
||||||
|
const trimmed = value.trim();
|
||||||
|
const match = trimmed.match(/^(\d{4}-\d{2}-\d{2})[ T](\d{2}:\d{2}:\d{2})/);
|
||||||
|
if (!match) return trimmed;
|
||||||
|
return `${match[1]} ${match[2]}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const extractLatency = (text: string): string | undefined => {
|
||||||
|
const match = text.match(LOG_LATENCY_REGEX);
|
||||||
|
if (!match) return undefined;
|
||||||
|
return match[0].replace(/\s+/g, '');
|
||||||
|
};
|
||||||
|
|
||||||
type ParsedLogLine = {
|
type ParsedLogLine = {
|
||||||
raw: string;
|
raw: string;
|
||||||
timestamp?: string;
|
timestamp?: string;
|
||||||
level?: LogLevel;
|
level?: LogLevel;
|
||||||
source?: string;
|
source?: string;
|
||||||
|
requestId?: string;
|
||||||
statusCode?: number;
|
statusCode?: number;
|
||||||
latency?: string;
|
latency?: string;
|
||||||
ip?: string;
|
ip?: string;
|
||||||
@@ -135,6 +168,16 @@ const parseLogLine = (raw: string): ParsedLogLine => {
|
|||||||
remaining = remaining.slice(tsMatch[0].length).trim();
|
remaining = remaining.slice(tsMatch[0].length).trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let requestId: string | undefined;
|
||||||
|
const requestIdMatch = remaining.match(/^\[([a-f0-9]{8}|--------)\]\s*/i);
|
||||||
|
if (requestIdMatch) {
|
||||||
|
const id = requestIdMatch[1];
|
||||||
|
if (!/^-+$/.test(id)) {
|
||||||
|
requestId = id;
|
||||||
|
}
|
||||||
|
remaining = remaining.slice(requestIdMatch[0].length).trim();
|
||||||
|
}
|
||||||
|
|
||||||
let level: LogLevel | undefined;
|
let level: LogLevel | undefined;
|
||||||
const lvlMatch = remaining.match(LOG_LEVEL_REGEX);
|
const lvlMatch = remaining.match(LOG_LEVEL_REGEX);
|
||||||
if (lvlMatch) {
|
if (lvlMatch) {
|
||||||
@@ -163,10 +206,40 @@ const parseLogLine = (raw: string): ParsedLogLine => {
|
|||||||
.filter(Boolean);
|
.filter(Boolean);
|
||||||
const consumed = new Set<number>();
|
const consumed = new Set<number>();
|
||||||
|
|
||||||
|
const ginIndex = segments.findIndex((segment) => GIN_TIMESTAMP_SEGMENT_REGEX.test(segment));
|
||||||
|
if (ginIndex >= 0) {
|
||||||
|
const match = segments[ginIndex].match(GIN_TIMESTAMP_SEGMENT_REGEX);
|
||||||
|
if (match) {
|
||||||
|
const ginTimestamp = `${match[1]}-${match[2]}-${match[3]} ${match[4]}`;
|
||||||
|
const normalizedGin = normalizeTimestampToSeconds(ginTimestamp);
|
||||||
|
const normalizedParsed = timestamp ? normalizeTimestampToSeconds(timestamp) : undefined;
|
||||||
|
|
||||||
|
if (!timestamp) {
|
||||||
|
timestamp = ginTimestamp;
|
||||||
|
consumed.add(ginIndex);
|
||||||
|
} else if (normalizedParsed === normalizedGin) {
|
||||||
|
consumed.add(ginIndex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// request id (8-char hex or dashes)
|
||||||
|
const requestIdIndex = segments.findIndex((segment) => LOG_REQUEST_ID_REGEX.test(segment));
|
||||||
|
if (requestIdIndex >= 0) {
|
||||||
|
const match = segments[requestIdIndex].match(LOG_REQUEST_ID_REGEX);
|
||||||
|
if (match) {
|
||||||
|
const id = match[1];
|
||||||
|
if (!/^-+$/.test(id)) {
|
||||||
|
requestId = id;
|
||||||
|
}
|
||||||
|
consumed.add(requestIdIndex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// status code
|
// status code
|
||||||
const statusIndex = segments.findIndex((segment) => /^\d{3}\b/.test(segment));
|
const statusIndex = segments.findIndex((segment) => /^\d{3}$/.test(segment));
|
||||||
if (statusIndex >= 0) {
|
if (statusIndex >= 0) {
|
||||||
const match = segments[statusIndex].match(/^(\d{3})\b/);
|
const match = segments[statusIndex].match(/^(\d{3})$/);
|
||||||
if (match) {
|
if (match) {
|
||||||
const code = Number.parseInt(match[1], 10);
|
const code = Number.parseInt(match[1], 10);
|
||||||
if (code >= 100 && code <= 599) {
|
if (code >= 100 && code <= 599) {
|
||||||
@@ -179,17 +252,15 @@ const parseLogLine = (raw: string): ParsedLogLine => {
|
|||||||
// latency
|
// latency
|
||||||
const latencyIndex = segments.findIndex((segment) => LOG_LATENCY_REGEX.test(segment));
|
const latencyIndex = segments.findIndex((segment) => LOG_LATENCY_REGEX.test(segment));
|
||||||
if (latencyIndex >= 0) {
|
if (latencyIndex >= 0) {
|
||||||
const match = segments[latencyIndex].match(LOG_LATENCY_REGEX);
|
const extracted = extractLatency(segments[latencyIndex]);
|
||||||
if (match) {
|
if (extracted) {
|
||||||
latency = `${match[1]}${match[2]}`;
|
latency = extracted;
|
||||||
consumed.add(latencyIndex);
|
consumed.add(latencyIndex);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ip
|
// ip
|
||||||
const ipIndex = segments.findIndex(
|
const ipIndex = segments.findIndex((segment) => Boolean(extractIp(segment)));
|
||||||
(segment) => Boolean(extractIp(segment))
|
|
||||||
);
|
|
||||||
if (ipIndex >= 0) {
|
if (ipIndex >= 0) {
|
||||||
const extracted = extractIp(segments[ipIndex]);
|
const extracted = extractIp(segments[ipIndex]);
|
||||||
if (extracted) {
|
if (extracted) {
|
||||||
@@ -210,12 +281,22 @@ const parseLogLine = (raw: string): ParsedLogLine => {
|
|||||||
consumed.add(methodIndex);
|
consumed.add(methodIndex);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// source (e.g. [gin_logger.go:94])
|
||||||
|
const sourceIndex = segments.findIndex((segment) => LOG_SOURCE_REGEX.test(segment));
|
||||||
|
if (sourceIndex >= 0) {
|
||||||
|
const match = segments[sourceIndex].match(LOG_SOURCE_REGEX);
|
||||||
|
if (match) {
|
||||||
|
source = match[1];
|
||||||
|
consumed.add(sourceIndex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
message = segments.filter((_, index) => !consumed.has(index)).join(' | ');
|
message = segments.filter((_, index) => !consumed.has(index)).join(' | ');
|
||||||
} else {
|
} else {
|
||||||
statusCode = detectHttpStatusCode(remaining);
|
statusCode = detectHttpStatusCode(remaining);
|
||||||
|
|
||||||
const latencyMatch = remaining.match(LOG_LATENCY_REGEX);
|
const extracted = extractLatency(remaining);
|
||||||
if (latencyMatch) latency = `${latencyMatch[1]}${latencyMatch[2]}`;
|
if (extracted) latency = extracted;
|
||||||
|
|
||||||
ip = extractIp(remaining);
|
ip = extractIp(remaining);
|
||||||
|
|
||||||
@@ -226,35 +307,97 @@ const parseLogLine = (raw: string): ParsedLogLine => {
|
|||||||
|
|
||||||
if (!level) level = inferLogLevel(raw);
|
if (!level) level = inferLogLevel(raw);
|
||||||
|
|
||||||
|
if (message) {
|
||||||
|
const match = message.match(GIN_TIMESTAMP_SEGMENT_REGEX);
|
||||||
|
if (match) {
|
||||||
|
const ginTimestamp = `${match[1]}-${match[2]}-${match[3]} ${match[4]}`;
|
||||||
|
if (!timestamp) timestamp = ginTimestamp;
|
||||||
|
if (normalizeTimestampToSeconds(timestamp) === normalizeTimestampToSeconds(ginTimestamp)) {
|
||||||
|
message = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
raw,
|
raw,
|
||||||
timestamp,
|
timestamp,
|
||||||
level,
|
level,
|
||||||
source,
|
source,
|
||||||
|
requestId,
|
||||||
statusCode,
|
statusCode,
|
||||||
latency,
|
latency,
|
||||||
ip,
|
ip,
|
||||||
method,
|
method,
|
||||||
path,
|
path,
|
||||||
message
|
message,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const getErrorMessage = (err: unknown): string => {
|
||||||
|
if (err instanceof Error) return err.message;
|
||||||
|
if (typeof err === 'string') return err;
|
||||||
|
if (typeof err !== 'object' || err === null) return '';
|
||||||
|
if (!('message' in err)) return '';
|
||||||
|
|
||||||
|
const message = (err as { message?: unknown }).message;
|
||||||
|
return typeof message === 'string' ? message : '';
|
||||||
|
};
|
||||||
|
|
||||||
|
const copyToClipboard = async (text: string) => {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(text);
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
try {
|
||||||
|
const textarea = document.createElement('textarea');
|
||||||
|
textarea.value = text;
|
||||||
|
textarea.style.position = 'fixed';
|
||||||
|
textarea.style.opacity = '0';
|
||||||
|
textarea.style.left = '-9999px';
|
||||||
|
textarea.style.top = '0';
|
||||||
|
document.body.appendChild(textarea);
|
||||||
|
textarea.focus();
|
||||||
|
textarea.select();
|
||||||
|
const ok = document.execCommand('copy');
|
||||||
|
document.body.removeChild(textarea);
|
||||||
|
return ok;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
type TabType = 'logs' | 'errors';
|
||||||
|
|
||||||
export function LogsPage() {
|
export function LogsPage() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { showNotification } = useNotificationStore();
|
const { showNotification } = useNotificationStore();
|
||||||
const connectionStatus = useAuthStore((state) => state.connectionStatus);
|
const connectionStatus = useAuthStore((state) => state.connectionStatus);
|
||||||
|
const requestLogEnabled = useConfigStore((state) => state.config?.requestLog ?? false);
|
||||||
|
|
||||||
|
const [activeTab, setActiveTab] = useState<TabType>('logs');
|
||||||
const [logState, setLogState] = useState<LogState>({ buffer: [], visibleFrom: 0 });
|
const [logState, setLogState] = useState<LogState>({ buffer: [], visibleFrom: 0 });
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
const [autoRefresh, setAutoRefresh] = useState(false);
|
const [autoRefresh, setAutoRefresh] = useState(false);
|
||||||
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
|
const deferredSearchQuery = useDeferredValue(searchQuery);
|
||||||
|
const [hideManagementLogs, setHideManagementLogs] = useState(true);
|
||||||
const [errorLogs, setErrorLogs] = useState<ErrorLogItem[]>([]);
|
const [errorLogs, setErrorLogs] = useState<ErrorLogItem[]>([]);
|
||||||
const [loadingErrors, setLoadingErrors] = useState(false);
|
const [loadingErrors, setLoadingErrors] = useState(false);
|
||||||
|
const [errorLogsError, setErrorLogsError] = useState('');
|
||||||
|
const [requestLogId, setRequestLogId] = useState<string | null>(null);
|
||||||
|
const [requestLogDownloading, setRequestLogDownloading] = useState(false);
|
||||||
|
|
||||||
const logViewerRef = useRef<HTMLDivElement | null>(null);
|
const logViewerRef = useRef<HTMLDivElement | null>(null);
|
||||||
const pendingScrollToBottomRef = useRef(false);
|
const pendingScrollToBottomRef = useRef(false);
|
||||||
const pendingPrependScrollRef = useRef<{ scrollHeight: number; scrollTop: number } | null>(null);
|
const pendingPrependScrollRef = useRef<{ scrollHeight: number; scrollTop: number } | null>(null);
|
||||||
|
const longPressRef = useRef<{
|
||||||
|
timer: number | null;
|
||||||
|
startX: number;
|
||||||
|
startY: number;
|
||||||
|
fired: boolean;
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
// 保存最新时间戳用于增量获取
|
// 保存最新时间戳用于增量获取
|
||||||
const latestTimestampRef = useRef<number>(0);
|
const latestTimestampRef = useRef<number>(0);
|
||||||
@@ -287,9 +430,8 @@ export function LogsPage() {
|
|||||||
try {
|
try {
|
||||||
pendingScrollToBottomRef.current = !incremental || isNearBottom(logViewerRef.current);
|
pendingScrollToBottomRef.current = !incremental || isNearBottom(logViewerRef.current);
|
||||||
|
|
||||||
const params = incremental && latestTimestampRef.current > 0
|
const params =
|
||||||
? { after: latestTimestampRef.current }
|
incremental && latestTimestampRef.current > 0 ? { after: latestTimestampRef.current } : {};
|
||||||
: {};
|
|
||||||
const data = await logsApi.fetchLogs(params);
|
const data = await logsApi.fetchLogs(params);
|
||||||
|
|
||||||
// 更新时间戳
|
// 更新时间戳
|
||||||
@@ -321,10 +463,10 @@ export function LogsPage() {
|
|||||||
const visibleFrom = Math.max(buffer.length - INITIAL_DISPLAY_LINES, 0);
|
const visibleFrom = Math.max(buffer.length - INITIAL_DISPLAY_LINES, 0);
|
||||||
setLogState({ buffer, visibleFrom });
|
setLogState({ buffer, visibleFrom });
|
||||||
}
|
}
|
||||||
} catch (err: any) {
|
} catch (err: unknown) {
|
||||||
console.error('Failed to load logs:', err);
|
console.error('Failed to load logs:', err);
|
||||||
if (!incremental) {
|
if (!incremental) {
|
||||||
setError(err?.message || t('logs.load_error'));
|
setError(getErrorMessage(err) || t('logs.load_error'));
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
if (!incremental) {
|
if (!incremental) {
|
||||||
@@ -333,6 +475,8 @@ export function LogsPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
useHeaderRefresh(() => loadLogs(false));
|
||||||
|
|
||||||
const clearLogs = async () => {
|
const clearLogs = async () => {
|
||||||
if (!window.confirm(t('logs.clear_confirm'))) return;
|
if (!window.confirm(t('logs.clear_confirm'))) return;
|
||||||
try {
|
try {
|
||||||
@@ -340,8 +484,12 @@ export function LogsPage() {
|
|||||||
setLogState({ buffer: [], visibleFrom: 0 });
|
setLogState({ buffer: [], visibleFrom: 0 });
|
||||||
latestTimestampRef.current = 0;
|
latestTimestampRef.current = 0;
|
||||||
showNotification(t('logs.clear_success'), 'success');
|
showNotification(t('logs.clear_success'), 'success');
|
||||||
} catch (err: any) {
|
} catch (err: unknown) {
|
||||||
showNotification(`${t('notification.delete_failed')}: ${err?.message || ''}`, 'error');
|
const message = getErrorMessage(err);
|
||||||
|
showNotification(
|
||||||
|
`${t('notification.delete_failed')}${message ? `: ${message}` : ''}`,
|
||||||
|
'error'
|
||||||
|
);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -364,22 +512,18 @@ export function LogsPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
setLoadingErrors(true);
|
setLoadingErrors(true);
|
||||||
|
setErrorLogsError('');
|
||||||
try {
|
try {
|
||||||
const res = await logsApi.fetchErrorLogs();
|
const res = await logsApi.fetchErrorLogs();
|
||||||
// API 返回 { files: [...] }
|
// API 返回 { files: [...] }
|
||||||
const files = (res as any)?.files;
|
setErrorLogs(Array.isArray(res.files) ? res.files : []);
|
||||||
const list: ErrorLogItem[] = Array.isArray(files)
|
} catch (err: unknown) {
|
||||||
? files.map((f: any) => ({
|
|
||||||
name: f.name,
|
|
||||||
size: f.size,
|
|
||||||
modified: f.modified
|
|
||||||
}))
|
|
||||||
: [];
|
|
||||||
setErrorLogs(list);
|
|
||||||
} catch (err: any) {
|
|
||||||
console.error('Failed to load error logs:', err);
|
console.error('Failed to load error logs:', err);
|
||||||
// 静默失败,不影响主日志显示
|
|
||||||
setErrorLogs([]);
|
setErrorLogs([]);
|
||||||
|
const message = getErrorMessage(err);
|
||||||
|
setErrorLogsError(
|
||||||
|
message ? `${t('logs.error_logs_load_error')}: ${message}` : t('logs.error_logs_load_error')
|
||||||
|
);
|
||||||
} finally {
|
} finally {
|
||||||
setLoadingErrors(false);
|
setLoadingErrors(false);
|
||||||
}
|
}
|
||||||
@@ -396,8 +540,12 @@ export function LogsPage() {
|
|||||||
a.click();
|
a.click();
|
||||||
window.URL.revokeObjectURL(url);
|
window.URL.revokeObjectURL(url);
|
||||||
showNotification(t('logs.error_log_download_success'), 'success');
|
showNotification(t('logs.error_log_download_success'), 'success');
|
||||||
} catch (err: any) {
|
} catch (err: unknown) {
|
||||||
showNotification(`${t('notification.download_failed')}: ${err?.message || ''}`, 'error');
|
const message = getErrorMessage(err);
|
||||||
|
showNotification(
|
||||||
|
`${t('notification.download_failed')}${message ? `: ${message}` : ''}`,
|
||||||
|
'error'
|
||||||
|
);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -405,11 +553,17 @@ export function LogsPage() {
|
|||||||
if (connectionStatus === 'connected') {
|
if (connectionStatus === 'connected') {
|
||||||
latestTimestampRef.current = 0;
|
latestTimestampRef.current = 0;
|
||||||
loadLogs(false);
|
loadLogs(false);
|
||||||
loadErrorLogs();
|
|
||||||
}
|
}
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [connectionStatus]);
|
}, [connectionStatus]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (activeTab !== 'errors') return;
|
||||||
|
if (connectionStatus !== 'connected') return;
|
||||||
|
void loadErrorLogs();
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [activeTab, connectionStatus, requestLogEnabled]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!autoRefresh || connectionStatus !== 'connected') {
|
if (!autoRefresh || connectionStatus !== 'connected') {
|
||||||
return;
|
return;
|
||||||
@@ -434,23 +588,65 @@ export function LogsPage() {
|
|||||||
() => logState.buffer.slice(logState.visibleFrom),
|
() => logState.buffer.slice(logState.visibleFrom),
|
||||||
[logState.buffer, logState.visibleFrom]
|
[logState.buffer, logState.visibleFrom]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const trimmedSearchQuery = deferredSearchQuery.trim();
|
||||||
|
const isSearching = trimmedSearchQuery.length > 0;
|
||||||
|
const baseLines = isSearching ? logState.buffer : visibleLines;
|
||||||
|
|
||||||
|
const { filteredLines, removedCount } = useMemo(() => {
|
||||||
|
let working = baseLines;
|
||||||
|
let removed = 0;
|
||||||
|
|
||||||
|
if (hideManagementLogs) {
|
||||||
|
const next: string[] = [];
|
||||||
|
for (const line of working) {
|
||||||
|
if (line.includes(MANAGEMENT_API_PREFIX)) {
|
||||||
|
removed += 1;
|
||||||
|
} else {
|
||||||
|
next.push(line);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
working = next;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (trimmedSearchQuery) {
|
||||||
|
const queryLowered = trimmedSearchQuery.toLowerCase();
|
||||||
|
const next: string[] = [];
|
||||||
|
for (const line of working) {
|
||||||
|
if (line.toLowerCase().includes(queryLowered)) {
|
||||||
|
next.push(line);
|
||||||
|
} else {
|
||||||
|
removed += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
working = next;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { filteredLines: working, removedCount: removed };
|
||||||
|
}, [baseLines, hideManagementLogs, trimmedSearchQuery]);
|
||||||
|
|
||||||
const parsedVisibleLines = useMemo(
|
const parsedVisibleLines = useMemo(
|
||||||
() => visibleLines.map((line) => parseLogLine(line)),
|
() => filteredLines.map((line) => parseLogLine(line)),
|
||||||
[visibleLines]
|
[filteredLines]
|
||||||
);
|
);
|
||||||
const canLoadMore = logState.visibleFrom > 0;
|
|
||||||
|
const canLoadMore = !isSearching && logState.visibleFrom > 0;
|
||||||
|
|
||||||
const handleLogScroll = () => {
|
const handleLogScroll = () => {
|
||||||
const node = logViewerRef.current;
|
const node = logViewerRef.current;
|
||||||
if (!node) return;
|
if (!node) return;
|
||||||
|
if (isSearching) return;
|
||||||
if (!canLoadMore) return;
|
if (!canLoadMore) return;
|
||||||
if (pendingPrependScrollRef.current) return;
|
if (pendingPrependScrollRef.current) return;
|
||||||
if (node.scrollTop > LOAD_MORE_THRESHOLD_PX) return;
|
if (node.scrollTop > LOAD_MORE_THRESHOLD_PX) return;
|
||||||
|
|
||||||
pendingPrependScrollRef.current = { scrollHeight: node.scrollHeight, scrollTop: node.scrollTop };
|
pendingPrependScrollRef.current = {
|
||||||
|
scrollHeight: node.scrollHeight,
|
||||||
|
scrollTop: node.scrollTop,
|
||||||
|
};
|
||||||
setLogState((prev) => ({
|
setLogState((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
visibleFrom: Math.max(prev.visibleFrom - LOAD_MORE_LINES, 0)
|
visibleFrom: Math.max(prev.visibleFrom - LOAD_MORE_LINES, 0),
|
||||||
}));
|
}));
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -464,13 +660,156 @@ export function LogsPage() {
|
|||||||
pendingPrependScrollRef.current = null;
|
pendingPrependScrollRef.current = null;
|
||||||
}, [logState.visibleFrom]);
|
}, [logState.visibleFrom]);
|
||||||
|
|
||||||
|
const copyLogLine = async (raw: string) => {
|
||||||
|
const ok = await copyToClipboard(raw);
|
||||||
|
if (ok) {
|
||||||
|
showNotification(t('logs.copy_success', { defaultValue: 'Copied to clipboard' }), 'success');
|
||||||
|
} else {
|
||||||
|
showNotification(t('logs.copy_failed', { defaultValue: 'Copy failed' }), 'error');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const clearLongPressTimer = () => {
|
||||||
|
if (longPressRef.current?.timer) {
|
||||||
|
window.clearTimeout(longPressRef.current.timer);
|
||||||
|
longPressRef.current.timer = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const startLongPress = (event: ReactPointerEvent<HTMLDivElement>, id?: string) => {
|
||||||
|
if (!requestLogEnabled) return;
|
||||||
|
if (!id) return;
|
||||||
|
if (requestLogId) return;
|
||||||
|
clearLongPressTimer();
|
||||||
|
longPressRef.current = {
|
||||||
|
timer: window.setTimeout(() => {
|
||||||
|
setRequestLogId(id);
|
||||||
|
if (longPressRef.current) {
|
||||||
|
longPressRef.current.fired = true;
|
||||||
|
longPressRef.current.timer = null;
|
||||||
|
}
|
||||||
|
}, LONG_PRESS_MS),
|
||||||
|
startX: event.clientX,
|
||||||
|
startY: event.clientY,
|
||||||
|
fired: false,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const cancelLongPress = () => {
|
||||||
|
clearLongPressTimer();
|
||||||
|
longPressRef.current = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleLongPressMove = (event: ReactPointerEvent<HTMLDivElement>) => {
|
||||||
|
const current = longPressRef.current;
|
||||||
|
if (!current || current.timer === null || current.fired) return;
|
||||||
|
const deltaX = Math.abs(event.clientX - current.startX);
|
||||||
|
const deltaY = Math.abs(event.clientY - current.startY);
|
||||||
|
if (deltaX > LONG_PRESS_MOVE_THRESHOLD || deltaY > LONG_PRESS_MOVE_THRESHOLD) {
|
||||||
|
cancelLongPress();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeRequestLogModal = () => {
|
||||||
|
if (requestLogDownloading) return;
|
||||||
|
setRequestLogId(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const downloadRequestLog = async (id: string) => {
|
||||||
|
setRequestLogDownloading(true);
|
||||||
|
try {
|
||||||
|
const response = await logsApi.downloadRequestLogById(id);
|
||||||
|
const blob = new Blob([response.data], { type: 'text/plain' });
|
||||||
|
const url = window.URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = `request-${id}.log`;
|
||||||
|
a.click();
|
||||||
|
window.URL.revokeObjectURL(url);
|
||||||
|
showNotification(t('logs.request_log_download_success'), 'success');
|
||||||
|
setRequestLogId(null);
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const message = getErrorMessage(err);
|
||||||
|
showNotification(
|
||||||
|
`${t('notification.download_failed')}${message ? `: ${message}` : ''}`,
|
||||||
|
'error'
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setRequestLogDownloading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (longPressRef.current?.timer) {
|
||||||
|
window.clearTimeout(longPressRef.current.timer);
|
||||||
|
longPressRef.current.timer = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.container}>
|
<div className={styles.container}>
|
||||||
<h1 className={styles.pageTitle}>{t('logs.title')}</h1>
|
<h1 className={styles.pageTitle}>{t('logs.title')}</h1>
|
||||||
|
|
||||||
|
<div className={styles.tabBar}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`${styles.tabItem} ${activeTab === 'logs' ? styles.tabActive : ''}`}
|
||||||
|
onClick={() => setActiveTab('logs')}
|
||||||
|
>
|
||||||
|
{t('logs.log_content')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`${styles.tabItem} ${activeTab === 'errors' ? styles.tabActive : ''}`}
|
||||||
|
onClick={() => setActiveTab('errors')}
|
||||||
|
>
|
||||||
|
{t('logs.error_logs_modal_title')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className={styles.content}>
|
<div className={styles.content}>
|
||||||
<Card
|
{activeTab === 'logs' && (
|
||||||
title={t('logs.log_content')}
|
<Card className={styles.logCard}>
|
||||||
extra={
|
{error && <div className="error-box">{error}</div>}
|
||||||
|
|
||||||
|
<div className={styles.filters}>
|
||||||
|
<div className={styles.searchWrapper}>
|
||||||
|
<Input
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
|
placeholder={t('logs.search_placeholder')}
|
||||||
|
className={styles.searchInput}
|
||||||
|
rightElement={
|
||||||
|
searchQuery ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.searchClear}
|
||||||
|
onClick={() => setSearchQuery('')}
|
||||||
|
title="Clear"
|
||||||
|
aria-label="Clear"
|
||||||
|
>
|
||||||
|
<IconX size={16} />
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<IconSearch size={16} className={styles.searchIcon} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ToggleSwitch
|
||||||
|
checked={hideManagementLogs}
|
||||||
|
onChange={setHideManagementLogs}
|
||||||
|
label={
|
||||||
|
<span className={styles.switchLabel}>
|
||||||
|
<IconEyeOff size={16} />
|
||||||
|
{t('logs.hide_management_logs', { prefix: MANAGEMENT_API_PREFIX })}
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
<div className={styles.toolbar}>
|
<div className={styles.toolbar}>
|
||||||
<Button
|
<Button
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
@@ -520,40 +859,65 @@ export function LogsPage() {
|
|||||||
</span>
|
</span>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
}
|
</div>
|
||||||
>
|
|
||||||
{error && <div className="error-box">{error}</div>}
|
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="hint">{t('logs.loading')}</div>
|
<div className="hint">{t('logs.loading')}</div>
|
||||||
) : logState.buffer.length > 0 ? (
|
) : logState.buffer.length > 0 && parsedVisibleLines.length > 0 ? (
|
||||||
<div ref={logViewerRef} className={styles.logPanel} onScroll={handleLogScroll}>
|
<div ref={logViewerRef} className={styles.logPanel} onScroll={handleLogScroll}>
|
||||||
{canLoadMore && (
|
{canLoadMore && (
|
||||||
<div className={styles.loadMoreBanner}>
|
<div className={styles.loadMoreBanner}>
|
||||||
<span>{t('logs.load_more_hint')}</span>
|
<span>{t('logs.load_more_hint')}</span>
|
||||||
|
<div className={styles.loadMoreStats}>
|
||||||
|
<span>
|
||||||
|
{t('logs.loaded_lines', { count: parsedVisibleLines.length })}
|
||||||
|
</span>
|
||||||
|
{removedCount > 0 && (
|
||||||
|
<span className={styles.loadMoreCount}>
|
||||||
|
{t('logs.filtered_lines', { count: removedCount })}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
<span className={styles.loadMoreCount}>
|
<span className={styles.loadMoreCount}>
|
||||||
{t('logs.hidden_lines', { count: logState.visibleFrom })}
|
{t('logs.hidden_lines', { count: logState.visibleFrom })}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className={styles.logList}>
|
<div className={styles.logList}>
|
||||||
{parsedVisibleLines.map((line, index) => {
|
{parsedVisibleLines.map((line, index) => {
|
||||||
const rowClassNames = [styles.logRow];
|
const rowClassNames = [styles.logRow];
|
||||||
if (line.level === 'warn') rowClassNames.push(styles.rowWarn);
|
if (line.level === 'warn') rowClassNames.push(styles.rowWarn);
|
||||||
if (line.level === 'error' || line.level === 'fatal') rowClassNames.push(styles.rowError);
|
if (line.level === 'error' || line.level === 'fatal')
|
||||||
|
rowClassNames.push(styles.rowError);
|
||||||
return (
|
return (
|
||||||
<div key={`${logState.visibleFrom + index}-${line.raw}`} className={rowClassNames.join(' ')}>
|
<div
|
||||||
|
key={`${logState.visibleFrom + index}-${line.raw}`}
|
||||||
|
className={rowClassNames.join(' ')}
|
||||||
|
onDoubleClick={() => {
|
||||||
|
void copyLogLine(line.raw);
|
||||||
|
}}
|
||||||
|
onPointerDown={(event) => startLongPress(event, line.requestId)}
|
||||||
|
onPointerUp={cancelLongPress}
|
||||||
|
onPointerLeave={cancelLongPress}
|
||||||
|
onPointerCancel={cancelLongPress}
|
||||||
|
onPointerMove={handleLongPressMove}
|
||||||
|
title={t('logs.double_click_copy_hint', {
|
||||||
|
defaultValue: 'Double-click to copy',
|
||||||
|
})}
|
||||||
|
>
|
||||||
<div className={styles.timestamp}>{line.timestamp || ''}</div>
|
<div className={styles.timestamp}>{line.timestamp || ''}</div>
|
||||||
<div className={styles.rowMain}>
|
<div className={styles.rowMain}>
|
||||||
<div className={styles.rowMeta}>
|
|
||||||
{line.level && (
|
{line.level && (
|
||||||
<span
|
<span
|
||||||
className={[
|
className={[
|
||||||
styles.badge,
|
styles.badge,
|
||||||
line.level === 'info' ? styles.levelInfo : '',
|
line.level === 'info' ? styles.levelInfo : '',
|
||||||
line.level === 'warn' ? styles.levelWarn : '',
|
line.level === 'warn' ? styles.levelWarn : '',
|
||||||
line.level === 'error' || line.level === 'fatal' ? styles.levelError : '',
|
line.level === 'error' || line.level === 'fatal'
|
||||||
|
? styles.levelError
|
||||||
|
: '',
|
||||||
line.level === 'debug' ? styles.levelDebug : '',
|
line.level === 'debug' ? styles.levelDebug : '',
|
||||||
line.level === 'trace' ? styles.levelTrace : ''
|
line.level === 'trace' ? styles.levelTrace : '',
|
||||||
]
|
]
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.join(' ')}
|
.join(' ')}
|
||||||
@@ -568,6 +932,15 @@ export function LogsPage() {
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{line.requestId && (
|
||||||
|
<span
|
||||||
|
className={[styles.badge, styles.requestIdBadge].join(' ')}
|
||||||
|
title={line.requestId}
|
||||||
|
>
|
||||||
|
{line.requestId}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
{typeof line.statusCode === 'number' && (
|
{typeof line.statusCode === 'number' && (
|
||||||
<span
|
<span
|
||||||
className={[
|
className={[
|
||||||
@@ -579,7 +952,7 @@ export function LogsPage() {
|
|||||||
? styles.statusInfo
|
? styles.statusInfo
|
||||||
: line.statusCode >= 400 && line.statusCode < 500
|
: line.statusCode >= 400 && line.statusCode < 500
|
||||||
? styles.statusWarn
|
? styles.statusWarn
|
||||||
: styles.statusError
|
: styles.statusError,
|
||||||
].join(' ')}
|
].join(' ')}
|
||||||
>
|
>
|
||||||
{line.statusCode}
|
{line.statusCode}
|
||||||
@@ -594,33 +967,60 @@ export function LogsPage() {
|
|||||||
{line.method}
|
{line.method}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{line.path && (
|
{line.path && (
|
||||||
<span className={styles.path} title={line.path}>
|
<span className={styles.path} title={line.path}>
|
||||||
{line.path}
|
{line.path}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
|
||||||
{line.message && <div className={styles.message}>{line.message}</div>}
|
{line.message && <span className={styles.message}>{line.message}</span>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
) : logState.buffer.length > 0 ? (
|
||||||
|
<EmptyState
|
||||||
|
title={t('logs.search_empty_title')}
|
||||||
|
description={t('logs.search_empty_desc')}
|
||||||
|
/>
|
||||||
) : (
|
) : (
|
||||||
<EmptyState title={t('logs.empty_title')} description={t('logs.empty_desc')} />
|
<EmptyState title={t('logs.empty_title')} description={t('logs.empty_desc')} />
|
||||||
)}
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeTab === 'errors' && (
|
||||||
<Card
|
<Card
|
||||||
title={t('logs.error_logs_modal_title')}
|
|
||||||
extra={
|
extra={
|
||||||
<Button variant="secondary" size="sm" onClick={loadErrorLogs} loading={loadingErrors}>
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
onClick={loadErrorLogs}
|
||||||
|
loading={loadingErrors}
|
||||||
|
disabled={disableControls}
|
||||||
|
>
|
||||||
{t('common.refresh')}
|
{t('common.refresh')}
|
||||||
</Button>
|
</Button>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{errorLogs.length === 0 ? (
|
<div className="stack">
|
||||||
|
<div className="hint">{t('logs.error_logs_description')}</div>
|
||||||
|
|
||||||
|
{requestLogEnabled && (
|
||||||
|
<div>
|
||||||
|
<div className="status-badge warning">{t('logs.error_logs_request_log_enabled')}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{errorLogsError && <div className="error-box">{errorLogsError}</div>}
|
||||||
|
|
||||||
|
<div className={styles.errorPanel}>
|
||||||
|
{loadingErrors ? (
|
||||||
|
<div className="hint">{t('common.loading')}</div>
|
||||||
|
) : errorLogs.length === 0 ? (
|
||||||
<div className="hint">{t('logs.error_logs_empty')}</div>
|
<div className="hint">{t('logs.error_logs_empty')}</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="item-list">
|
<div className="item-list">
|
||||||
@@ -634,7 +1034,12 @@ export function LogsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="item-actions">
|
<div className="item-actions">
|
||||||
<Button variant="secondary" size="sm" onClick={() => downloadErrorLog(item.name)}>
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => downloadErrorLog(item.name)}
|
||||||
|
disabled={disableControls}
|
||||||
|
>
|
||||||
{t('logs.error_logs_download')}
|
{t('logs.error_logs_download')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -642,8 +1047,37 @@ export function LogsPage() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</Card>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
open={Boolean(requestLogId)}
|
||||||
|
onClose={closeRequestLogModal}
|
||||||
|
title={t('logs.request_log_download_title')}
|
||||||
|
footer={
|
||||||
|
<>
|
||||||
|
<Button variant="secondary" onClick={closeRequestLogModal} disabled={requestLogDownloading}>
|
||||||
|
{t('common.cancel')}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
if (requestLogId) {
|
||||||
|
void downloadRequestLog(requestLogId);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
loading={requestLogDownloading}
|
||||||
|
disabled={!requestLogId}
|
||||||
|
>
|
||||||
|
{t('common.confirm')}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{requestLogId ? t('logs.request_log_download_confirm', { id: requestLogId }) : null}
|
||||||
|
</Modal>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,17 @@
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.cardTitle {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: $spacing-sm;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cardTitleIcon {
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
.pageTitle {
|
.pageTitle {
|
||||||
font-size: 28px;
|
font-size: 28px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
@@ -59,3 +70,76 @@
|
|||||||
color: #3b82f6;
|
color: #3b82f6;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.callbackSection {
|
||||||
|
margin-top: $spacing-md;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: $spacing-xs;
|
||||||
|
}
|
||||||
|
|
||||||
|
.callbackActions {
|
||||||
|
display: flex;
|
||||||
|
gap: $spacing-md;
|
||||||
|
}
|
||||||
|
|
||||||
|
.authUrlBox {
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border: 1px dashed var(--border-color);
|
||||||
|
border-radius: $radius-md;
|
||||||
|
padding: $spacing-md;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: $spacing-xs;
|
||||||
|
}
|
||||||
|
|
||||||
|
.authUrlLabel {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.authUrlValue {
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-primary);
|
||||||
|
word-break: break-all;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
line-height: 1.5;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.authUrlActions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: $spacing-sm;
|
||||||
|
margin-top: $spacing-sm;
|
||||||
|
}
|
||||||
|
|
||||||
|
.geminiProjectField {
|
||||||
|
:global(.form-group) {
|
||||||
|
margin-top: $spacing-sm;
|
||||||
|
gap: $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);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,12 +1,20 @@
|
|||||||
import { useEffect, useRef, useState, useMemo } from 'react';
|
import { useEffect, useRef, useState, type ChangeEvent } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Card } from '@/components/ui/Card';
|
import { Card } from '@/components/ui/Card';
|
||||||
import { Button } from '@/components/ui/Button';
|
import { Button } from '@/components/ui/Button';
|
||||||
import { Input } from '@/components/ui/Input';
|
import { Input } from '@/components/ui/Input';
|
||||||
import { useNotificationStore } from '@/stores';
|
import { useNotificationStore, useThemeStore } from '@/stores';
|
||||||
import { oauthApi, type OAuthProvider, type IFlowCookieAuthResponse } from '@/services/api/oauth';
|
import { oauthApi, type OAuthProvider, type IFlowCookieAuthResponse } from '@/services/api/oauth';
|
||||||
import { isLocalhost } from '@/utils/connection';
|
import { vertexApi, type VertexImportResponse } from '@/services/api/vertex';
|
||||||
import styles from './OAuthPage.module.scss';
|
import styles from './OAuthPage.module.scss';
|
||||||
|
import iconOpenaiLight from '@/assets/icons/openai-light.svg';
|
||||||
|
import iconOpenaiDark from '@/assets/icons/openai-dark.svg';
|
||||||
|
import iconClaude from '@/assets/icons/claude.svg';
|
||||||
|
import iconAntigravity from '@/assets/icons/antigravity.svg';
|
||||||
|
import iconGemini from '@/assets/icons/gemini.svg';
|
||||||
|
import iconQwen from '@/assets/icons/qwen.svg';
|
||||||
|
import iconIflow from '@/assets/icons/iflow.svg';
|
||||||
|
import iconVertex from '@/assets/icons/vertex.svg';
|
||||||
|
|
||||||
interface ProviderState {
|
interface ProviderState {
|
||||||
url?: string;
|
url?: string;
|
||||||
@@ -14,6 +22,12 @@ interface ProviderState {
|
|||||||
status?: 'idle' | 'waiting' | 'success' | 'error';
|
status?: 'idle' | 'waiting' | 'success' | 'error';
|
||||||
error?: string;
|
error?: string;
|
||||||
polling?: boolean;
|
polling?: boolean;
|
||||||
|
projectId?: string;
|
||||||
|
projectIdError?: string;
|
||||||
|
callbackUrl?: string;
|
||||||
|
callbackSubmitting?: boolean;
|
||||||
|
callbackStatus?: 'success' | 'error';
|
||||||
|
callbackError?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface IFlowCookieState {
|
interface IFlowCookieState {
|
||||||
@@ -24,24 +38,52 @@ interface IFlowCookieState {
|
|||||||
errorType?: 'error' | 'warning';
|
errorType?: 'error' | 'warning';
|
||||||
}
|
}
|
||||||
|
|
||||||
const PROVIDERS: { id: OAuthProvider; titleKey: string; hintKey: string; urlLabelKey: string }[] = [
|
interface VertexImportResult {
|
||||||
{ id: 'codex', titleKey: 'auth_login.codex_oauth_title', hintKey: 'auth_login.codex_oauth_hint', urlLabelKey: 'auth_login.codex_oauth_url_label' },
|
projectId?: string;
|
||||||
{ id: 'anthropic', titleKey: 'auth_login.anthropic_oauth_title', hintKey: 'auth_login.anthropic_oauth_hint', urlLabelKey: 'auth_login.anthropic_oauth_url_label' },
|
email?: string;
|
||||||
{ id: 'antigravity', titleKey: 'auth_login.antigravity_oauth_title', hintKey: 'auth_login.antigravity_oauth_hint', urlLabelKey: 'auth_login.antigravity_oauth_url_label' },
|
location?: string;
|
||||||
{ id: 'gemini-cli', titleKey: 'auth_login.gemini_cli_oauth_title', hintKey: 'auth_login.gemini_cli_oauth_hint', urlLabelKey: 'auth_login.gemini_cli_oauth_url_label' },
|
authFile?: string;
|
||||||
{ id: 'qwen', titleKey: 'auth_login.qwen_oauth_title', hintKey: 'auth_login.qwen_oauth_hint', urlLabelKey: 'auth_login.qwen_oauth_url_label' },
|
}
|
||||||
{ id: 'iflow', titleKey: 'auth_login.iflow_oauth_title', hintKey: 'auth_login.iflow_oauth_hint', urlLabelKey: 'auth_login.iflow_oauth_url_label' }
|
|
||||||
|
interface VertexImportState {
|
||||||
|
file?: File;
|
||||||
|
fileName: string;
|
||||||
|
location: string;
|
||||||
|
loading: boolean;
|
||||||
|
error?: string;
|
||||||
|
result?: VertexImportResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PROVIDERS: { id: OAuthProvider; titleKey: string; hintKey: string; urlLabelKey: string; icon: string | { light: string; dark: string } }[] = [
|
||||||
|
{ id: 'codex', titleKey: 'auth_login.codex_oauth_title', hintKey: 'auth_login.codex_oauth_hint', urlLabelKey: 'auth_login.codex_oauth_url_label', icon: { light: iconOpenaiLight, dark: iconOpenaiDark } },
|
||||||
|
{ id: 'anthropic', titleKey: 'auth_login.anthropic_oauth_title', hintKey: 'auth_login.anthropic_oauth_hint', urlLabelKey: 'auth_login.anthropic_oauth_url_label', icon: iconClaude },
|
||||||
|
{ id: 'antigravity', titleKey: 'auth_login.antigravity_oauth_title', hintKey: 'auth_login.antigravity_oauth_hint', urlLabelKey: 'auth_login.antigravity_oauth_url_label', icon: iconAntigravity },
|
||||||
|
{ id: 'gemini-cli', titleKey: 'auth_login.gemini_cli_oauth_title', hintKey: 'auth_login.gemini_cli_oauth_hint', urlLabelKey: 'auth_login.gemini_cli_oauth_url_label', icon: iconGemini },
|
||||||
|
{ id: 'qwen', titleKey: 'auth_login.qwen_oauth_title', hintKey: 'auth_login.qwen_oauth_hint', urlLabelKey: 'auth_login.qwen_oauth_url_label', icon: iconQwen }
|
||||||
];
|
];
|
||||||
|
|
||||||
|
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() {
|
export function OAuthPage() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { showNotification } = useNotificationStore();
|
const { showNotification } = useNotificationStore();
|
||||||
|
const resolvedTheme = useThemeStore((state) => state.resolvedTheme);
|
||||||
const [states, setStates] = useState<Record<OAuthProvider, ProviderState>>({} as Record<OAuthProvider, ProviderState>);
|
const [states, setStates] = useState<Record<OAuthProvider, ProviderState>>({} as Record<OAuthProvider, ProviderState>);
|
||||||
const [iflowCookie, setIflowCookie] = useState<IFlowCookieState>({ cookie: '', loading: false });
|
const [iflowCookie, setIflowCookie] = useState<IFlowCookieState>({ cookie: '', loading: false });
|
||||||
|
const [vertexState, setVertexState] = useState<VertexImportState>({
|
||||||
|
fileName: '',
|
||||||
|
location: '',
|
||||||
|
loading: false
|
||||||
|
});
|
||||||
const timers = useRef<Record<string, number>>({});
|
const timers = useRef<Record<string, number>>({});
|
||||||
|
const vertexFileInputRef = useRef<HTMLInputElement | null>(null);
|
||||||
// 检测是否为本地访问
|
|
||||||
const isLocal = useMemo(() => isLocalhost(window.location.hostname), []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return () => {
|
return () => {
|
||||||
@@ -49,6 +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) => {
|
const startPolling = (provider: OAuthProvider, state: string) => {
|
||||||
if (timers.current[provider]) {
|
if (timers.current[provider]) {
|
||||||
clearInterval(timers.current[provider]);
|
clearInterval(timers.current[provider]);
|
||||||
@@ -57,27 +106,21 @@ export function OAuthPage() {
|
|||||||
try {
|
try {
|
||||||
const res = await oauthApi.getAuthStatus(state);
|
const res = await oauthApi.getAuthStatus(state);
|
||||||
if (res.status === 'ok') {
|
if (res.status === 'ok') {
|
||||||
setStates((prev) => ({
|
updateProviderState(provider, { status: 'success', polling: false });
|
||||||
...prev,
|
showNotification(t(getAuthKey(provider, 'oauth_status_success')), 'success');
|
||||||
[provider]: { ...prev[provider], status: 'success', polling: false }
|
|
||||||
}));
|
|
||||||
showNotification(t('auth_login.codex_oauth_status_success'), 'success');
|
|
||||||
window.clearInterval(timer);
|
window.clearInterval(timer);
|
||||||
delete timers.current[provider];
|
delete timers.current[provider];
|
||||||
} else if (res.status === 'error') {
|
} else if (res.status === 'error') {
|
||||||
setStates((prev) => ({
|
updateProviderState(provider, { status: 'error', error: res.error, polling: false });
|
||||||
...prev,
|
showNotification(
|
||||||
[provider]: { ...prev[provider], status: 'error', error: res.error, polling: false }
|
`${t(getAuthKey(provider, 'oauth_status_error'))} ${res.error || ''}`,
|
||||||
}));
|
'error'
|
||||||
showNotification(`${t('auth_login.codex_oauth_status_error')} ${res.error || ''}`, 'error');
|
);
|
||||||
window.clearInterval(timer);
|
window.clearInterval(timer);
|
||||||
delete timers.current[provider];
|
delete timers.current[provider];
|
||||||
}
|
}
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
setStates((prev) => ({
|
updateProviderState(provider, { status: 'error', error: err?.message, polling: false });
|
||||||
...prev,
|
|
||||||
[provider]: { ...prev[provider], status: 'error', error: err?.message, polling: false }
|
|
||||||
}));
|
|
||||||
window.clearInterval(timer);
|
window.clearInterval(timer);
|
||||||
delete timers.current[provider];
|
delete timers.current[provider];
|
||||||
}
|
}
|
||||||
@@ -86,25 +129,31 @@ export function OAuthPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const startAuth = async (provider: OAuthProvider) => {
|
const startAuth = async (provider: OAuthProvider) => {
|
||||||
setStates((prev) => ({
|
const projectId = provider === 'gemini-cli' ? (states[provider]?.projectId || '').trim() : undefined;
|
||||||
...prev,
|
// 项目 ID 现在是可选的,如果不输入将自动选择第一个可用项目
|
||||||
[provider]: { ...prev[provider], status: 'waiting', polling: true, error: undefined }
|
if (provider === 'gemini-cli') {
|
||||||
}));
|
updateProviderState(provider, { projectIdError: undefined });
|
||||||
|
}
|
||||||
|
updateProviderState(provider, {
|
||||||
|
status: 'waiting',
|
||||||
|
polling: true,
|
||||||
|
error: undefined,
|
||||||
|
callbackStatus: undefined,
|
||||||
|
callbackError: undefined,
|
||||||
|
callbackUrl: ''
|
||||||
|
});
|
||||||
try {
|
try {
|
||||||
const res = await oauthApi.startAuth(provider);
|
const res = await oauthApi.startAuth(
|
||||||
setStates((prev) => ({
|
provider,
|
||||||
...prev,
|
provider === 'gemini-cli' ? { projectId: projectId || undefined } : undefined
|
||||||
[provider]: { ...prev[provider], url: res.url, state: res.state, status: 'waiting', polling: true }
|
);
|
||||||
}));
|
updateProviderState(provider, { url: res.url, state: res.state, status: 'waiting', polling: true });
|
||||||
if (res.state) {
|
if (res.state) {
|
||||||
startPolling(provider, res.state);
|
startPolling(provider, res.state);
|
||||||
}
|
}
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
setStates((prev) => ({
|
updateProviderState(provider, { status: 'error', error: err?.message, polling: false });
|
||||||
...prev,
|
showNotification(`${t(getAuthKey(provider, 'oauth_start_error'))} ${err?.message || ''}`, 'error');
|
||||||
[provider]: { ...prev[provider], status: 'error', error: err?.message, polling: false }
|
|
||||||
}));
|
|
||||||
showNotification(`${t('auth_login.codex_oauth_start_error')} ${err?.message || ''}`, 'error');
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -118,6 +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 submitIflowCookie = async () => {
|
||||||
const cookie = iflowCookie.cookie.trim();
|
const cookie = iflowCookie.cookie.trim();
|
||||||
if (!cookie) {
|
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 (
|
return (
|
||||||
<div className={styles.container}>
|
<div className={styles.container}>
|
||||||
<h1 className={styles.pageTitle}>{t('nav.oauth', { defaultValue: 'OAuth' })}</h1>
|
<h1 className={styles.pageTitle}>{t('nav.oauth', { defaultValue: 'OAuth' })}</h1>
|
||||||
@@ -164,56 +305,106 @@ export function OAuthPage() {
|
|||||||
<div className={styles.content}>
|
<div className={styles.content}>
|
||||||
{PROVIDERS.map((provider) => {
|
{PROVIDERS.map((provider) => {
|
||||||
const state = states[provider.id] || {};
|
const state = states[provider.id] || {};
|
||||||
// 非本地访问时禁用所有 OAuth 登录方式
|
const canSubmitCallback = CALLBACK_SUPPORTED.includes(provider.id) && Boolean(state.url);
|
||||||
const isDisabled = !isLocal;
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div key={provider.id}>
|
||||||
key={provider.id}
|
|
||||||
style={isDisabled ? { opacity: 0.6, pointerEvents: 'none' } : undefined}
|
|
||||||
>
|
|
||||||
<Card
|
<Card
|
||||||
title={t(provider.titleKey)}
|
title={
|
||||||
|
<span className={styles.cardTitle}>
|
||||||
|
<img
|
||||||
|
src={getIcon(provider.icon, resolvedTheme)}
|
||||||
|
alt=""
|
||||||
|
className={styles.cardTitleIcon}
|
||||||
|
/>
|
||||||
|
{t(provider.titleKey)}
|
||||||
|
</span>
|
||||||
|
}
|
||||||
extra={
|
extra={
|
||||||
<Button
|
<Button onClick={() => startAuth(provider.id)} loading={state.polling}>
|
||||||
onClick={() => startAuth(provider.id)}
|
|
||||||
loading={state.polling}
|
|
||||||
disabled={isDisabled}
|
|
||||||
>
|
|
||||||
{t('common.login')}
|
{t('common.login')}
|
||||||
</Button>
|
</Button>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<div className="hint">{t(provider.hintKey)}</div>
|
<div className="hint">{t(provider.hintKey)}</div>
|
||||||
{isDisabled && (
|
{provider.id === 'gemini-cli' && (
|
||||||
<div className="status-badge warning" style={{ marginTop: 8 }}>
|
<div className={styles.geminiProjectField}>
|
||||||
{t('auth_login.remote_access_disabled')}
|
<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')}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{!isDisabled && state.url && (
|
{state.url && (
|
||||||
<div className="connection-box">
|
<div className={`connection-box ${styles.authUrlBox}`}>
|
||||||
<div className="label">{t(provider.urlLabelKey)}</div>
|
<div className={styles.authUrlLabel}>{t(provider.urlLabelKey)}</div>
|
||||||
<div className="value">{state.url}</div>
|
<div className={styles.authUrlValue}>{state.url}</div>
|
||||||
<div className="item-actions" style={{ marginTop: 8 }}>
|
<div className={styles.authUrlActions}>
|
||||||
<Button variant="secondary" size="sm" onClick={() => copyLink(state.url!)}>
|
<Button variant="secondary" size="sm" onClick={() => copyLink(state.url!)}>
|
||||||
{t('auth_login.codex_copy_link')}
|
{t(getAuthKey(provider.id, 'copy_link'))}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => window.open(state.url, '_blank', 'noopener,noreferrer')}
|
onClick={() => window.open(state.url, '_blank', 'noopener,noreferrer')}
|
||||||
>
|
>
|
||||||
{t('auth_login.codex_open_link')}
|
{t(getAuthKey(provider.id, 'open_link'))}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{!isDisabled && state.status && state.status !== 'idle' && (
|
{canSubmitCallback && (
|
||||||
|
<div className={styles.callbackSection}>
|
||||||
|
<Input
|
||||||
|
label={t('auth_login.oauth_callback_label')}
|
||||||
|
hint={t('auth_login.oauth_callback_hint')}
|
||||||
|
value={state.callbackUrl || ''}
|
||||||
|
onChange={(e) =>
|
||||||
|
updateProviderState(provider.id, {
|
||||||
|
callbackUrl: e.target.value,
|
||||||
|
callbackStatus: undefined,
|
||||||
|
callbackError: undefined
|
||||||
|
})
|
||||||
|
}
|
||||||
|
placeholder={t('auth_login.oauth_callback_placeholder')}
|
||||||
|
/>
|
||||||
|
<div className={styles.callbackActions}>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => submitCallback(provider.id)}
|
||||||
|
loading={state.callbackSubmitting}
|
||||||
|
>
|
||||||
|
{t('auth_login.oauth_callback_button')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{state.callbackStatus === 'success' && state.status === 'waiting' && (
|
||||||
|
<div className="status-badge success" style={{ marginTop: 8 }}>
|
||||||
|
{t('auth_login.oauth_callback_status_success')}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{state.callbackStatus === 'error' && (
|
||||||
|
<div className="status-badge error" style={{ marginTop: 8 }}>
|
||||||
|
{t('auth_login.oauth_callback_status_error')} {state.callbackError || ''}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{state.status && state.status !== 'idle' && (
|
||||||
<div className="status-badge" style={{ marginTop: 8 }}>
|
<div className="status-badge" style={{ marginTop: 8 }}>
|
||||||
{state.status === 'success'
|
{state.status === 'success'
|
||||||
? t('auth_login.codex_oauth_status_success')
|
? t(getAuthKey(provider.id, 'oauth_status_success'))
|
||||||
: state.status === 'error'
|
: state.status === 'error'
|
||||||
? `${t('auth_login.codex_oauth_status_error')} ${state.error || ''}`
|
? `${t(getAuthKey(provider.id, 'oauth_status_error'))} ${state.error || ''}`
|
||||||
: t('auth_login.codex_oauth_status_waiting')}
|
: t(getAuthKey(provider.id, 'oauth_status_waiting'))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
@@ -221,9 +412,102 @@ export function OAuthPage() {
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
||||||
|
{/* Vertex JSON 登录 */}
|
||||||
|
<Card
|
||||||
|
title={
|
||||||
|
<span className={styles.cardTitle}>
|
||||||
|
<img src={iconVertex} alt="" className={styles.cardTitleIcon} />
|
||||||
|
{t('vertex_import.title')}
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
extra={
|
||||||
|
<Button onClick={handleVertexImport} loading={vertexState.loading}>
|
||||||
|
{t('vertex_import.import_button')}
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="hint">{t('vertex_import.description')}</div>
|
||||||
|
<Input
|
||||||
|
label={t('vertex_import.location_label')}
|
||||||
|
hint={t('vertex_import.location_hint')}
|
||||||
|
value={vertexState.location}
|
||||||
|
onChange={(e) =>
|
||||||
|
setVertexState((prev) => ({
|
||||||
|
...prev,
|
||||||
|
location: e.target.value
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
placeholder={t('vertex_import.location_placeholder')}
|
||||||
|
/>
|
||||||
|
<div className="form-group">
|
||||||
|
<label>{t('vertex_import.file_label')}</label>
|
||||||
|
<div className={styles.filePicker}>
|
||||||
|
<Button variant="secondary" size="sm" onClick={handleVertexFilePick}>
|
||||||
|
{t('vertex_import.choose_file')}
|
||||||
|
</Button>
|
||||||
|
<div
|
||||||
|
className={`${styles.fileName} ${
|
||||||
|
vertexState.fileName ? '' : styles.fileNamePlaceholder
|
||||||
|
}`.trim()}
|
||||||
|
>
|
||||||
|
{vertexState.fileName || t('vertex_import.file_placeholder')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="hint">{t('vertex_import.file_hint')}</div>
|
||||||
|
<input
|
||||||
|
ref={vertexFileInputRef}
|
||||||
|
type="file"
|
||||||
|
accept=".json,application/json"
|
||||||
|
style={{ display: 'none' }}
|
||||||
|
onChange={handleVertexFileChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{vertexState.error && (
|
||||||
|
<div className="status-badge error" style={{ marginTop: 8 }}>
|
||||||
|
{vertexState.error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{vertexState.result && (
|
||||||
|
<div className="connection-box" style={{ marginTop: 12 }}>
|
||||||
|
<div className="label">{t('vertex_import.result_title')}</div>
|
||||||
|
<div className="key-value-list">
|
||||||
|
{vertexState.result.projectId && (
|
||||||
|
<div className="key-value-item">
|
||||||
|
<span className="key">{t('vertex_import.result_project')}</span>
|
||||||
|
<span className="value">{vertexState.result.projectId}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{vertexState.result.email && (
|
||||||
|
<div className="key-value-item">
|
||||||
|
<span className="key">{t('vertex_import.result_email')}</span>
|
||||||
|
<span className="value">{vertexState.result.email}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{vertexState.result.location && (
|
||||||
|
<div className="key-value-item">
|
||||||
|
<span className="key">{t('vertex_import.result_location')}</span>
|
||||||
|
<span className="value">{vertexState.result.location}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{vertexState.result.authFile && (
|
||||||
|
<div className="key-value-item">
|
||||||
|
<span className="key">{t('vertex_import.result_file')}</span>
|
||||||
|
<span className="value">{vertexState.result.authFile}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
|
||||||
{/* iFlow Cookie 登录 */}
|
{/* iFlow Cookie 登录 */}
|
||||||
<Card
|
<Card
|
||||||
title={t('auth_login.iflow_cookie_title')}
|
title={
|
||||||
|
<span className={styles.cardTitle}>
|
||||||
|
<img src={iconIflow} alt="" className={styles.cardTitleIcon} />
|
||||||
|
{t('auth_login.iflow_cookie_title')}
|
||||||
|
</span>
|
||||||
|
}
|
||||||
extra={
|
extra={
|
||||||
<Button onClick={submitIflowCookie} loading={iflowCookie.loading}>
|
<Button onClick={submitIflowCookie} loading={iflowCookie.loading}>
|
||||||
{t('auth_login.iflow_cookie_button')}
|
{t('auth_login.iflow_cookie_button')}
|
||||||
|
|||||||
333
src/pages/QuotaPage.module.scss
Normal 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;
|
||||||
|
}
|
||||||
92
src/pages/QuotaPage.tsx
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
/**
|
||||||
|
* Quota management page - coordinates the three quota sections.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { useHeaderRefresh } from '@/hooks/useHeaderRefresh';
|
||||||
|
import { useAuthStore } from '@/stores';
|
||||||
|
import { authFilesApi, configFileApi } 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 loadConfig = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
await configFileApi.fetchConfigYaml();
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const errorMessage = err instanceof Error ? err.message : t('notification.refresh_failed');
|
||||||
|
setError((prev) => prev || errorMessage);
|
||||||
|
}
|
||||||
|
}, [t]);
|
||||||
|
|
||||||
|
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]);
|
||||||
|
|
||||||
|
const handleHeaderRefresh = useCallback(async () => {
|
||||||
|
await Promise.all([loadConfig(), loadFiles()]);
|
||||||
|
}, [loadConfig, loadFiles]);
|
||||||
|
|
||||||
|
useHeaderRefresh(handleHeaderRefresh);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadFiles();
|
||||||
|
loadConfig();
|
||||||
|
}, [loadFiles, loadConfig]);
|
||||||
|
|
||||||
|
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>
|
||||||
|
|
||||||
|
{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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -16,7 +16,6 @@ type PendingKey =
|
|||||||
| 'switchProject'
|
| 'switchProject'
|
||||||
| 'switchPreview'
|
| 'switchPreview'
|
||||||
| 'usage'
|
| 'usage'
|
||||||
| 'requestLog'
|
|
||||||
| 'loggingToFile'
|
| 'loggingToFile'
|
||||||
| 'wsAuth';
|
| 'wsAuth';
|
||||||
|
|
||||||
@@ -70,7 +69,7 @@ export function SettingsPage() {
|
|||||||
|
|
||||||
const toggleSetting = async (
|
const toggleSetting = async (
|
||||||
section: PendingKey,
|
section: PendingKey,
|
||||||
rawKey: 'debug' | 'usage-statistics-enabled' | 'request-log' | 'logging-to-file' | 'ws-auth',
|
rawKey: 'debug' | 'usage-statistics-enabled' | 'logging-to-file' | 'ws-auth',
|
||||||
value: boolean,
|
value: boolean,
|
||||||
updater: (val: boolean) => Promise<any>,
|
updater: (val: boolean) => Promise<any>,
|
||||||
successMessage: string
|
successMessage: string
|
||||||
@@ -81,8 +80,6 @@ export function SettingsPage() {
|
|||||||
return config?.debug ?? false;
|
return config?.debug ?? false;
|
||||||
case 'usage-statistics-enabled':
|
case 'usage-statistics-enabled':
|
||||||
return config?.usageStatisticsEnabled ?? false;
|
return config?.usageStatisticsEnabled ?? false;
|
||||||
case 'request-log':
|
|
||||||
return config?.requestLog ?? false;
|
|
||||||
case 'logging-to-file':
|
case 'logging-to-file':
|
||||||
return config?.loggingToFile ?? false;
|
return config?.loggingToFile ?? false;
|
||||||
case 'ws-auth':
|
case 'ws-auth':
|
||||||
@@ -200,21 +197,6 @@ export function SettingsPage() {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<ToggleSwitch
|
|
||||||
label={t('basic_settings.request_log_enable')}
|
|
||||||
checked={config?.requestLog ?? false}
|
|
||||||
disabled={disableControls || pending.requestLog || loading}
|
|
||||||
onChange={(value) =>
|
|
||||||
toggleSetting(
|
|
||||||
'requestLog',
|
|
||||||
'request-log',
|
|
||||||
value,
|
|
||||||
configApi.updateRequestLog,
|
|
||||||
t('notification.request_log_updated')
|
|
||||||
)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<ToggleSwitch
|
<ToggleSwitch
|
||||||
label={t('basic_settings.logging_to_file_enable')}
|
label={t('basic_settings.logging_to_file_enable')}
|
||||||
checked={config?.loggingToFile ?? false}
|
checked={config?.loggingToFile ?? false}
|
||||||
|
|||||||
@@ -34,6 +34,12 @@
|
|||||||
margin: 0 0 $spacing-md 0;
|
margin: 0 0 $spacing-md 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.clearLoginActions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
.infoGrid {
|
.infoGrid {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: $spacing-sm;
|
gap: $spacing-sm;
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { IconGithub, IconBookOpen, IconExternalLink, IconCode } from '@/componen
|
|||||||
import { useAuthStore, useConfigStore, useNotificationStore, useModelsStore } from '@/stores';
|
import { useAuthStore, useConfigStore, useNotificationStore, useModelsStore } from '@/stores';
|
||||||
import { apiKeysApi } from '@/services/api/apiKeys';
|
import { apiKeysApi } from '@/services/api/apiKeys';
|
||||||
import { classifyModels } from '@/utils/models';
|
import { classifyModels } from '@/utils/models';
|
||||||
|
import { STORAGE_KEY_AUTH } from '@/utils/constants';
|
||||||
import styles from './SystemPage.module.scss';
|
import styles from './SystemPage.module.scss';
|
||||||
|
|
||||||
export function SystemPage() {
|
export function SystemPage() {
|
||||||
@@ -104,6 +105,15 @@ export function SystemPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleClearLoginStorage = () => {
|
||||||
|
if (!window.confirm(t('system_info.clear_login_confirm'))) return;
|
||||||
|
auth.logout();
|
||||||
|
if (typeof localStorage === 'undefined') return;
|
||||||
|
const keysToRemove = [STORAGE_KEY_AUTH, 'isLoggedIn', 'apiBase', 'apiUrl', 'managementKey'];
|
||||||
|
keysToRemove.forEach((key) => localStorage.removeItem(key));
|
||||||
|
showNotification(t('notification.login_storage_cleared'), 'success');
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchConfig().catch(() => {
|
fetchConfig().catch(() => {
|
||||||
// ignore
|
// ignore
|
||||||
@@ -248,6 +258,15 @@ export function SystemPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
<Card title={t('system_info.clear_login_title')}>
|
||||||
|
<p className={styles.sectionDescription}>{t('system_info.clear_login_desc')}</p>
|
||||||
|
<div className={styles.clearLoginActions}>
|
||||||
|
<Button variant="danger" onClick={handleClearLoginStorage}>
|
||||||
|
{t('system_info.clear_login_button')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -3,9 +3,11 @@
|
|||||||
|
|
||||||
.container {
|
.container {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
min-height: 100%;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 20px;
|
gap: 20px;
|
||||||
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
.header {
|
.header {
|
||||||
@@ -16,6 +18,13 @@
|
|||||||
gap: 10px;
|
gap: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.headerActions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
.pageTitle {
|
.pageTitle {
|
||||||
font-size: 28px;
|
font-size: 28px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
@@ -39,6 +48,45 @@
|
|||||||
padding: 16px;
|
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
|
// Stats Grid
|
||||||
.statsGrid {
|
.statsGrid {
|
||||||
display: grid;
|
display: grid;
|
||||||
|
|||||||
32
src/router/MainRoutes.tsx
Normal 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);
|
||||||
|
}
|
||||||
@@ -18,9 +18,6 @@ export const ampcodeApi = {
|
|||||||
updateUpstreamApiKey: (apiKey: string) => apiClient.put('/ampcode/upstream-api-key', { value: apiKey }),
|
updateUpstreamApiKey: (apiKey: string) => apiClient.put('/ampcode/upstream-api-key', { value: apiKey }),
|
||||||
clearUpstreamApiKey: () => apiClient.delete('/ampcode/upstream-api-key'),
|
clearUpstreamApiKey: () => apiClient.delete('/ampcode/upstream-api-key'),
|
||||||
|
|
||||||
updateRestrictManagementToLocalhost: (enabled: boolean) =>
|
|
||||||
apiClient.put('/ampcode/restrict-management-to-localhost', { value: enabled }),
|
|
||||||
|
|
||||||
async getModelMappings(): Promise<AmpcodeModelMapping[]> {
|
async getModelMappings(): Promise<AmpcodeModelMapping[]> {
|
||||||
const data = await apiClient.get('/ampcode/model-mappings');
|
const data = await apiClient.get('/ampcode/model-mappings');
|
||||||
const list = data?.['model-mappings'] ?? data?.modelMappings ?? data?.items ?? data;
|
const list = data?.['model-mappings'] ?? data?.modelMappings ?? data?.items ?? data;
|
||||||
|
|||||||
86
src/services/api/apiCall.ts
Normal 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
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -62,12 +62,37 @@ class ApiClient {
|
|||||||
return `${normalized}${MANAGEMENT_API_PREFIX}`;
|
return `${normalized}${MANAGEMENT_API_PREFIX}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
private readHeader(headers: Record<string, any>, keys: string[]): string | null {
|
private readHeader(headers: Record<string, any> | undefined, keys: string[]): string | null {
|
||||||
|
if (!headers) return null;
|
||||||
|
|
||||||
|
const normalizeValue = (value: unknown): string | null => {
|
||||||
|
if (value === undefined || value === null) return null;
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
const first = value.find((entry) => entry !== undefined && entry !== null && String(entry).trim());
|
||||||
|
return first !== undefined ? String(first) : null;
|
||||||
|
}
|
||||||
|
const text = String(value);
|
||||||
|
return text ? text : null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const headerGetter = (headers as { get?: (name: string) => any }).get;
|
||||||
|
if (typeof headerGetter === 'function') {
|
||||||
|
for (const key of keys) {
|
||||||
|
const match = normalizeValue(headerGetter.call(headers, key));
|
||||||
|
if (match) return match;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const entries =
|
||||||
|
typeof (headers as { entries?: () => Iterable<[string, any]> }).entries === 'function'
|
||||||
|
? Array.from((headers as { entries: () => Iterable<[string, any]> }).entries())
|
||||||
|
: Object.entries(headers);
|
||||||
|
|
||||||
const normalized = Object.fromEntries(
|
const normalized = Object.fromEntries(
|
||||||
Object.entries(headers || {}).map(([key, value]) => [key.toLowerCase(), value as string | undefined])
|
entries.map(([key, value]) => [String(key).toLowerCase(), value])
|
||||||
);
|
);
|
||||||
for (const key of keys) {
|
for (const key of keys) {
|
||||||
const match = normalized[key.toLowerCase()];
|
const match = normalizeValue(normalized[key.toLowerCase()]);
|
||||||
if (match) return match;
|
if (match) return match;
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
@@ -82,6 +107,10 @@ class ApiClient {
|
|||||||
(config) => {
|
(config) => {
|
||||||
// 设置 baseURL
|
// 设置 baseURL
|
||||||
config.baseURL = this.apiBase;
|
config.baseURL = this.apiBase;
|
||||||
|
if (config.url) {
|
||||||
|
// Normalize deprecated Gemini endpoint to the current path.
|
||||||
|
config.url = config.url.replace(/\/generative-language-api-key\b/g, '/gemini-api-key');
|
||||||
|
}
|
||||||
|
|
||||||
// 添加认证头
|
// 添加认证头
|
||||||
if (this.managementKey) {
|
if (this.managementKey) {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
export * from './client';
|
export * from './client';
|
||||||
|
export * from './apiCall';
|
||||||
export * from './config';
|
export * from './config';
|
||||||
export * from './configFile';
|
export * from './configFile';
|
||||||
export * from './apiKeys';
|
export * from './apiKeys';
|
||||||
@@ -11,3 +12,4 @@ export * from './logs';
|
|||||||
export * from './version';
|
export * from './version';
|
||||||
export * from './models';
|
export * from './models';
|
||||||
export * from './transformers';
|
export * from './transformers';
|
||||||
|
export * from './vertex';
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { apiClient } from './client';
|
import { apiClient } from './client';
|
||||||
|
import { LOGS_TIMEOUT_MS } from '@/utils/constants';
|
||||||
|
|
||||||
export interface LogsQuery {
|
export interface LogsQuery {
|
||||||
after?: number;
|
after?: number;
|
||||||
@@ -14,16 +15,34 @@ export interface LogsResponse {
|
|||||||
'latest-timestamp': number;
|
'latest-timestamp': number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ErrorLogFile {
|
||||||
|
name: string;
|
||||||
|
size?: number;
|
||||||
|
modified?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ErrorLogsResponse {
|
||||||
|
files?: ErrorLogFile[];
|
||||||
|
}
|
||||||
|
|
||||||
export const logsApi = {
|
export const logsApi = {
|
||||||
fetchLogs: (params: LogsQuery = {}): Promise<LogsResponse> =>
|
fetchLogs: (params: LogsQuery = {}): Promise<LogsResponse> =>
|
||||||
apiClient.get('/logs', { params }),
|
apiClient.get('/logs', { params, timeout: LOGS_TIMEOUT_MS }),
|
||||||
|
|
||||||
clearLogs: () => apiClient.delete('/logs'),
|
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) =>
|
downloadErrorLog: (filename: string) =>
|
||||||
apiClient.getRaw(`/request-error-logs/${encodeURIComponent(filename)}`, {
|
apiClient.getRaw(`/request-error-logs/${encodeURIComponent(filename)}`, {
|
||||||
responseType: 'blob'
|
responseType: 'blob',
|
||||||
})
|
timeout: LOGS_TIMEOUT_MS
|
||||||
|
}),
|
||||||
|
|
||||||
|
downloadRequestLogById: (id: string) =>
|
||||||
|
apiClient.getRaw(`/request-log-by-id/${encodeURIComponent(id)}`, {
|
||||||
|
responseType: 'blob',
|
||||||
|
timeout: LOGS_TIMEOUT_MS
|
||||||
|
}),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
|
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import { normalizeModelList } from '@/utils/models';
|
import { normalizeModelList } from '@/utils/models';
|
||||||
|
import { apiCallApi, getApiCallErrorMessage } from './apiCall';
|
||||||
|
|
||||||
const normalizeBaseUrl = (baseUrl: string): string => {
|
const normalizeBaseUrl = (baseUrl: string): string => {
|
||||||
let normalized = String(baseUrl || '').trim();
|
let normalized = String(baseUrl || '').trim();
|
||||||
@@ -39,5 +40,35 @@ export const modelsApi = {
|
|||||||
});
|
});
|
||||||
const payload = response.data?.data ?? response.data?.models ?? response.data;
|
const payload = response.data?.data ?? response.data?.models ?? response.data;
|
||||||
return normalizeModelList(payload, { dedupe: true });
|
return normalizeModelList(payload, { dedupe: true });
|
||||||
|
},
|
||||||
|
|
||||||
|
async fetchModelsViaApiCall(
|
||||||
|
baseUrl: string,
|
||||||
|
apiKey?: string,
|
||||||
|
headers: Record<string, string> = {}
|
||||||
|
) {
|
||||||
|
const endpoint = buildModelsEndpoint(baseUrl);
|
||||||
|
if (!endpoint) {
|
||||||
|
throw new Error('Invalid base url');
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolvedHeaders = { ...headers };
|
||||||
|
const hasAuthHeader = Boolean(resolvedHeaders.Authorization || resolvedHeaders.authorization);
|
||||||
|
if (apiKey && !hasAuthHeader) {
|
||||||
|
resolvedHeaders.Authorization = `Bearer ${apiKey}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await apiCallApi.request({
|
||||||
|
method: 'GET',
|
||||||
|
url: endpoint,
|
||||||
|
header: Object.keys(resolvedHeaders).length ? resolvedHeaders : undefined
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result.statusCode < 200 || result.statusCode >= 300) {
|
||||||
|
throw new Error(getApiCallErrorMessage(result));
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = result.body ?? result.bodyText;
|
||||||
|
return normalizeModelList(payload, { dedupe: true });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -9,14 +9,17 @@ export type OAuthProvider =
|
|||||||
| 'anthropic'
|
| 'anthropic'
|
||||||
| 'antigravity'
|
| 'antigravity'
|
||||||
| 'gemini-cli'
|
| 'gemini-cli'
|
||||||
| 'qwen'
|
| 'qwen';
|
||||||
| 'iflow';
|
|
||||||
|
|
||||||
export interface OAuthStartResponse {
|
export interface OAuthStartResponse {
|
||||||
url: string;
|
url: string;
|
||||||
state?: string;
|
state?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface OAuthCallbackResponse {
|
||||||
|
status: 'ok';
|
||||||
|
}
|
||||||
|
|
||||||
export interface IFlowCookieAuthResponse {
|
export interface IFlowCookieAuthResponse {
|
||||||
status: 'ok' | 'error';
|
status: 'ok' | 'error';
|
||||||
error?: string;
|
error?: string;
|
||||||
@@ -26,19 +29,38 @@ export interface IFlowCookieAuthResponse {
|
|||||||
type?: string;
|
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 = {
|
export const oauthApi = {
|
||||||
startAuth: (provider: OAuthProvider) =>
|
startAuth: (provider: OAuthProvider, options?: { projectId?: string }) => {
|
||||||
apiClient.get<OAuthStartResponse>(`/${provider}-auth-url`, {
|
const params: Record<string, string | boolean> = {};
|
||||||
params: WEBUI_SUPPORTED.includes(provider) ? { is_webui: true } : undefined
|
if (WEBUI_SUPPORTED.includes(provider)) {
|
||||||
}),
|
params.is_webui = true;
|
||||||
|
}
|
||||||
|
if (provider === 'gemini-cli' && options?.projectId) {
|
||||||
|
params.project_id = options.projectId;
|
||||||
|
}
|
||||||
|
return apiClient.get<OAuthStartResponse>(`/${provider}-auth-url`, {
|
||||||
|
params: Object.keys(params).length ? params : undefined
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
getAuthStatus: (state: string) =>
|
getAuthStatus: (state: string) =>
|
||||||
apiClient.get<{ status: 'ok' | 'wait' | 'error'; error?: string }>(`/get-auth-status`, {
|
apiClient.get<{ status: 'ok' | 'wait' | 'error'; error?: string }>(`/get-auth-status`, {
|
||||||
params: { state }
|
params: { state }
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
submitCallback: (provider: OAuthProvider, redirectUrl: string) => {
|
||||||
|
const callbackProvider = CALLBACK_PROVIDER_MAP[provider] ?? provider;
|
||||||
|
return apiClient.post<OAuthCallbackResponse>('/oauth-callback', {
|
||||||
|
provider: callbackProvider,
|
||||||
|
redirect_url: redirectUrl
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
/** iFlow cookie 认证 */
|
/** iFlow cookie 认证 */
|
||||||
iflowCookieAuth: (cookie: string) =>
|
iflowCookieAuth: (cookie: string) =>
|
||||||
apiClient.post<IFlowCookieAuthResponse>('/iflow-auth-url', { cookie })
|
apiClient.post<IFlowCookieAuthResponse>('/iflow-auth-url', { cookie })
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ const serializeApiKeyEntry = (entry: ApiKeyEntry) => {
|
|||||||
|
|
||||||
const serializeProviderKey = (config: ProviderKeyConfig) => {
|
const serializeProviderKey = (config: ProviderKeyConfig) => {
|
||||||
const payload: Record<string, any> = { 'api-key': config.apiKey };
|
const payload: Record<string, any> = { 'api-key': config.apiKey };
|
||||||
|
if (config.prefix?.trim()) payload.prefix = config.prefix.trim();
|
||||||
if (config.baseUrl) payload['base-url'] = config.baseUrl;
|
if (config.baseUrl) payload['base-url'] = config.baseUrl;
|
||||||
if (config.proxyUrl) payload['proxy-url'] = config.proxyUrl;
|
if (config.proxyUrl) payload['proxy-url'] = config.proxyUrl;
|
||||||
const headers = serializeHeaders(config.headers);
|
const headers = serializeHeaders(config.headers);
|
||||||
@@ -62,6 +63,7 @@ const serializeProviderKey = (config: ProviderKeyConfig) => {
|
|||||||
|
|
||||||
const serializeGeminiKey = (config: GeminiKeyConfig) => {
|
const serializeGeminiKey = (config: GeminiKeyConfig) => {
|
||||||
const payload: Record<string, any> = { 'api-key': config.apiKey };
|
const payload: Record<string, any> = { 'api-key': config.apiKey };
|
||||||
|
if (config.prefix?.trim()) payload.prefix = config.prefix.trim();
|
||||||
if (config.baseUrl) payload['base-url'] = config.baseUrl;
|
if (config.baseUrl) payload['base-url'] = config.baseUrl;
|
||||||
const headers = serializeHeaders(config.headers);
|
const headers = serializeHeaders(config.headers);
|
||||||
if (headers) payload.headers = headers;
|
if (headers) payload.headers = headers;
|
||||||
@@ -79,6 +81,7 @@ const serializeOpenAIProvider = (provider: OpenAIProviderConfig) => {
|
|||||||
? provider.apiKeyEntries.map((entry) => serializeApiKeyEntry(entry))
|
? provider.apiKeyEntries.map((entry) => serializeApiKeyEntry(entry))
|
||||||
: []
|
: []
|
||||||
};
|
};
|
||||||
|
if (provider.prefix?.trim()) payload.prefix = provider.prefix.trim();
|
||||||
const headers = serializeHeaders(provider.headers);
|
const headers = serializeHeaders(provider.headers);
|
||||||
if (headers) payload.headers = headers;
|
if (headers) payload.headers = headers;
|
||||||
const models = serializeModelAliases(provider.models);
|
const models = serializeModelAliases(provider.models);
|
||||||
|
|||||||
@@ -70,6 +70,12 @@ const normalizeExcludedModels = (input: any): string[] => {
|
|||||||
return normalized;
|
return normalized;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const normalizePrefix = (value: any): string | undefined => {
|
||||||
|
if (value === undefined || value === null) return undefined;
|
||||||
|
const trimmed = String(value).trim();
|
||||||
|
return trimmed ? trimmed : undefined;
|
||||||
|
};
|
||||||
|
|
||||||
const normalizeApiKeyEntry = (entry: any): ApiKeyEntry | null => {
|
const normalizeApiKeyEntry = (entry: any): ApiKeyEntry | null => {
|
||||||
if (!entry) return null;
|
if (!entry) return null;
|
||||||
const apiKey = entry['api-key'] ?? entry.apiKey ?? entry.key ?? (typeof entry === 'string' ? entry : '');
|
const apiKey = entry['api-key'] ?? entry.apiKey ?? entry.key ?? (typeof entry === 'string' ? entry : '');
|
||||||
@@ -93,6 +99,8 @@ const normalizeProviderKeyConfig = (item: any): ProviderKeyConfig | null => {
|
|||||||
if (!trimmed) return null;
|
if (!trimmed) return null;
|
||||||
|
|
||||||
const config: ProviderKeyConfig = { apiKey: trimmed };
|
const config: ProviderKeyConfig = { apiKey: trimmed };
|
||||||
|
const prefix = normalizePrefix(item.prefix ?? item['prefix']);
|
||||||
|
if (prefix) config.prefix = prefix;
|
||||||
const baseUrl = item['base-url'] ?? item.baseUrl;
|
const baseUrl = item['base-url'] ?? item.baseUrl;
|
||||||
const proxyUrl = item['proxy-url'] ?? item.proxyUrl;
|
const proxyUrl = item['proxy-url'] ?? item.proxyUrl;
|
||||||
if (baseUrl) config.baseUrl = String(baseUrl);
|
if (baseUrl) config.baseUrl = String(baseUrl);
|
||||||
@@ -118,6 +126,8 @@ const normalizeGeminiKeyConfig = (item: any): GeminiKeyConfig | null => {
|
|||||||
if (!trimmed) return null;
|
if (!trimmed) return null;
|
||||||
|
|
||||||
const config: GeminiKeyConfig = { apiKey: trimmed };
|
const config: GeminiKeyConfig = { apiKey: trimmed };
|
||||||
|
const prefix = normalizePrefix(item.prefix ?? item['prefix']);
|
||||||
|
if (prefix) config.prefix = prefix;
|
||||||
const baseUrl = item['base-url'] ?? item.baseUrl ?? item['base_url'];
|
const baseUrl = item['base-url'] ?? item.baseUrl ?? item['base_url'];
|
||||||
if (baseUrl) config.baseUrl = String(baseUrl);
|
if (baseUrl) config.baseUrl = String(baseUrl);
|
||||||
const headers = normalizeHeaders(item.headers);
|
const headers = normalizeHeaders(item.headers);
|
||||||
@@ -155,6 +165,8 @@ const normalizeOpenAIProvider = (provider: any): OpenAIProviderConfig | null =>
|
|||||||
apiKeyEntries
|
apiKeyEntries
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const prefix = normalizePrefix(provider.prefix ?? provider['prefix']);
|
||||||
|
if (prefix) result.prefix = prefix;
|
||||||
if (headers) result.headers = headers;
|
if (headers) result.headers = headers;
|
||||||
if (models.length) result.models = models;
|
if (models.length) result.models = models;
|
||||||
if (priority !== undefined) result.priority = Number(priority);
|
if (priority !== undefined) result.priority = Number(priority);
|
||||||
@@ -205,15 +217,6 @@ const normalizeAmpcodeConfig = (payload: any): AmpcodeConfig | undefined => {
|
|||||||
const upstreamApiKey = source['upstream-api-key'] ?? source.upstreamApiKey ?? source['upstream_api_key'];
|
const upstreamApiKey = source['upstream-api-key'] ?? source.upstreamApiKey ?? source['upstream_api_key'];
|
||||||
if (upstreamApiKey) config.upstreamApiKey = String(upstreamApiKey);
|
if (upstreamApiKey) config.upstreamApiKey = String(upstreamApiKey);
|
||||||
|
|
||||||
const restrictManagementToLocalhost = normalizeBoolean(
|
|
||||||
source['restrict-management-to-localhost'] ??
|
|
||||||
source.restrictManagementToLocalhost ??
|
|
||||||
source['restrict_management_to_localhost']
|
|
||||||
);
|
|
||||||
if (restrictManagementToLocalhost !== undefined) {
|
|
||||||
config.restrictManagementToLocalhost = restrictManagementToLocalhost;
|
|
||||||
}
|
|
||||||
|
|
||||||
const forceModelMappings = normalizeBoolean(
|
const forceModelMappings = normalizeBoolean(
|
||||||
source['force-model-mappings'] ?? source.forceModelMappings ?? source['force_model_mappings']
|
source['force-model-mappings'] ?? source.forceModelMappings ?? source['force_model_mappings']
|
||||||
);
|
);
|
||||||
|
|||||||