Compare commits
325 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e050aee804 | ||
|
|
7aee0577ea | ||
|
|
879112d856 | ||
|
|
027ab483d4 | ||
|
|
535c303aec | ||
|
|
6c2cd761ba | ||
|
|
3783bec983 | ||
|
|
b90239d39c | ||
|
|
f8d66917fd | ||
|
|
36bfd0fa6a | ||
|
|
709ce4c8dd | ||
|
|
525b152a76 | ||
|
|
e053854544 | ||
|
|
0b54b6de64 | ||
|
|
0c8686cefa | ||
|
|
385117d01a | ||
|
|
700bff1d03 | ||
|
|
680b24026c | ||
|
|
2da4099d0b | ||
|
|
8acef95e5a | ||
|
|
c892d939c7 | ||
|
|
50ab96c3ed | ||
|
|
0bb8090686 | ||
|
|
cade2647d6 | ||
|
|
3661530f5f | ||
|
|
f833f0dfd2 | ||
|
|
d5ccef8b24 | ||
|
|
ad6a3bd732 | ||
|
|
ad1387d076 | ||
|
|
babc7d9294 | ||
|
|
1d93296ce2 | ||
|
|
8911e325e1 | ||
|
|
131da6c718 | ||
|
|
b6d0a1e0f6 | ||
|
|
5f2566074f | ||
|
|
e2afc17f22 | ||
|
|
26fa1ea98e | ||
|
|
e568e4a2b5 | ||
|
|
4a0386472d | ||
|
|
b9001c27c5 | ||
|
|
e6e62e2992 | ||
|
|
f53d333198 | ||
|
|
adcf0b6582 | ||
|
|
11c2498be6 | ||
|
|
43ec39040d | ||
|
|
d981506942 | ||
|
|
7d41afb5f1 | ||
|
|
d4bc0bc622 | ||
|
|
5241d52b14 | ||
|
|
9887a78889 | ||
|
|
759e369d42 | ||
|
|
db487dc49d | ||
|
|
a94a9791bc | ||
|
|
ee60be6f0a | ||
|
|
259bf88f81 | ||
|
|
149d89a3d8 | ||
|
|
473cece09e | ||
|
|
aebe95d221 | ||
|
|
08e8fe2edd | ||
|
|
20d93142d6 | ||
|
|
9ee120feb8 | ||
|
|
dd21ec2c72 | ||
|
|
f54172e2df | ||
|
|
bca7082bb0 | ||
|
|
a61893d102 | ||
|
|
8513bd4186 | ||
|
|
d996b95f0a | ||
|
|
c4642444ef | ||
|
|
d9272d6d0e | ||
|
|
f8c4a434ed | ||
|
|
237cca5680 | ||
|
|
f0735dbc1e | ||
|
|
f77cbad98e | ||
|
|
0d40eecbe7 | ||
|
|
ce47d6d985 | ||
|
|
01a69ff32b | ||
|
|
fd1174e010 | ||
|
|
3e55d601a1 | ||
|
|
c6fabcb6bc | ||
|
|
460519ed00 | ||
|
|
1053e91fe4 | ||
|
|
b4d08dd0d7 | ||
|
|
1502e14ca7 | ||
|
|
7b77520526 | ||
|
|
525541ea0d | ||
|
|
e7a33f8852 | ||
|
|
70968bbc4c | ||
|
|
c93030370e | ||
|
|
96307873c5 | ||
|
|
b4eb2d790c | ||
|
|
3d33958d9e | ||
|
|
e4c5f80b02 | ||
|
|
291f67e2b9 | ||
|
|
3cdcb7a2a3 | ||
|
|
3d83d0bfe2 | ||
|
|
129d89cf67 | ||
|
|
5c85df486e | ||
|
|
34b6d114d3 | ||
|
|
94f0038f19 | ||
|
|
aa9c7d89f9 | ||
|
|
9bbf61e1b6 | ||
|
|
73198d6929 | ||
|
|
ab86fcf674 | ||
|
|
a88078e171 | ||
|
|
8148851a06 | ||
|
|
dcdb20159b | ||
|
|
54e6121234 | ||
|
|
8b3c4189f1 | ||
|
|
db5fb0d125 | ||
|
|
9515d88e3c | ||
|
|
2bf721974b | ||
|
|
0c53dcfa80 | ||
|
|
6096ffc94e | ||
|
|
77fe9905b1 | ||
|
|
eaaa8f6e80 | ||
|
|
4932374bdd | ||
|
|
82cb521b2e | ||
|
|
034c086e31 | ||
|
|
76e9eb4aa0 | ||
|
|
f22d392b21 | ||
|
|
2539710075 | ||
|
|
6bdc87aed6 | ||
|
|
268b92c59b | ||
|
|
c89bbd5098 | ||
|
|
2715f44a5e | ||
|
|
305ddef900 | ||
|
|
7e56d33bf0 | ||
|
|
80daf03fa6 | ||
|
|
8c3ac0d50a | ||
|
|
883059b031 | ||
|
|
e4850656a5 | ||
|
|
d077b5dd26 | ||
|
|
d79ccc480d | ||
|
|
7b0d6dc7e9 | ||
|
|
b8d7b8997c | ||
|
|
0bb34ca74b | ||
|
|
99c4fbc30d | ||
|
|
a44257edda | ||
|
|
ebb80df24a | ||
|
|
5165715d37 | ||
|
|
73ee6eb2f3 | ||
|
|
161d5d1e7f | ||
|
|
3cbd04b296 | ||
|
|
859f7f120c | ||
|
|
fea29f7318 | ||
|
|
f663b83ac8 | ||
|
|
ee99836285 | ||
|
|
2086c348a9 | ||
|
|
a8abf71bfe | ||
|
|
8dca670358 | ||
|
|
71556a51c5 | ||
|
|
2a92ea8862 | ||
|
|
681fc3cee5 | ||
|
|
916dd3ec26 | ||
|
|
692f7f3cde | ||
|
|
bf20f3d86e | ||
|
|
b7e720133d | ||
|
|
e914337e57 | ||
|
|
6364bac1f2 | ||
|
|
38a3e20427 | ||
|
|
334d75f2dd | ||
|
|
42eb783395 | ||
|
|
84b219957e | ||
|
|
f5c1ef36ce | ||
|
|
fae4fb0fed | ||
|
|
1d8729ec53 | ||
|
|
c6ef8a259f | ||
|
|
0efef5a789 | ||
|
|
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 | ||
|
|
ec4b5ab46a | ||
|
|
cd6c142324 | ||
|
|
0ebf62b564 | ||
|
|
16f3442a11 | ||
|
|
3328e686ee | ||
|
|
f60bdb0a8e | ||
|
|
5eed3e787b | ||
|
|
5ebc845a1f | ||
|
|
03c1cd1dc8 | ||
|
|
db6d5ca4b5 | ||
|
|
8d606aa456 | ||
|
|
a993299cb5 | ||
|
|
8bcd172c5a | ||
|
|
4d898b3e20 | ||
|
|
f17329b0ff | ||
|
|
2757d82007 | ||
|
|
340c1f1ae5 | ||
|
|
09c17c03b9 | ||
|
|
9d648e3404 | ||
|
|
e615979757 | ||
|
|
ea2ce4047f | ||
|
|
2a87a4d82a | ||
|
|
abf9b5f8c9 | ||
|
|
aea1ceb6be | ||
|
|
20a69a25bc | ||
|
|
e0584af365 | ||
|
|
c4034c6467 | ||
|
|
ccc82e5802 | ||
|
|
13d1804e66 | ||
|
|
62486534e4 | ||
|
|
da9469c5aa | ||
|
|
a7b77ffa25 | ||
|
|
bcf82252ea | ||
|
|
7c0a2280a4 | ||
|
|
bae7ff8752 | ||
|
|
2a57055f81 | ||
|
|
ad92f0c2ed | ||
|
|
d425332eb0 | ||
|
|
3c1a600994 | ||
|
|
673ab15ad4 | ||
|
|
95218676db | ||
|
|
defa633f92 | ||
|
|
841dfa8a61 | ||
|
|
bf5f34be0d | ||
|
|
e8d918ba98 | ||
|
|
c71af9a8a5 | ||
|
|
d8f540cdb1 | ||
|
|
18b1adb4e2 | ||
|
|
5d5334afb1 | ||
|
|
2ca662e971 | ||
|
|
e417d3c771 | ||
|
|
b6765b074e | ||
|
|
9d7db57c6a | ||
|
|
450964fb1a | ||
|
|
8e4132200d | ||
|
|
fc10db3b0a | ||
|
|
2bcaf15fe8 | ||
|
|
28750ab068 | ||
|
|
69f808e180 |
20
.eslintrc.cjs
Normal file
@@ -0,0 +1,20 @@
|
||||
module.exports = {
|
||||
root: true,
|
||||
env: { browser: true, es2020: true },
|
||||
extends: [
|
||||
'eslint:recommended',
|
||||
'plugin:@typescript-eslint/recommended',
|
||||
'plugin:react-hooks/recommended',
|
||||
],
|
||||
ignorePatterns: ['dist', '.eslintrc.cjs'],
|
||||
parser: '@typescript-eslint/parser',
|
||||
plugins: ['react-refresh'],
|
||||
rules: {
|
||||
'react-refresh/only-export-components': [
|
||||
'warn',
|
||||
{ allowConstantExport: true },
|
||||
],
|
||||
'@typescript-eslint/no-explicit-any': 'warn',
|
||||
'@typescript-eslint/no-unused-vars': ['warn', { argsIgnorePattern: '^_' }],
|
||||
},
|
||||
};
|
||||
35
.github/workflows/release.yml
vendored
@@ -15,6 +15,9 @@ jobs:
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
fetch-tags: true
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
@@ -36,27 +39,25 @@ jobs:
|
||||
mv index.html management.html
|
||||
ls -lh management.html
|
||||
|
||||
- name: Generate release notes
|
||||
run: |
|
||||
set -euo pipefail
|
||||
current_tag="${GITHUB_REF_NAME}"
|
||||
previous_tag="$(git tag --list 'v*' --sort=-v:refname | grep -v "^${current_tag}$" | head -n 1 || true)"
|
||||
if [ -n "${previous_tag}" ]; then
|
||||
range="${previous_tag}..${current_tag}"
|
||||
else
|
||||
range="${current_tag}"
|
||||
fi
|
||||
|
||||
: > release-notes.md
|
||||
git log --pretty=format:"- %h %s" "${range}" >> release-notes.md
|
||||
|
||||
- name: Create Release
|
||||
uses: softprops/action-gh-release@v1
|
||||
with:
|
||||
files: dist/management.html
|
||||
body: |
|
||||
## CLI Proxy API Management Center - ${{ github.ref_name }}
|
||||
|
||||
### Download and Usage
|
||||
1. Download the `management.html` file
|
||||
2. Open it directly in your browser
|
||||
3. All assets (CSS, JavaScript, images) are bundled into this single file
|
||||
|
||||
### Features
|
||||
- Single file, no external dependencies required
|
||||
- Complete management interface for CLI Proxy API
|
||||
- Support for local and remote connections
|
||||
- Multi-language support (Chinese/English)
|
||||
- Dark/Light theme support
|
||||
|
||||
---
|
||||
🤖 Generated with GitHub Actions
|
||||
body_path: release-notes.md
|
||||
draft: false
|
||||
prerelease: false
|
||||
env:
|
||||
|
||||
60
.gitignore
vendored
@@ -1,28 +1,36 @@
|
||||
# Node modules
|
||||
node_modules/
|
||||
|
||||
# Build output
|
||||
dist/
|
||||
|
||||
# Temporary build files
|
||||
index.build.html
|
||||
|
||||
# npm lock files
|
||||
package-lock.json
|
||||
|
||||
# IDE and editor files
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
|
||||
# OS files
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
api.md
|
||||
usage.json
|
||||
CLAUDE.md
|
||||
.claude
|
||||
AGENTS.md
|
||||
.codex
|
||||
.serena
|
||||
antigravity_usage.json
|
||||
codex_usage.json
|
||||
style.md
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
skills
|
||||
|
||||
# Editor directories and files
|
||||
settings.local.json
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
tmpclaude*
|
||||
.claude
|
||||
CLIProxyAPI
|
||||
9
.prettierrc
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"semi": true,
|
||||
"trailingComma": "es5",
|
||||
"singleQuote": true,
|
||||
"printWidth": 100,
|
||||
"tabWidth": 2,
|
||||
"useTabs": false,
|
||||
"arrowParens": "always"
|
||||
}
|
||||
@@ -1,69 +0,0 @@
|
||||
# Build and Release Instructions
|
||||
|
||||
## Overview
|
||||
|
||||
This project uses webpack to bundle all HTML, CSS, JavaScript, and images into a single all-in-one HTML file. The GitHub workflow automatically builds and releases this file when you create a new tag.
|
||||
|
||||
## How to Create a Release
|
||||
|
||||
1. Make sure all your changes are committed
|
||||
2. Create and push a new tag:
|
||||
```bash
|
||||
git tag v1.0.0
|
||||
git push origin v1.0.0
|
||||
```
|
||||
3. The GitHub workflow will automatically:
|
||||
- Install dependencies
|
||||
- Build the all-in-one HTML file using webpack
|
||||
- Create a new release with the tag
|
||||
- Upload the bundled HTML file to the release
|
||||
|
||||
## Manual Build
|
||||
|
||||
To build locally:
|
||||
|
||||
```bash
|
||||
# Install dependencies
|
||||
npm install
|
||||
|
||||
# Build the all-in-one HTML file
|
||||
npm run build
|
||||
```
|
||||
|
||||
The output will be in the `dist/` directory as `index.html`.
|
||||
|
||||
## How It Works
|
||||
|
||||
1. **build-scripts/prepare-html.js**: Pre-build script
|
||||
- Reads the original `index.html`
|
||||
- Removes local CSS and JavaScript references
|
||||
- Generates temporary `index.build.html` for webpack
|
||||
|
||||
2. **webpack.config.js**: Configures webpack to bundle all assets
|
||||
- Uses `style-loader` to inline CSS
|
||||
- Uses `asset/inline` to embed images as base64
|
||||
- Uses `html-inline-script-webpack-plugin` to inline JavaScript
|
||||
- Uses `index.build.html` as template (generated dynamically)
|
||||
|
||||
3. **bundle-entry.js**: Entry point that imports all resources
|
||||
- Imports CSS files
|
||||
- Imports JavaScript modules
|
||||
- Imports and sets logo image
|
||||
|
||||
4. **package.json scripts**:
|
||||
- `prebuild`: Automatically runs before build to generate `index.build.html`
|
||||
- `build`: Runs webpack to bundle everything
|
||||
- `postbuild`: Cleans up temporary `index.build.html` file
|
||||
|
||||
5. **.github/workflows/release.yml**: GitHub workflow
|
||||
- Triggers on tag push
|
||||
- Builds the project (prebuild → build → postbuild)
|
||||
- Creates a release with the bundled HTML file
|
||||
|
||||
## External Dependencies
|
||||
|
||||
The bundled HTML file still relies on these CDN resources:
|
||||
- Font Awesome (icons)
|
||||
- Chart.js (charts and graphs)
|
||||
|
||||
These are loaded from CDN to keep the file size reasonable and leverage browser caching.
|
||||
21
LICENSE
@@ -1,21 +0,0 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2025 Supra4E8C
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
376
README.md
@@ -1,107 +1,305 @@
|
||||
# Cli-Proxy-API-Management-Center
|
||||
This is the modern WebUI for managing the CLI Proxy API.
|
||||
# CLI Proxy API 管理中心 (CPAMC)
|
||||
|
||||
[中文文档](README_CN.md)
|
||||
> 一个基于官方仓库二次创作的 Web 管理界面
|
||||
|
||||
Main Project: https://github.com/router-for-me/CLIProxyAPI
|
||||
Example URL: https://remote.router-for.me/
|
||||
Minimum required version: ≥ 6.3.0 (recommended ≥ 6.5.0)
|
||||
**[English](README_EN.md) | [中文](README.md)**
|
||||
|
||||
Since 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
|
||||
## 关于本项目
|
||||
|
||||
### Capabilities
|
||||
- **Login & UX**: Auto-detects the current address (manual override/reset supported), encrypted auto-login, language/theme toggles, responsive layout with mobile sidebar.
|
||||
- **Basic Settings**: Debug, proxy URL, request retries, quota fallback (auto-switch project/preview models), usage-statistics toggle, request logging & logging-to-file switches, WebSocket `/ws/*` auth switch.
|
||||
- **Keys & Providers**: Manage proxy auth keys, Gemini/Codex/Claude configs, OpenAI-compatible providers (custom base URLs/headers/proxy/model aliases), Vertex AI credential import from service-account JSON with optional location.
|
||||
- **Auth Files & OAuth**: Upload/download/search/paginate JSON credentials; type filters (Qwen/Gemini/GeminiCLI/AIStudio/Claude/Codex/Antigravity/iFlow/Vertex/Empty); delete-all; OAuth/Device flows for Codex, Anthropic (Claude), Antigravity (Google), Gemini CLI (optional project), Qwen; iFlow OAuth and cookie login.
|
||||
- **Logs**: Live viewer with auto-refresh/incremental updates, download and clear; section appears when logging-to-file is enabled.
|
||||
- **Usage Analytics**: Overview cards, hourly/daily toggles, up to three model lines per chart, per-API stats table (Chart.js).
|
||||
- **Config Management**: In-browser CodeMirror YAML editor for `/config.yaml` with reload/save, syntax highlighting, and status feedback.
|
||||
- **System Info & Versioning**: Connection/config cache status, last refresh time, server version/build date, and UI version in the footer.
|
||||
- **Security & Preferences**: Masked secrets, secure local storage, persistent theme/language/sidebar state, real-time status feedback.
|
||||
本项目是基于官方 [CLI Proxy API WebUI](https://github.com/router-for-me/Cli-Proxy-API-Management-Center) 进行开发的日志监控和数据可视化管理界面
|
||||
|
||||
## How to Use
|
||||
### 与官方版本的区别
|
||||
|
||||
1) **After CLI Proxy API is running (recommended)**
|
||||
Visit `http://your-server:8317/management.html`.
|
||||
本版本与官方版本其他功能保持一致,主要差异在于**新增监控中心**,对日志分析和查看的增强
|
||||
|
||||
2) **Direct static use after build**
|
||||
The single file `dist/index.html` generated by `npm run build`
|
||||
### 界面预览
|
||||
|
||||
3) **Local server**
|
||||
```bash
|
||||
npm install
|
||||
npm start # http://localhost:3000
|
||||
npm run dev # optional dev port: 3090
|
||||
# or
|
||||
python -m http.server 8000
|
||||
```
|
||||
Then open the corresponding localhost URL.
|
||||
管理界面展示
|
||||
|
||||
4) **Configure connection**
|
||||
The login page shows the detected address; you can override it, enter the management key, and click Connect. Saved credentials use encrypted local storage for auto-login.
|
||||

|
||||
|
||||
Tip: The Logs navigation item appears after enabling "Logging to file" in Basic Settings.
|
||||
---
|
||||
|
||||
## Tech Stack
|
||||
## 快速开始
|
||||
|
||||
- **Frontend**: Plain HTML, CSS, JavaScript (ES6+)
|
||||
- **Styling**: CSS3 + Flexbox/Grid with CSS Variables
|
||||
- **Icons**: Font Awesome 6.4.0
|
||||
- **Charts**: Chart.js for interactive data visualization
|
||||
- **Editor/Parsing**: CodeMirror + js-yaml
|
||||
- **Fonts**: Segoe UI system font
|
||||
- **Internationalization**: Custom i18n (EN/CN) and theme system (light/dark)
|
||||
- **API**: RESTful management endpoints with automatic authentication
|
||||
- **Storage**: LocalStorage with lightweight encryption for preferences/credentials
|
||||
### 使用本管理界面
|
||||
|
||||
## Build & Development
|
||||
在你的 `config.yaml` 中修改以下配置:
|
||||
|
||||
- `npm run build` bundles everything into `dist/index.html` via webpack (`build.cjs`, `bundle-entry.js`, `build-scripts/prepare-html.js`).
|
||||
- External CDNs remain for Font Awesome, Chart.js, and CodeMirror to keep the bundle lean.
|
||||
- Development servers: `npm start` (3000) or `npm run dev` (3090); Python `http.server` also works for static hosting.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Connection Issues
|
||||
1. Confirm that the CLI Proxy API service is running.
|
||||
2. Check if the API address is correct.
|
||||
3. Verify that the management key is valid.
|
||||
4. Ensure your firewall settings allow the connection.
|
||||
|
||||
### Data Not Updating
|
||||
1. Click the "Refresh All" button.
|
||||
2. Check your network connection.
|
||||
3. Check the browser's console for any error messages.
|
||||
|
||||
### Logs & Config Editor
|
||||
- Logs: Requires server-side logging-to-file; 404 indicates the server build is too old or logging is disabled.
|
||||
- Config editor: Requires `/config.yaml` endpoint; keep YAML valid before saving.
|
||||
|
||||
### Usage Stats
|
||||
- Enable "Usage statistics" if charts stay empty; data resets on server restart.
|
||||
|
||||
## Project Structure
|
||||
```
|
||||
├── index.html
|
||||
├── styles.css
|
||||
├── app.js
|
||||
├── i18n.js
|
||||
├── src/ # Core/modules/utils source code
|
||||
├── build.cjs # Webpack build script
|
||||
├── bundle-entry.js # Bundling entry
|
||||
├── build-scripts/ # Build utilities
|
||||
│ └── prepare-html.js
|
||||
├── dist/ # Bundled single-file output
|
||||
├── BUILD_RELEASE.md
|
||||
├── LICENSE
|
||||
├── README.md
|
||||
└── README_CN.md
|
||||
```yaml
|
||||
remote-management:
|
||||
panel-github-repository: "https://github.com/kongkongyo/Cli-Proxy-API-Management-Center"
|
||||
```
|
||||
|
||||
## Contributing
|
||||
We welcome Issues and Pull Requests to improve this project! We encourage more developers to contribute to the enhancement of this WebUI!
|
||||
配置完成后,重启 CLI Proxy API 服务,访问 `http://<host>:<api_port>/management.html` 即可查看管理界面
|
||||
|
||||
This project is licensed under the MIT License.
|
||||
详细配置说明请参考官方文档:https://help.router-for.me/cn/management/webui.html
|
||||
|
||||
---
|
||||
|
||||
## 主要功能
|
||||
|
||||
### 监控中心 - 核心新增功能
|
||||
|
||||
这是本管理界面相对于官方版本的唯一新增功能,提供了全方位的数据可视化和监控能力
|
||||
|
||||
> 注意:CLI Proxy API 主程序目前没有数据持久化功能,重启程序后统计数据会丢失。需要先通过 API 使用相关服务产生数据后,才能在监控中心看到统计信息。
|
||||
|
||||
#### KPI 指标仪表盘
|
||||
|
||||
实时展示核心运营指标,支持按时间范围筛选:
|
||||
- **请求数**:总请求数、成功/失败统计、成功率百分比
|
||||
- **Token 数**:总 Token 数、输入 Token、输出 Token
|
||||
- **平均 TPM**:每分钟 Token 使用量
|
||||
- **平均 RPM**:每分钟请求数
|
||||
- **日均 RPD**:日均请求数
|
||||
|
||||
所有指标都会根据选择的时间范围(今天/7天/14天/30天)动态计算,实时更新
|
||||
|
||||
#### 模型用量分布
|
||||
|
||||
直观的饼图展示不同模型的使用占比:
|
||||
- 按请求数分布
|
||||
- 按 Token 数分布
|
||||
- 可切换查看请求占比或 Token 占比
|
||||
|
||||
#### 每日趋势分析
|
||||
|
||||
详细的时间序列图表,展示每日用量变化趋势:
|
||||
- 请求数趋势曲线
|
||||
- 输入 Token 趋势
|
||||
- 输出 Token 趋势
|
||||
- 思考 Token 趋势(如支持)
|
||||
- 缓存 Token 趋势
|
||||
|
||||
#### 每小时分析
|
||||
|
||||
两个详细的小时级图表,帮助定位高峰时段:
|
||||
|
||||
**每小时模型请求分布**
|
||||
- 柱状图展示不同模型在各小时的请求数
|
||||
- 支持最近 6 小时/12 小时/24 小时/全部视图切换
|
||||
|
||||
**每小时 Token 用量**
|
||||
- 堆叠柱状图展示 Token 使用构成
|
||||
- 区分输入 Token、输出 Token、思考 Token、缓存 Token
|
||||
|
||||
#### 渠道统计
|
||||
|
||||
详细表格展示各渠道(API Key/模型)的使用情况:
|
||||
- 可按全部渠道/特定渠道筛选
|
||||
- 可按全部模型/特定模型筛选
|
||||
- 可按全部状态/仅成功/仅失败筛选
|
||||
- 显示渠道名称、请求数、成功率
|
||||
- 点击展开查看该渠道下各模型的详细统计
|
||||
- 显示最近请求状态(最近 10 次请求的迷你状态条)
|
||||
- 最近请求时间
|
||||
|
||||
#### 失败来源分析
|
||||
|
||||
帮助定位问题渠道和模型:
|
||||
- 按渠道统计失败次数
|
||||
- 显示最近失败时间
|
||||
- 列出主要失败的模型
|
||||
- 点击展开查看该渠道下所有失败的请求详情
|
||||
|
||||
#### 请求日志 - 高级功能
|
||||
|
||||
功能强大的请求日志表格,支持海量数据流畅浏览
|
||||
|
||||
**多维度筛选**
|
||||
- 按 API Key 筛选
|
||||
- 按提供商类型筛选(OpenAI/Gemini/Claude 等)
|
||||
- 按模型名称筛选
|
||||
- 按来源渠道筛选
|
||||
- 按请求状态筛选(全部/成功/失败)
|
||||
|
||||
**独立时间范围**
|
||||
- 支持今天/7天/14天/30天/自定义日期范围
|
||||
- 与主页面时间范围独立控制
|
||||
|
||||
**虚拟滚动**
|
||||
- 支持 10 万+ 条日志流畅浏览
|
||||
- 显示当前可见范围统计
|
||||
- 性能优化,只渲染可见行
|
||||
|
||||
**智能信息展示**
|
||||
- 自动匹配 API Key 到提供商名称(基于配置信息)
|
||||
- 完整的渠道信息(提供商名称 + 掩码后的密钥)
|
||||
- 请求类型/模型名称/请求状态
|
||||
- 最近 10 次请求的状态可视化(绿点=成功,红点=失败)
|
||||
- 成功率百分比
|
||||
- 总请求数/输入 Token/输出 Token/总 Token
|
||||
- 请求时间(完整时间戳)
|
||||
|
||||
**自动刷新**
|
||||
- 支持手动刷新 / 5秒 / 10秒 / 15秒 / 30秒 / 60秒 自动刷新
|
||||
- 倒计时显示下次刷新时间
|
||||
- 独立数据加载,不阻塞主页面
|
||||
|
||||
**一键禁用模型**
|
||||
- 支持直接在日志中禁用某渠道的某个模型
|
||||
- 只对支持该操作的渠道类型生效
|
||||
- 不支持时显示提示和手动操作指南
|
||||
|
||||
---
|
||||
|
||||
## 官方版本功能
|
||||
|
||||
以下功能与官方版本一致,通过改进的界面提供更好的使用体验
|
||||
|
||||
### 仪表盘
|
||||
- 连接状态实时监控
|
||||
- 服务器版本和构建信息一目了然
|
||||
- 使用数据快速概览,掌握全局
|
||||
- 可用模型统计
|
||||
|
||||
### API 密钥管理
|
||||
- 添加、编辑、删除 API 密钥
|
||||
- 管理代理服务认证
|
||||
|
||||
### AI 提供商配置
|
||||
- **Gemini**:API 密钥管理、排除模型、模型前缀
|
||||
- **Claude**:API 密钥和配置、自定义模型列表
|
||||
- **Codex**:完整配置管理(API 密钥、Base URL、代理)
|
||||
- **Vertex**:模型映射配置
|
||||
- **OpenAI 兼容**:多密钥管理、模型别名导入、连通性测试
|
||||
- **Ampcode**:上游集成和模型映射
|
||||
|
||||
### 认证文件管理
|
||||
- 上传、下载、删除 JSON 认证文件
|
||||
- 支持多种提供商(Qwen、Gemini、Claude 等)
|
||||
- 搜索、筛选、分页浏览
|
||||
- 查看每个凭证支持的模型
|
||||
|
||||
### OAuth 登录
|
||||
- 一键启动 OAuth 授权流程
|
||||
- 支持 Codex、Anthropic、Gemini CLI、Qwen、iFlow 等
|
||||
- 自动保存认证文件
|
||||
- 支持远程浏览器回调提交
|
||||
|
||||
### 配额管理
|
||||
- Antigravity 额度查询
|
||||
- Codex 额度查询(5 小时、周限额、代码审查)
|
||||
- Gemini CLI 额度查询
|
||||
- 一键刷新所有额度
|
||||
|
||||
### 使用统计
|
||||
- 请求/Token 趋势图表
|
||||
- 按模型和 API 的详细统计
|
||||
- RPM/TPM 实时速率
|
||||
- 缓存和推理 Token 分解
|
||||
- 成本估算(支持自定义价格)
|
||||
|
||||
### 配置管理
|
||||
- 在线编辑 `config.yaml`
|
||||
- YAML 语法高亮
|
||||
- 搜索和导航
|
||||
- 保存和重载配置
|
||||
|
||||
### 日志查看
|
||||
- 实时日志流
|
||||
- 搜索和过滤
|
||||
- 自动刷新
|
||||
- 下载错误日志
|
||||
- 屏蔽管理端流量
|
||||
|
||||
### 中心信息
|
||||
- 连接状态检查
|
||||
- 版本更新检查
|
||||
- 可用模型列表展示
|
||||
- 快捷链接入口
|
||||
|
||||
---
|
||||
|
||||
## 连接说明
|
||||
|
||||
### API 地址格式
|
||||
|
||||
以下格式都可以,系统会自动识别
|
||||
|
||||
```
|
||||
localhost:8317
|
||||
http://192.168.1.10:8317
|
||||
https://example.com:8317
|
||||
```
|
||||
|
||||
### 管理密钥
|
||||
|
||||
管理密钥是验证管理操作的钥匙,和客户端使用的 API 密钥不一样
|
||||
|
||||
### 远程管理
|
||||
|
||||
从非本地浏览器访问的时候,需要在服务器启用远程管理(`allow-remote-management: true`)
|
||||
|
||||
---
|
||||
|
||||
## 界面特性
|
||||
|
||||
### 主题切换
|
||||
- 亮色模式
|
||||
- 暗色模式
|
||||
- 跟随系统
|
||||
|
||||
### 语言支持
|
||||
- 简体中文
|
||||
- English
|
||||
|
||||
### 响应式设计
|
||||
- 桌面端完整功能
|
||||
- 移动端适配体验
|
||||
- 侧边栏可折叠
|
||||
|
||||
---
|
||||
|
||||
## 常见问题
|
||||
|
||||
**Q: 如何使用这个自定义 UI?**
|
||||
|
||||
A: 在 CLI Proxy API 的配置文件中添加以下配置即可
|
||||
```yaml
|
||||
remote-management:
|
||||
panel-github-repository: "https://github.com/kongkongyo/CLIProxyAPI-Web-Dashboard"
|
||||
```
|
||||
|
||||
**Q: 无法连接到服务器?**
|
||||
|
||||
A: 请检查以下内容
|
||||
- API 地址是否正确
|
||||
- 管理密钥是否正确
|
||||
- 服务器是否启动
|
||||
- 远程访问是否启用
|
||||
|
||||
**Q: 日志页面不显示?**
|
||||
|
||||
A: 需要去"基础设置"里开启"日志记录到文件"功能
|
||||
|
||||
**Q: 某些功能显示"不支持"?**
|
||||
|
||||
A: 可能是服务器版本太旧,升级到最新版本的 CLI Proxy API
|
||||
|
||||
**Q: OpenAI 提供商测试失败?**
|
||||
|
||||
A: 测试是在浏览器端执行的,可能会受到 CORS 限制,失败不一定代表服务器端不能用
|
||||
|
||||
**Q: 这个版本和官方版本有什么区别?**
|
||||
|
||||
A: 主要区别有两个:
|
||||
1. **界面风格**:全新的视觉设计,UI 细节更精致
|
||||
2. **监控中心**:这是唯一新增的功能模块,提供了强大的数据可视化和监控能力,包括 KPI 仪表盘、模型用量分布、趋势分析、小时级图表、渠道统计、失败分析和高级请求日志等功能
|
||||
|
||||
其他所有功能与官方版本保持一致
|
||||
|
||||
---
|
||||
|
||||
## 相关链接
|
||||
|
||||
- **官方主程序**: https://github.com/router-for-me/CLIProxyAPI
|
||||
- **官方 WebUI**: https://github.com/router-for-me/Cli-Proxy-API-Management-Center
|
||||
- **本仓库**: https://github.com/kongkongyo/CLIProxyAPI-Web-Dashboard
|
||||
|
||||
## 许可证
|
||||
|
||||
MIT License
|
||||
|
||||
106
README_CN.md
@@ -1,106 +0,0 @@
|
||||
# Cli-Proxy-API-Management-Center
|
||||
这是一个用于管理 CLI Proxy API 的现代化 Web 界面。
|
||||
|
||||
[English](README.md)
|
||||
|
||||
主项目: https://github.com/router-for-me/CLIProxyAPI
|
||||
示例网站: https://remote.router-for.me/
|
||||
最低版本 ≥ 6.3.0(推荐 ≥ 6.5.0)
|
||||
|
||||
自 6.0.19 起 WebUI 已集成到主程序中,启动后可通过 `/management.html` 访问。
|
||||
|
||||
## 功能特点
|
||||
|
||||
### 主要能力
|
||||
- **登录与体验**: 自动检测当前地址(可自定义/重置),加密自动登录,语言/主题切换,响应式布局与移动端侧边栏。
|
||||
- **基础设置**: 调试、代理 URL、请求重试,配额溢出自动切换项目/预览模型,使用统计开关,请求日志与文件日志开关,WebSocket `/ws/*` 鉴权开关。
|
||||
- **密钥与提供商**: 管理代理服务密钥,Gemini/Codex/Claude 配置,OpenAI 兼容提供商(自定义 Base URL/Headers/Proxy/模型别名),Vertex AI 服务账号导入(可选区域)。
|
||||
- **认证文件与 OAuth**: 上传/下载/搜索/分页 JSON 凭据,类型筛选(Qwen/Gemini/GeminiCLI/AIStudio/Claude/Codex/Antigravity/iFlow/Vertex/Empty),一键删除全部;Codex、Anthropic(Claude)、Antigravity(Google)、Gemini CLI(可选项目)、Qwen 设备码、iFlow OAuth 与 Cookie 登录。
|
||||
- **日志**: 实时查看并增量刷新,支持下载和清空;启用“写入日志文件”后出现日志栏目。
|
||||
- **使用统计**: 概览卡片、小时/天切换、最多三条模型曲线、按 API 统计表(Chart.js)。
|
||||
- **配置管理**: 内置 CodeMirror YAML 编辑器,在线读取/保存 `/config.yaml`,语法高亮与状态提示。
|
||||
- **系统与版本**: 连接/配置缓存状态、最后刷新时间,底栏显示服务版本、构建时间与 UI 版本。
|
||||
- **安全与偏好**: 密钥遮蔽、加密本地存储,主题/语言/侧边栏状态持久化,实时状态反馈。
|
||||
|
||||
## 使用方法
|
||||
|
||||
1) **主程序启动后使用(推荐)**
|
||||
访问 `http://您的服务器:8317/management.html`。
|
||||
|
||||
2) **构建后直接静态打开**
|
||||
`npm run build` 生成的 `dist/index.html` 单文件
|
||||
|
||||
3) **本地服务器**
|
||||
```bash
|
||||
npm install
|
||||
npm start # 默认 http://localhost:3000
|
||||
npm run dev # 可选开发端口 3090
|
||||
# 或
|
||||
python -m http.server 8000
|
||||
```
|
||||
然后在浏览器打开对应的 localhost 地址。
|
||||
|
||||
4) **配置连接**
|
||||
登录页会显示自动检测的地址,可自行修改,填入管理密钥后点击连接。凭据将加密保存以便下次自动登录。
|
||||
|
||||
提示: 开启“写入日志文件”后才会显示“日志查看”导航。
|
||||
|
||||
## 技术栈
|
||||
|
||||
- **前端**: 纯 HTML、CSS、JavaScript (ES6+)
|
||||
- **样式**: CSS3 + Flexbox/Grid,支持 CSS 变量
|
||||
- **图标**: Font Awesome 6.4.0
|
||||
- **图表**: Chart.js 交互式数据可视化
|
||||
- **编辑/解析**: CodeMirror + js-yaml
|
||||
- **国际化**: 自定义 i18n(中/英)与主题系统(明/暗)
|
||||
- **API**: RESTful 管理接口,自动附加认证
|
||||
- **存储**: LocalStorage 轻量加密存储偏好与凭据
|
||||
|
||||
## 构建与开发
|
||||
|
||||
- `npm run build` 通过 webpack(`build.cjs`、`bundle-entry.js`、`build-scripts/prepare-html.js`)打包为 `dist/index.html`。
|
||||
- Font Awesome、Chart.js、CodeMirror 仍走 CDN,减小打包体积。
|
||||
- 开发可用 `npm start` (3000) / `npm run dev` (3090) 或 `python -m http.server` 静态托管。
|
||||
|
||||
## 故障排除
|
||||
|
||||
### 连接问题
|
||||
1. 确认 CLI Proxy API 服务正在运行
|
||||
2. 检查 API 地址是否正确
|
||||
3. 验证管理密钥是否有效
|
||||
4. 确认防火墙设置允许连接
|
||||
|
||||
### 数据不更新
|
||||
1. 点击"刷新全部"按钮
|
||||
2. 检查网络连接
|
||||
3. 查看浏览器控制台错误信息
|
||||
|
||||
### 日志与配置编辑
|
||||
- 日志: 需要服务端开启写文件日志;返回 404 说明版本过旧或未启用。
|
||||
- 配置编辑: 依赖 `/config.yaml` 接口,保存前请确保 YAML 语法正确。
|
||||
|
||||
### 使用统计
|
||||
- 若图表为空,请开启“使用统计”;数据在服务重启后会清空。
|
||||
|
||||
## 项目结构
|
||||
```
|
||||
├── index.html
|
||||
├── styles.css
|
||||
├── app.js
|
||||
├── i18n.js
|
||||
├── src/ # 核心/模块/工具源码
|
||||
├── build.cjs # Webpack 构建脚本
|
||||
├── bundle-entry.js # 打包入口
|
||||
├── build-scripts/ # 构建工具
|
||||
│ └── prepare-html.js
|
||||
├── dist/ # 打包输出单文件
|
||||
├── BUILD_RELEASE.md
|
||||
├── LICENSE
|
||||
├── README.md
|
||||
└── README_CN.md
|
||||
```
|
||||
|
||||
## 贡献
|
||||
欢迎提交 Issue 和 Pull Request 来改进这个项目!我们欢迎更多的大佬来对这个 WebUI 进行更新!
|
||||
|
||||
本项目采用 MIT 许可。
|
||||
305
README_EN.md
Normal file
@@ -0,0 +1,305 @@
|
||||
# CLI Proxy API Management Center (CPAMC)
|
||||
|
||||
> A Web management interface based on the official repository with custom modifications
|
||||
|
||||
**[English](README_EN.md) | [中文](README.md)**
|
||||
|
||||
---
|
||||
|
||||
## About This Project
|
||||
|
||||
This project is a log monitoring and data visualization management interface developed based on the official [CLI Proxy API WebUI](https://github.com/router-for-me/Cli-Proxy-API-Management-Center)
|
||||
|
||||
### Differences from Official Version
|
||||
|
||||
This version is consistent with the official version in other functions, with the main difference being the **new monitoring center**, which enhances log analysis and viewing
|
||||
|
||||
### Interface Preview
|
||||
|
||||
Management interface display
|
||||
|
||||

|
||||
|
||||
---
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Using This Management Interface
|
||||
|
||||
Modify following configuration in your `config.yaml`:
|
||||
|
||||
```yaml
|
||||
remote-management:
|
||||
panel-github-repository: "https://github.com/kongkongyo/CLIProxyAPI-Web-Dashboard"
|
||||
```
|
||||
|
||||
After configuration, restart the CLI Proxy API service and visit `http://<host>:<api_port>/management.html` to view the management interface
|
||||
|
||||
For detailed configuration instructions, please refer to the official documentation: https://help.router-for.me/cn/management/webui.html
|
||||
|
||||
---
|
||||
|
||||
## Main Features
|
||||
|
||||
### Monitoring Center - Core New Feature
|
||||
|
||||
This is the only new feature of this management interface compared to the official version, providing comprehensive data visualization and monitoring capabilities
|
||||
|
||||
> Note: The CLI Proxy API main program currently does not have data persistence functionality. Statistical data will be lost after restarting the program. You need to use related services through the API first to generate data before you can see statistical information in the monitoring center.
|
||||
|
||||
#### KPI Dashboard
|
||||
|
||||
Real-time display of core operational metrics, supports filtering by time range:
|
||||
- **Request Count**: Total requests, success/failure statistics, success rate percentage
|
||||
- **Token Count**: Total tokens, input tokens, output tokens
|
||||
- **Average TPM**: Tokens per minute
|
||||
- **Average RPM**: Requests per minute
|
||||
- **Average RPD**: Daily average requests
|
||||
|
||||
All metrics are dynamically calculated and updated in real-time based on the selected time range (today/7 days/14 days/30 days)
|
||||
|
||||
#### Model Usage Distribution
|
||||
|
||||
Intuitive pie chart showing the usage distribution of different models:
|
||||
- Distribution by request count
|
||||
- Distribution by token count
|
||||
- Switchable between request percentage and token percentage
|
||||
|
||||
#### Daily Trend Analysis
|
||||
|
||||
Detailed time series charts showing daily usage trends:
|
||||
- Request count trend curve
|
||||
- Input token trend
|
||||
- Output token trend
|
||||
- Thinking token trend (if supported)
|
||||
- Cache token trend
|
||||
|
||||
#### Hourly Analysis
|
||||
|
||||
Two detailed hourly charts to help identify peak periods:
|
||||
|
||||
**Hourly Model Request Distribution**
|
||||
- Bar chart showing requests for different models in each hour
|
||||
- Supports switching between recent 6 hours/12 hours/24 hours/all views
|
||||
|
||||
**Hourly Token Usage**
|
||||
- Stacked bar chart showing token usage composition
|
||||
- Distinguishes between input tokens, output tokens, thinking tokens, cache tokens
|
||||
|
||||
#### Channel Statistics
|
||||
|
||||
Detailed table showing usage of each channel (API Key/model):
|
||||
- Filter by all channels/specific channel
|
||||
- Filter by all models/specific model
|
||||
- Filter by all status/success only/failure only
|
||||
- Display channel name, request count, success rate
|
||||
- Click to expand and view detailed statistics of each model under that channel
|
||||
- Display recent request status (mini status bar of recent 10 requests)
|
||||
- Most recent request time
|
||||
|
||||
#### Failure Source Analysis
|
||||
|
||||
Help locate problematic channels and models:
|
||||
- Statistics of failure counts by channel
|
||||
- Display most recent failure time
|
||||
- List of main failed models
|
||||
- Click to expand and view all failed request details under that channel
|
||||
|
||||
#### Request Logs - Advanced Feature
|
||||
|
||||
Powerful request log table, supports smooth browsing of massive data
|
||||
|
||||
**Multi-dimensional Filtering**
|
||||
- Filter by API Key
|
||||
- Filter by provider type (OpenAI/Gemini/Claude, etc.)
|
||||
- Filter by model name
|
||||
- Filter by source channel
|
||||
- Filter by request status (all/success/failure)
|
||||
|
||||
**Independent Time Range**
|
||||
- Supports today/7 days/14 days/30 days/custom date range
|
||||
- Independent control from main page time range
|
||||
|
||||
**Virtual Scrolling**
|
||||
- Supports smooth browsing of 100,000+ logs
|
||||
- Display current visible range statistics
|
||||
- Performance optimized, only renders visible rows
|
||||
|
||||
**Smart Information Display**
|
||||
- Automatically match API Key to provider name (based on configuration)
|
||||
- Complete channel information (provider name + masked key)
|
||||
- Request type/model name/request status
|
||||
- Status visualization of recent 10 requests (green dot=success, red dot=failure)
|
||||
- Success rate percentage
|
||||
- Total requests/input tokens/output tokens/total tokens
|
||||
- Request time (complete timestamp)
|
||||
|
||||
**Auto Refresh**
|
||||
- Supports manual refresh / 5s / 10s / 15s / 30s / 60s auto refresh
|
||||
- Countdown display for next refresh time
|
||||
- Independent data loading, does not block main page
|
||||
|
||||
**One-click Disable Model**
|
||||
- Supports directly disabling a specific model of a channel in logs
|
||||
- Only effective for channel types that support this operation
|
||||
- Shows prompt and manual operation guide when not supported
|
||||
|
||||
---
|
||||
|
||||
## Official Version Features
|
||||
|
||||
The following features are consistent with the official version, providing a better user experience through an improved interface
|
||||
|
||||
### Dashboard
|
||||
- Real-time connection status monitoring
|
||||
- Server version and build information at a glance
|
||||
- Quick overview of usage data
|
||||
- Available model statistics
|
||||
|
||||
### API Key Management
|
||||
- Add, edit, delete API keys
|
||||
- Manage proxy service authentication
|
||||
|
||||
### AI Provider Configuration
|
||||
- **Gemini**: API key management, model exclusion, model prefix
|
||||
- **Claude**: API key and configuration, custom model list
|
||||
- **Codex**: Complete configuration management (API key, Base URL, proxy)
|
||||
- **Vertex**: Model mapping configuration
|
||||
- **OpenAI Compatible**: Multi-key management, model alias import, connectivity testing
|
||||
- **Ampcode**: Upstream integration and model mapping
|
||||
|
||||
### Authentication File Management
|
||||
- Upload, download, delete JSON authentication files
|
||||
- Supports multiple providers (Qwen, Gemini, Claude, etc.)
|
||||
- Search, filter, paginated browsing
|
||||
- View models supported by each credential
|
||||
|
||||
### OAuth Login
|
||||
- One-click start OAuth authorization flow
|
||||
- Supports Codex, Anthropic, Gemini CLI, Qwen, iFlow, etc.
|
||||
- Automatically save authentication files
|
||||
- Supports remote browser callback submission
|
||||
|
||||
### Quota Management
|
||||
- Antigravity quota query
|
||||
- Codex quota query (5 hours, weekly limit, code review)
|
||||
- Gemini CLI quota query
|
||||
- One-click refresh all quotas
|
||||
|
||||
### Usage Statistics
|
||||
- Request/Token trend charts
|
||||
- Detailed statistics by model and API
|
||||
- RPM/TPM real-time rates
|
||||
- Cache and reasoning token breakdown
|
||||
- Cost estimation (supports custom prices)
|
||||
|
||||
### Configuration Management
|
||||
- Online editing of `config.yaml`
|
||||
- YAML syntax highlighting
|
||||
- Search and navigation
|
||||
- Save and reload configuration
|
||||
|
||||
### Log Viewing
|
||||
- Real-time log stream
|
||||
- Search and filtering
|
||||
- Auto refresh
|
||||
- Download error logs
|
||||
- Mask management traffic
|
||||
|
||||
### Center Information
|
||||
- Connection status check
|
||||
- Version update check
|
||||
- Available model list display
|
||||
- Quick link entry
|
||||
|
||||
---
|
||||
|
||||
## Connection Instructions
|
||||
|
||||
### API Address Format
|
||||
|
||||
The following formats are all supported, and the system will automatically recognize them
|
||||
|
||||
```
|
||||
localhost:8317
|
||||
http://192.168.1.10:8317
|
||||
https://example.com:8317
|
||||
```
|
||||
|
||||
### Management Key
|
||||
|
||||
The management key is the key for verifying management operations and is different from the API key used by clients
|
||||
|
||||
### Remote Management
|
||||
|
||||
When accessing from a non-local browser, you need to enable remote management on the server (`allow-remote-management: true`)
|
||||
|
||||
---
|
||||
|
||||
## Interface Features
|
||||
|
||||
### Theme Switching
|
||||
- Light mode
|
||||
- Dark mode
|
||||
- Follow system
|
||||
|
||||
### Language Support
|
||||
- Simplified Chinese
|
||||
- English
|
||||
|
||||
### Responsive Design
|
||||
- Full functionality on desktop
|
||||
- Mobile-adapted experience
|
||||
- Collapsible sidebar
|
||||
|
||||
---
|
||||
|
||||
## FAQ
|
||||
|
||||
**Q: How to use this custom UI?**
|
||||
|
||||
A: Add the following configuration to your CLI Proxy API configuration file
|
||||
```yaml
|
||||
remote-management:
|
||||
panel-github-repository: "https://github.com/kongkongyo/CLIProxyAPI-Web-Dashboard"
|
||||
```
|
||||
|
||||
**Q: Cannot connect to the server?**
|
||||
|
||||
A: Please check the following
|
||||
- Is the API address correct?
|
||||
- Is the management key correct?
|
||||
- Is the server started?
|
||||
- Is remote access enabled?
|
||||
|
||||
**Q: Log page not displaying?**
|
||||
|
||||
A: You need to enable the "Log to file" function in "Basic Settings"
|
||||
|
||||
**Q: Some functions show "not supported"?**
|
||||
|
||||
A: The server version may be too old. Upgrade to the latest version of CLI Proxy API
|
||||
|
||||
**Q: OpenAI provider test failed?**
|
||||
|
||||
A: Tests are executed in the browser and may be subject to CORS restrictions. Failure does not necessarily mean it won't work on the server side
|
||||
|
||||
**Q: What is the difference between this version and the official version?**
|
||||
|
||||
A: There are two main differences:
|
||||
1. **Interface Style**: Completely new visual design with more refined UI details
|
||||
2. **Monitoring Center**: This is the only newly added feature module, providing powerful data visualization and monitoring capabilities, including KPI dashboard, model usage distribution, trend analysis, hourly charts, channel statistics, failure analysis, and advanced request logs
|
||||
|
||||
All other features remain consistent with the official version
|
||||
|
||||
---
|
||||
|
||||
## Related Links
|
||||
|
||||
- **Official Main Program**: https://github.com/router-for-me/CLIProxyAPI
|
||||
- **Official WebUI**: https://github.com/router-for-me/Cli-Proxy-API-Management-Center
|
||||
- **This Repository**: https://github.com/kongkongyo/CLIProxyAPI-Web-Dashboard
|
||||
|
||||
## License
|
||||
|
||||
MIT License
|
||||
991
app.js
@@ -1,991 +0,0 @@
|
||||
// 模块导入
|
||||
import { themeModule } from './src/modules/theme.js';
|
||||
import { navigationModule } from './src/modules/navigation.js';
|
||||
import { languageModule } from './src/modules/language.js';
|
||||
import { loginModule } from './src/modules/login.js';
|
||||
import { configEditorModule } from './src/modules/config-editor.js';
|
||||
import { logsModule } from './src/modules/logs.js';
|
||||
import { apiKeysModule } from './src/modules/api-keys.js';
|
||||
import { authFilesModule } from './src/modules/auth-files.js';
|
||||
import { oauthModule } from './src/modules/oauth.js';
|
||||
import { usageModule } from './src/modules/usage.js';
|
||||
import { settingsModule } from './src/modules/settings.js';
|
||||
import { aiProvidersModule } from './src/modules/ai-providers.js';
|
||||
|
||||
// 工具函数导入
|
||||
import { escapeHtml } from './src/utils/html.js';
|
||||
import { maskApiKey, formatFileSize } from './src/utils/string.js';
|
||||
import { normalizeArrayResponse } from './src/utils/array.js';
|
||||
import { debounce } from './src/utils/dom.js';
|
||||
import {
|
||||
CACHE_EXPIRY_MS,
|
||||
MAX_LOG_LINES,
|
||||
LOG_FETCH_LIMIT,
|
||||
DEFAULT_AUTH_FILES_PAGE_SIZE,
|
||||
MIN_AUTH_FILES_PAGE_SIZE,
|
||||
MAX_AUTH_FILES_PAGE_SIZE,
|
||||
OAUTH_CARD_IDS,
|
||||
STORAGE_KEY_AUTH_FILES_PAGE_SIZE,
|
||||
NOTIFICATION_DURATION_MS
|
||||
} from './src/utils/constants.js';
|
||||
|
||||
// 核心服务导入
|
||||
import { createErrorHandler } from './src/core/error-handler.js';
|
||||
import { connectionModule } from './src/core/connection.js';
|
||||
import { ApiClient } from './src/core/api-client.js';
|
||||
import { ConfigService } from './src/core/config-service.js';
|
||||
import { createEventBus } from './src/core/event-bus.js';
|
||||
|
||||
// CLI Proxy API 管理界面 JavaScript
|
||||
class CLIProxyManager {
|
||||
constructor() {
|
||||
// 事件总线
|
||||
this.events = createEventBus();
|
||||
|
||||
// API 客户端(规范化基础地址、封装请求)
|
||||
this.apiClient = new ApiClient({
|
||||
onVersionUpdate: (headers) => this.updateVersionFromHeaders(headers)
|
||||
});
|
||||
const detectedBase = this.detectApiBaseFromLocation();
|
||||
this.apiClient.setApiBase(detectedBase);
|
||||
this.apiBase = this.apiClient.apiBase;
|
||||
this.apiUrl = this.apiClient.apiUrl;
|
||||
this.managementKey = '';
|
||||
this.isConnected = false;
|
||||
this.isLoggedIn = false;
|
||||
this.uiVersion = null;
|
||||
this.serverVersion = null;
|
||||
this.serverBuildDate = null;
|
||||
this.latestVersion = null;
|
||||
this.versionCheckStatus = 'muted';
|
||||
this.versionCheckMessage = i18n.t('system_info.version_check_idle');
|
||||
|
||||
// 配置缓存 - 改为分段缓存(交由 ConfigService 管理)
|
||||
this.cacheExpiry = CACHE_EXPIRY_MS;
|
||||
this.configService = new ConfigService({
|
||||
apiClient: this.apiClient,
|
||||
cacheExpiry: this.cacheExpiry
|
||||
});
|
||||
this.configCache = this.configService.cache;
|
||||
this.cacheTimestamps = this.configService.cacheTimestamps;
|
||||
this.availableModels = [];
|
||||
this.availableModelApiKeysCache = null;
|
||||
this.availableModelsLoading = false;
|
||||
|
||||
// 状态更新定时器
|
||||
this.statusUpdateTimer = null;
|
||||
this.lastConnectionStatusEmitted = null;
|
||||
this.isGlobalRefreshInProgress = false;
|
||||
|
||||
this.registerCoreEventHandlers();
|
||||
|
||||
// 日志自动刷新定时器
|
||||
this.logsRefreshTimer = null;
|
||||
|
||||
// 当前展示的日志行
|
||||
this.allLogLines = [];
|
||||
this.displayedLogLines = [];
|
||||
this.logSearchQuery = '';
|
||||
this.maxDisplayLogLines = MAX_LOG_LINES;
|
||||
this.logFetchLimit = LOG_FETCH_LIMIT;
|
||||
|
||||
// 日志时间戳(用于增量加载)
|
||||
this.latestLogTimestamp = null;
|
||||
|
||||
// Auth file filter state cache
|
||||
this.currentAuthFileFilter = 'all';
|
||||
this.cachedAuthFiles = [];
|
||||
this.authFilesPagination = {
|
||||
pageSize: DEFAULT_AUTH_FILES_PAGE_SIZE,
|
||||
currentPage: 1,
|
||||
totalPages: 1
|
||||
};
|
||||
this.authFileStatsCache = {};
|
||||
this.authFileSearchQuery = '';
|
||||
this.authFilesPageSizeKey = STORAGE_KEY_AUTH_FILES_PAGE_SIZE;
|
||||
this.loadAuthFilePreferences();
|
||||
|
||||
// OAuth 模型排除列表状态
|
||||
this.oauthExcludedModels = {};
|
||||
this._oauthExcludedLoading = false;
|
||||
|
||||
// Vertex AI credential import state
|
||||
this.vertexImportState = {
|
||||
file: null,
|
||||
loading: false,
|
||||
result: null
|
||||
};
|
||||
|
||||
// 顶栏标题动画状态
|
||||
this.brandCollapseTimer = null;
|
||||
this.brandCollapseDelayMs = 5000;
|
||||
this.brandIsCollapsed = false;
|
||||
this.brandAnimationReady = false;
|
||||
this.brandElements = {
|
||||
toggle: null,
|
||||
wrapper: null,
|
||||
fullText: null,
|
||||
shortText: null
|
||||
};
|
||||
this.brandResizeHandler = null;
|
||||
this.brandToggleHandler = null;
|
||||
|
||||
// 主题管理
|
||||
this.currentTheme = 'light';
|
||||
|
||||
// 配置文件编辑器状态
|
||||
this.configYamlCache = '';
|
||||
this.isConfigEditorDirty = false;
|
||||
this.configEditorElements = {
|
||||
textarea: null,
|
||||
editorInstance: null,
|
||||
saveBtn: null,
|
||||
reloadBtn: null,
|
||||
statusEl: null
|
||||
};
|
||||
this.lastConfigFetchUrl = null;
|
||||
this.lastEditorConnectionState = null;
|
||||
|
||||
// 初始化错误处理器
|
||||
this.errorHandler = createErrorHandler((message, type) => this.showNotification(message, type));
|
||||
|
||||
this.init();
|
||||
}
|
||||
|
||||
loadAuthFilePreferences() {
|
||||
try {
|
||||
if (typeof localStorage === 'undefined') {
|
||||
return;
|
||||
}
|
||||
const savedPageSize = parseInt(localStorage.getItem(this.authFilesPageSizeKey), 10);
|
||||
if (Number.isFinite(savedPageSize)) {
|
||||
this.authFilesPagination.pageSize = this.normalizeAuthFilesPageSize(savedPageSize);
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Failed to restore auth file preferences:', error);
|
||||
}
|
||||
}
|
||||
|
||||
normalizeAuthFilesPageSize(value) {
|
||||
const defaultSize = DEFAULT_AUTH_FILES_PAGE_SIZE;
|
||||
const minSize = MIN_AUTH_FILES_PAGE_SIZE;
|
||||
const maxSize = MAX_AUTH_FILES_PAGE_SIZE;
|
||||
const parsed = parseInt(value, 10);
|
||||
if (!Number.isFinite(parsed) || parsed <= 0) {
|
||||
return defaultSize;
|
||||
}
|
||||
return Math.min(maxSize, Math.max(minSize, parsed));
|
||||
}
|
||||
|
||||
init() {
|
||||
this.initUiVersion();
|
||||
this.initializeTheme();
|
||||
this.registerCoreEventHandlers();
|
||||
this.registerSettingsListeners();
|
||||
this.registerUsageListeners();
|
||||
if (typeof this.registerLogsListeners === 'function') {
|
||||
this.registerLogsListeners();
|
||||
}
|
||||
if (typeof this.registerConfigEditorListeners === 'function') {
|
||||
this.registerConfigEditorListeners();
|
||||
}
|
||||
this.checkLoginStatus();
|
||||
this.bindEvents();
|
||||
this.setupNavigation();
|
||||
this.setupLanguageSwitcher();
|
||||
this.setupThemeSwitcher();
|
||||
this.setupConfigEditor();
|
||||
this.updateConfigEditorAvailability();
|
||||
// loadSettings 将在登录成功后调用
|
||||
this.updateLoginConnectionInfo();
|
||||
// 检查主机名,如果不是 localhost 或 127.0.0.1,则隐藏 OAuth 登录框
|
||||
this.checkHostAndHideOAuth();
|
||||
if (typeof this.registerAuthFilesListeners === 'function') {
|
||||
this.registerAuthFilesListeners();
|
||||
}
|
||||
}
|
||||
|
||||
registerCoreEventHandlers() {
|
||||
if (!this.events || typeof this.events.on !== 'function') {
|
||||
return;
|
||||
}
|
||||
this.events.on('config:refresh-requested', async (event) => {
|
||||
const detail = event?.detail || {};
|
||||
const forceRefresh = detail.forceRefresh !== false;
|
||||
// 避免并发触发导致重复请求
|
||||
if (this.isGlobalRefreshInProgress) {
|
||||
return;
|
||||
}
|
||||
await this.runGlobalRefresh(forceRefresh);
|
||||
});
|
||||
}
|
||||
|
||||
async runGlobalRefresh(forceRefresh = false) {
|
||||
this.isGlobalRefreshInProgress = true;
|
||||
try {
|
||||
await this.loadAllData(forceRefresh);
|
||||
} finally {
|
||||
this.isGlobalRefreshInProgress = false;
|
||||
}
|
||||
}
|
||||
|
||||
// 检查主机名并隐藏 OAuth 登录框
|
||||
checkHostAndHideOAuth() {
|
||||
const hostname = window.location.hostname;
|
||||
const isLocalhost = hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '::1';
|
||||
|
||||
if (!isLocalhost) {
|
||||
// 隐藏所有 OAuth 登录卡片
|
||||
OAUTH_CARD_IDS.forEach(cardId => {
|
||||
const card = document.getElementById(cardId);
|
||||
if (card) {
|
||||
card.style.display = 'none';
|
||||
}
|
||||
});
|
||||
|
||||
// 如果找不到具体的卡片 ID,尝试通过类名查找
|
||||
const oauthCardElements = document.querySelectorAll('.card');
|
||||
oauthCardElements.forEach(card => {
|
||||
const cardText = card.textContent || '';
|
||||
if (cardText.includes('Codex OAuth') ||
|
||||
cardText.includes('Anthropic OAuth') ||
|
||||
cardText.includes('Antigravity OAuth') ||
|
||||
cardText.includes('Gemini CLI OAuth') ||
|
||||
cardText.includes('Qwen OAuth') ||
|
||||
cardText.includes('iFlow OAuth')) {
|
||||
card.style.display = 'none';
|
||||
}
|
||||
});
|
||||
|
||||
console.log(`当前主机名: ${hostname},已隐藏 OAuth 登录框`);
|
||||
}
|
||||
}
|
||||
|
||||
// 检查登录状态
|
||||
// 处理登录表单提交
|
||||
// 事件绑定
|
||||
bindEvents() {
|
||||
// 登录相关(安全绑定)
|
||||
const loginSubmit = document.getElementById('login-submit');
|
||||
const logoutBtn = document.getElementById('logout-btn');
|
||||
|
||||
if (loginSubmit) {
|
||||
loginSubmit.addEventListener('click', () => this.handleLogin());
|
||||
}
|
||||
if (logoutBtn) {
|
||||
logoutBtn.addEventListener('click', () => this.logout());
|
||||
}
|
||||
|
||||
// 密钥可见性切换事件
|
||||
this.setupKeyVisibilityToggle();
|
||||
|
||||
// 主页面元素(延迟绑定,在显示主页面时绑定)
|
||||
this.bindMainPageEvents();
|
||||
}
|
||||
|
||||
// 设置密钥可见性切换
|
||||
setupKeyVisibilityToggle() {
|
||||
const toggleButtons = document.querySelectorAll('.toggle-key-visibility');
|
||||
toggleButtons.forEach(button => {
|
||||
button.addEventListener('click', () => this.toggleLoginKeyVisibility(button));
|
||||
});
|
||||
}
|
||||
|
||||
// 绑定主页面事件
|
||||
bindMainPageEvents() {
|
||||
// 连接状态检查
|
||||
const connectionStatus = document.getElementById('connection-status');
|
||||
const refreshAll = document.getElementById('refresh-all');
|
||||
const availableModelsRefresh = document.getElementById('available-models-refresh');
|
||||
const versionCheckBtn = document.getElementById('version-check-btn');
|
||||
|
||||
if (connectionStatus) {
|
||||
connectionStatus.addEventListener('click', () => this.checkConnectionStatus());
|
||||
}
|
||||
if (refreshAll) {
|
||||
refreshAll.addEventListener('click', () => this.refreshAllData());
|
||||
}
|
||||
if (availableModelsRefresh) {
|
||||
availableModelsRefresh.addEventListener('click', () => this.loadAvailableModels({ forceRefresh: true }));
|
||||
}
|
||||
if (versionCheckBtn) {
|
||||
versionCheckBtn.addEventListener('click', () => this.checkLatestVersion());
|
||||
}
|
||||
|
||||
// 基础设置
|
||||
const debugToggle = document.getElementById('debug-toggle');
|
||||
const updateProxy = document.getElementById('update-proxy');
|
||||
const clearProxy = document.getElementById('clear-proxy');
|
||||
const updateRetry = document.getElementById('update-retry');
|
||||
const switchProjectToggle = document.getElementById('switch-project-toggle');
|
||||
const switchPreviewToggle = document.getElementById('switch-preview-model-toggle');
|
||||
const usageStatisticsToggle = document.getElementById('usage-statistics-enabled-toggle');
|
||||
const requestLogToggle = document.getElementById('request-log-toggle');
|
||||
const wsAuthToggle = document.getElementById('ws-auth-toggle');
|
||||
|
||||
if (debugToggle) {
|
||||
debugToggle.addEventListener('change', (e) => this.updateDebug(e.target.checked));
|
||||
}
|
||||
if (updateProxy) {
|
||||
updateProxy.addEventListener('click', () => this.updateProxyUrl());
|
||||
}
|
||||
if (clearProxy) {
|
||||
clearProxy.addEventListener('click', () => this.clearProxyUrl());
|
||||
}
|
||||
if (updateRetry) {
|
||||
updateRetry.addEventListener('click', () => this.updateRequestRetry());
|
||||
}
|
||||
if (switchProjectToggle) {
|
||||
switchProjectToggle.addEventListener('change', (e) => this.updateSwitchProject(e.target.checked));
|
||||
}
|
||||
if (switchPreviewToggle) {
|
||||
switchPreviewToggle.addEventListener('change', (e) => this.updateSwitchPreviewModel(e.target.checked));
|
||||
}
|
||||
if (usageStatisticsToggle) {
|
||||
usageStatisticsToggle.addEventListener('change', (e) => this.updateUsageStatisticsEnabled(e.target.checked));
|
||||
}
|
||||
if (requestLogToggle) {
|
||||
requestLogToggle.addEventListener('change', (e) => this.updateRequestLog(e.target.checked));
|
||||
}
|
||||
if (wsAuthToggle) {
|
||||
wsAuthToggle.addEventListener('change', (e) => this.updateWsAuth(e.target.checked));
|
||||
}
|
||||
|
||||
// 日志记录设置
|
||||
const loggingToFileToggle = document.getElementById('logging-to-file-toggle');
|
||||
if (loggingToFileToggle) {
|
||||
loggingToFileToggle.addEventListener('change', (e) => this.updateLoggingToFile(e.target.checked));
|
||||
}
|
||||
|
||||
// 日志查看
|
||||
const refreshLogs = document.getElementById('refresh-logs');
|
||||
const selectErrorLog = document.getElementById('select-error-log');
|
||||
const downloadLogs = document.getElementById('download-logs');
|
||||
const clearLogs = document.getElementById('clear-logs');
|
||||
const logsAutoRefreshToggle = document.getElementById('logs-auto-refresh-toggle');
|
||||
const logsSearchInput = document.getElementById('logs-search-input');
|
||||
|
||||
if (refreshLogs) {
|
||||
refreshLogs.addEventListener('click', () => this.refreshLogs());
|
||||
}
|
||||
if (selectErrorLog) {
|
||||
selectErrorLog.addEventListener('click', () => this.openErrorLogsModal());
|
||||
}
|
||||
if (downloadLogs) {
|
||||
downloadLogs.addEventListener('click', () => this.downloadLogs());
|
||||
}
|
||||
if (clearLogs) {
|
||||
clearLogs.addEventListener('click', () => this.clearLogs());
|
||||
}
|
||||
if (logsAutoRefreshToggle) {
|
||||
logsAutoRefreshToggle.addEventListener('change', (e) => this.toggleLogsAutoRefresh(e.target.checked));
|
||||
}
|
||||
if (logsSearchInput) {
|
||||
const debouncedLogSearch = this.debounce((value) => {
|
||||
this.updateLogSearchQuery(value);
|
||||
}, 200);
|
||||
logsSearchInput.addEventListener('input', (e) => {
|
||||
debouncedLogSearch(e?.target?.value ?? '');
|
||||
});
|
||||
}
|
||||
|
||||
// API 密钥管理
|
||||
const addApiKey = document.getElementById('add-api-key');
|
||||
const addGeminiKey = document.getElementById('add-gemini-key');
|
||||
const addCodexKey = document.getElementById('add-codex-key');
|
||||
const addClaudeKey = document.getElementById('add-claude-key');
|
||||
const addOpenaiProvider = document.getElementById('add-openai-provider');
|
||||
|
||||
if (addApiKey) {
|
||||
addApiKey.addEventListener('click', () => this.showAddApiKeyModal());
|
||||
}
|
||||
if (addGeminiKey) {
|
||||
addGeminiKey.addEventListener('click', () => this.showAddGeminiKeyModal());
|
||||
}
|
||||
if (addCodexKey) {
|
||||
addCodexKey.addEventListener('click', () => this.showAddCodexKeyModal());
|
||||
}
|
||||
if (addClaudeKey) {
|
||||
addClaudeKey.addEventListener('click', () => this.showAddClaudeKeyModal());
|
||||
}
|
||||
if (addOpenaiProvider) {
|
||||
addOpenaiProvider.addEventListener('click', () => this.showAddOpenAIProviderModal());
|
||||
}
|
||||
|
||||
|
||||
// 认证文件管理
|
||||
const uploadAuthFile = document.getElementById('upload-auth-file');
|
||||
const deleteAllAuthFiles = document.getElementById('delete-all-auth-files');
|
||||
const authFileInput = document.getElementById('auth-file-input');
|
||||
|
||||
if (uploadAuthFile) {
|
||||
uploadAuthFile.addEventListener('click', () => this.uploadAuthFile());
|
||||
}
|
||||
if (deleteAllAuthFiles) {
|
||||
deleteAllAuthFiles.addEventListener('click', () => this.deleteAllAuthFiles());
|
||||
}
|
||||
if (authFileInput) {
|
||||
authFileInput.addEventListener('change', (e) => this.handleFileUpload(e));
|
||||
}
|
||||
this.bindAuthFilesPaginationEvents();
|
||||
this.bindAuthFilesSearchControl();
|
||||
this.bindAuthFilesPageSizeControl();
|
||||
this.syncAuthFileControls();
|
||||
|
||||
// OAuth 排除列表
|
||||
const oauthExcludedAdd = document.getElementById('oauth-excluded-add');
|
||||
const oauthExcludedRefresh = document.getElementById('oauth-excluded-refresh');
|
||||
|
||||
if (oauthExcludedAdd) {
|
||||
oauthExcludedAdd.addEventListener('click', () => this.openOauthExcludedEditor());
|
||||
}
|
||||
if (oauthExcludedRefresh) {
|
||||
oauthExcludedRefresh.addEventListener('click', () => this.loadOauthExcludedModels(true));
|
||||
}
|
||||
|
||||
// Vertex AI credential import
|
||||
const vertexSelectFile = document.getElementById('vertex-select-file');
|
||||
const vertexFileInput = document.getElementById('vertex-file-input');
|
||||
const vertexImportBtn = document.getElementById('vertex-import-btn');
|
||||
|
||||
if (vertexSelectFile) {
|
||||
vertexSelectFile.addEventListener('click', () => this.openVertexFilePicker());
|
||||
}
|
||||
if (vertexFileInput) {
|
||||
vertexFileInput.addEventListener('change', (e) => this.handleVertexFileSelection(e));
|
||||
}
|
||||
if (vertexImportBtn) {
|
||||
vertexImportBtn.addEventListener('click', () => this.importVertexCredential());
|
||||
}
|
||||
this.updateVertexFileDisplay();
|
||||
this.updateVertexImportButtonState();
|
||||
this.renderVertexImportResult(this.vertexImportState.result);
|
||||
|
||||
// Codex OAuth
|
||||
const codexOauthBtn = document.getElementById('codex-oauth-btn');
|
||||
const codexOpenLink = document.getElementById('codex-open-link');
|
||||
const codexCopyLink = document.getElementById('codex-copy-link');
|
||||
|
||||
if (codexOauthBtn) {
|
||||
codexOauthBtn.addEventListener('click', () => this.startCodexOAuth());
|
||||
}
|
||||
if (codexOpenLink) {
|
||||
codexOpenLink.addEventListener('click', () => this.openCodexLink());
|
||||
}
|
||||
if (codexCopyLink) {
|
||||
codexCopyLink.addEventListener('click', () => this.copyCodexLink());
|
||||
}
|
||||
|
||||
// Anthropic OAuth
|
||||
const anthropicOauthBtn = document.getElementById('anthropic-oauth-btn');
|
||||
const anthropicOpenLink = document.getElementById('anthropic-open-link');
|
||||
const anthropicCopyLink = document.getElementById('anthropic-copy-link');
|
||||
|
||||
if (anthropicOauthBtn) {
|
||||
anthropicOauthBtn.addEventListener('click', () => this.startAnthropicOAuth());
|
||||
}
|
||||
if (anthropicOpenLink) {
|
||||
anthropicOpenLink.addEventListener('click', () => this.openAnthropicLink());
|
||||
}
|
||||
if (anthropicCopyLink) {
|
||||
anthropicCopyLink.addEventListener('click', () => this.copyAnthropicLink());
|
||||
}
|
||||
|
||||
// Antigravity OAuth
|
||||
const antigravityOauthBtn = document.getElementById('antigravity-oauth-btn');
|
||||
const antigravityOpenLink = document.getElementById('antigravity-open-link');
|
||||
const antigravityCopyLink = document.getElementById('antigravity-copy-link');
|
||||
|
||||
if (antigravityOauthBtn) {
|
||||
antigravityOauthBtn.addEventListener('click', () => this.startAntigravityOAuth());
|
||||
}
|
||||
if (antigravityOpenLink) {
|
||||
antigravityOpenLink.addEventListener('click', () => this.openAntigravityLink());
|
||||
}
|
||||
if (antigravityCopyLink) {
|
||||
antigravityCopyLink.addEventListener('click', () => this.copyAntigravityLink());
|
||||
}
|
||||
|
||||
// Gemini CLI OAuth
|
||||
const geminiCliOauthBtn = document.getElementById('gemini-cli-oauth-btn');
|
||||
const geminiCliOpenLink = document.getElementById('gemini-cli-open-link');
|
||||
const geminiCliCopyLink = document.getElementById('gemini-cli-copy-link');
|
||||
|
||||
if (geminiCliOauthBtn) {
|
||||
geminiCliOauthBtn.addEventListener('click', () => this.startGeminiCliOAuth());
|
||||
}
|
||||
if (geminiCliOpenLink) {
|
||||
geminiCliOpenLink.addEventListener('click', () => this.openGeminiCliLink());
|
||||
}
|
||||
if (geminiCliCopyLink) {
|
||||
geminiCliCopyLink.addEventListener('click', () => this.copyGeminiCliLink());
|
||||
}
|
||||
|
||||
// Qwen OAuth
|
||||
const qwenOauthBtn = document.getElementById('qwen-oauth-btn');
|
||||
const qwenOpenLink = document.getElementById('qwen-open-link');
|
||||
const qwenCopyLink = document.getElementById('qwen-copy-link');
|
||||
|
||||
if (qwenOauthBtn) {
|
||||
qwenOauthBtn.addEventListener('click', () => this.startQwenOAuth());
|
||||
}
|
||||
if (qwenOpenLink) {
|
||||
qwenOpenLink.addEventListener('click', () => this.openQwenLink());
|
||||
}
|
||||
if (qwenCopyLink) {
|
||||
qwenCopyLink.addEventListener('click', () => this.copyQwenLink());
|
||||
}
|
||||
|
||||
// iFlow OAuth
|
||||
const iflowOauthBtn = document.getElementById('iflow-oauth-btn');
|
||||
const iflowOpenLink = document.getElementById('iflow-open-link');
|
||||
const iflowCopyLink = document.getElementById('iflow-copy-link');
|
||||
const iflowCookieSubmit = document.getElementById('iflow-cookie-submit');
|
||||
|
||||
if (iflowOauthBtn) {
|
||||
iflowOauthBtn.addEventListener('click', () => this.startIflowOAuth());
|
||||
}
|
||||
if (iflowOpenLink) {
|
||||
iflowOpenLink.addEventListener('click', () => this.openIflowLink());
|
||||
}
|
||||
if (iflowCopyLink) {
|
||||
iflowCopyLink.addEventListener('click', () => this.copyIflowLink());
|
||||
}
|
||||
if (iflowCookieSubmit) {
|
||||
iflowCookieSubmit.addEventListener('click', () => this.submitIflowCookieLogin());
|
||||
}
|
||||
|
||||
// 使用统计
|
||||
const refreshUsageStats = document.getElementById('refresh-usage-stats');
|
||||
const requestsHourBtn = document.getElementById('requests-hour-btn');
|
||||
const requestsDayBtn = document.getElementById('requests-day-btn');
|
||||
const tokensHourBtn = document.getElementById('tokens-hour-btn');
|
||||
const tokensDayBtn = document.getElementById('tokens-day-btn');
|
||||
const costHourBtn = document.getElementById('cost-hour-btn');
|
||||
const costDayBtn = document.getElementById('cost-day-btn');
|
||||
const addChartLineBtn = document.getElementById('add-chart-line');
|
||||
const chartLineSelects = document.querySelectorAll('.chart-line-select');
|
||||
const chartLineDeleteButtons = document.querySelectorAll('.chart-line-delete');
|
||||
const modelPriceForm = document.getElementById('model-price-form');
|
||||
const resetModelPricesBtn = document.getElementById('reset-model-prices');
|
||||
const modelPriceSelect = document.getElementById('model-price-model-select');
|
||||
|
||||
if (refreshUsageStats) {
|
||||
refreshUsageStats.addEventListener('click', () => this.loadUsageStats());
|
||||
}
|
||||
if (requestsHourBtn) {
|
||||
requestsHourBtn.addEventListener('click', () => this.switchRequestsPeriod('hour'));
|
||||
}
|
||||
if (requestsDayBtn) {
|
||||
requestsDayBtn.addEventListener('click', () => this.switchRequestsPeriod('day'));
|
||||
}
|
||||
if (tokensHourBtn) {
|
||||
tokensHourBtn.addEventListener('click', () => this.switchTokensPeriod('hour'));
|
||||
}
|
||||
if (tokensDayBtn) {
|
||||
tokensDayBtn.addEventListener('click', () => this.switchTokensPeriod('day'));
|
||||
}
|
||||
if (costHourBtn) {
|
||||
costHourBtn.addEventListener('click', () => this.switchCostPeriod('hour'));
|
||||
}
|
||||
if (costDayBtn) {
|
||||
costDayBtn.addEventListener('click', () => this.switchCostPeriod('day'));
|
||||
}
|
||||
if (addChartLineBtn) {
|
||||
addChartLineBtn.addEventListener('click', () => this.changeChartLineCount(1));
|
||||
}
|
||||
if (chartLineSelects.length) {
|
||||
chartLineSelects.forEach(select => {
|
||||
select.addEventListener('change', (event) => {
|
||||
const index = Number.parseInt(select.getAttribute('data-line-index'), 10);
|
||||
this.handleChartLineSelectionChange(Number.isNaN(index) ? -1 : index, event.target.value);
|
||||
});
|
||||
});
|
||||
}
|
||||
if (chartLineDeleteButtons.length) {
|
||||
chartLineDeleteButtons.forEach(button => {
|
||||
button.addEventListener('click', () => {
|
||||
const index = Number.parseInt(button.getAttribute('data-line-index'), 10);
|
||||
this.removeChartLine(Number.isNaN(index) ? -1 : index);
|
||||
});
|
||||
});
|
||||
}
|
||||
this.updateChartLineControlsUI();
|
||||
if (modelPriceForm) {
|
||||
modelPriceForm.addEventListener('submit', (event) => {
|
||||
event.preventDefault();
|
||||
this.handleModelPriceSubmit();
|
||||
});
|
||||
}
|
||||
if (resetModelPricesBtn) {
|
||||
resetModelPricesBtn.addEventListener('click', () => this.handleModelPriceReset());
|
||||
}
|
||||
if (modelPriceSelect) {
|
||||
modelPriceSelect.addEventListener('change', () => this.prefillModelPriceInputs());
|
||||
}
|
||||
|
||||
// 模态框
|
||||
const closeBtn = document.querySelector('.close');
|
||||
if (closeBtn) {
|
||||
closeBtn.addEventListener('click', () => this.closeModal());
|
||||
}
|
||||
|
||||
// 移动端菜单按钮
|
||||
const mobileMenuBtn = document.getElementById('mobile-menu-btn');
|
||||
const sidebarOverlay = document.getElementById('sidebar-overlay');
|
||||
const sidebar = document.getElementById('sidebar');
|
||||
|
||||
if (mobileMenuBtn) {
|
||||
mobileMenuBtn.addEventListener('click', () => this.toggleMobileSidebar());
|
||||
}
|
||||
|
||||
if (sidebarOverlay) {
|
||||
sidebarOverlay.addEventListener('click', () => this.closeMobileSidebar());
|
||||
}
|
||||
|
||||
// 侧边栏收起/展开按钮(桌面端)
|
||||
const sidebarToggleBtnDesktop = document.getElementById('sidebar-toggle-btn-desktop');
|
||||
if (sidebarToggleBtnDesktop) {
|
||||
sidebarToggleBtnDesktop.addEventListener('click', () => this.toggleSidebar());
|
||||
}
|
||||
|
||||
// 从本地存储恢复侧边栏状态
|
||||
this.restoreSidebarState();
|
||||
|
||||
// 监听窗口大小变化
|
||||
window.addEventListener('resize', () => {
|
||||
const sidebar = document.getElementById('sidebar');
|
||||
const layout = document.getElementById('layout-container');
|
||||
|
||||
if (window.innerWidth <= 1024) {
|
||||
// 移动端:移除收起状态
|
||||
if (sidebar && layout) {
|
||||
sidebar.classList.remove('collapsed');
|
||||
layout.classList.remove('sidebar-collapsed');
|
||||
}
|
||||
} else {
|
||||
// 桌面端:恢复保存的状态
|
||||
this.restoreSidebarState();
|
||||
}
|
||||
});
|
||||
|
||||
// 点击侧边栏导航项时在移动端关闭侧边栏
|
||||
const navItems = document.querySelectorAll('.nav-item');
|
||||
navItems.forEach(item => {
|
||||
item.addEventListener('click', () => {
|
||||
if (window.innerWidth <= 1024) {
|
||||
this.closeMobileSidebar();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// 顶栏标题动画与状态
|
||||
setupBrandTitleAnimation() {
|
||||
const mainPage = document.getElementById('main-page');
|
||||
if (mainPage && mainPage.style.display === 'none') {
|
||||
return;
|
||||
}
|
||||
|
||||
const toggle = document.getElementById('brand-name-toggle');
|
||||
const wrapper = document.getElementById('brand-texts');
|
||||
const fullText = document.querySelector('.brand-text-full');
|
||||
const shortText = document.querySelector('.brand-text-short');
|
||||
|
||||
if (!toggle || !wrapper || !fullText || !shortText) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.brandElements = { toggle, wrapper, fullText, shortText };
|
||||
|
||||
if (!this.brandToggleHandler) {
|
||||
this.brandToggleHandler = () => this.handleBrandToggle();
|
||||
toggle.addEventListener('click', this.brandToggleHandler);
|
||||
}
|
||||
if (!this.brandResizeHandler) {
|
||||
this.brandResizeHandler = () => this.updateBrandTextWidths({ immediate: true });
|
||||
window.addEventListener('resize', this.brandResizeHandler);
|
||||
}
|
||||
|
||||
this.brandAnimationReady = true;
|
||||
}
|
||||
|
||||
getBrandTextWidth(element) {
|
||||
if (!element) {
|
||||
return 0;
|
||||
}
|
||||
const width = element.scrollWidth || element.getBoundingClientRect().width || 0;
|
||||
return Number.isFinite(width) ? Math.ceil(width) : 0;
|
||||
}
|
||||
|
||||
applyBrandWidth(targetWidth, { animate = true } = {}) {
|
||||
const wrapper = this.brandElements?.wrapper;
|
||||
if (!wrapper || !Number.isFinite(targetWidth)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!animate) {
|
||||
const previousTransition = wrapper.style.transition;
|
||||
wrapper.style.transition = 'none';
|
||||
wrapper.style.width = `${targetWidth}px`;
|
||||
wrapper.getBoundingClientRect(); // 强制重绘以应用无动画的宽度
|
||||
wrapper.style.transition = previousTransition;
|
||||
return;
|
||||
}
|
||||
|
||||
wrapper.style.width = `${targetWidth}px`;
|
||||
}
|
||||
|
||||
updateBrandTextWidths(options = {}) {
|
||||
const { wrapper, fullText, shortText } = this.brandElements || {};
|
||||
if (!wrapper || !fullText || !shortText) {
|
||||
return;
|
||||
}
|
||||
|
||||
const targetSpan = this.brandIsCollapsed ? shortText : fullText;
|
||||
const targetWidth = this.getBrandTextWidth(targetSpan);
|
||||
this.applyBrandWidth(targetWidth, { animate: !options.immediate });
|
||||
}
|
||||
|
||||
setBrandCollapsed(collapsed, options = {}) {
|
||||
const { toggle, fullText, shortText } = this.brandElements || {};
|
||||
if (!toggle || !fullText || !shortText) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.brandIsCollapsed = collapsed;
|
||||
const targetSpan = collapsed ? shortText : fullText;
|
||||
const targetWidth = this.getBrandTextWidth(targetSpan);
|
||||
|
||||
this.applyBrandWidth(targetWidth, { animate: options.animate !== false });
|
||||
toggle.classList.toggle('collapsed', collapsed);
|
||||
toggle.classList.toggle('expanded', !collapsed);
|
||||
}
|
||||
|
||||
scheduleBrandCollapse(delayMs = this.brandCollapseDelayMs) {
|
||||
this.clearBrandCollapseTimer();
|
||||
this.brandCollapseTimer = window.setTimeout(() => {
|
||||
this.setBrandCollapsed(true);
|
||||
this.brandCollapseTimer = null;
|
||||
}, delayMs);
|
||||
}
|
||||
|
||||
clearBrandCollapseTimer() {
|
||||
if (this.brandCollapseTimer) {
|
||||
clearTimeout(this.brandCollapseTimer);
|
||||
this.brandCollapseTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
startBrandCollapseCycle() {
|
||||
this.setupBrandTitleAnimation();
|
||||
if (!this.brandAnimationReady) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.clearBrandCollapseTimer();
|
||||
this.brandIsCollapsed = false;
|
||||
this.setBrandCollapsed(false, { animate: false });
|
||||
this.scheduleBrandCollapse(this.brandCollapseDelayMs);
|
||||
}
|
||||
|
||||
resetBrandTitleState() {
|
||||
this.clearBrandCollapseTimer();
|
||||
const mainPage = document.getElementById('main-page');
|
||||
if (!this.brandAnimationReady || (mainPage && mainPage.style.display === 'none')) {
|
||||
this.brandIsCollapsed = false;
|
||||
return;
|
||||
}
|
||||
|
||||
this.brandIsCollapsed = false;
|
||||
this.setBrandCollapsed(false, { animate: false });
|
||||
}
|
||||
|
||||
refreshBrandTitleAfterTextChange() {
|
||||
if (!this.brandAnimationReady) {
|
||||
return;
|
||||
}
|
||||
this.updateBrandTextWidths({ immediate: true });
|
||||
if (!this.brandIsCollapsed) {
|
||||
this.scheduleBrandCollapse(this.brandCollapseDelayMs);
|
||||
}
|
||||
}
|
||||
|
||||
handleBrandToggle() {
|
||||
if (!this.brandAnimationReady) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nextCollapsed = !this.brandIsCollapsed;
|
||||
this.setBrandCollapsed(nextCollapsed);
|
||||
this.clearBrandCollapseTimer();
|
||||
|
||||
if (!nextCollapsed) {
|
||||
// 展开后给用户留出一点时间阅读再收起
|
||||
this.scheduleBrandCollapse(this.brandCollapseDelayMs + 1500);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// 显示通知
|
||||
showNotification(message, type = 'info') {
|
||||
const notification = document.getElementById('notification');
|
||||
notification.textContent = message;
|
||||
notification.className = `notification ${type}`;
|
||||
notification.classList.add('show');
|
||||
|
||||
setTimeout(() => {
|
||||
notification.classList.remove('show');
|
||||
}, NOTIFICATION_DURATION_MS);
|
||||
}
|
||||
|
||||
// 密钥可见性切换
|
||||
toggleKeyVisibility() {
|
||||
const keyInput = document.getElementById('management-key');
|
||||
const toggleButton = document.getElementById('toggle-key-visibility');
|
||||
|
||||
if (keyInput.type === 'password') {
|
||||
keyInput.type = 'text';
|
||||
toggleButton.innerHTML = '<i class="fas fa-eye-slash"></i>';
|
||||
} else {
|
||||
keyInput.type = 'password';
|
||||
toggleButton.innerHTML = '<i class="fas fa-eye"></i>';
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 使用统计相关方法 =====
|
||||
|
||||
// 使用统计状态
|
||||
requestsChart = null;
|
||||
tokensChart = null;
|
||||
costChart = null;
|
||||
currentUsageData = null;
|
||||
chartLineMaxCount = 9;
|
||||
chartLineVisibleCount = 3;
|
||||
chartLineSelections = Array(3).fill('none');
|
||||
chartLineSelectionsInitialized = false;
|
||||
chartLineSelectIds = Array.from({ length: 9 }, (_, idx) => `chart-line-select-${idx}`);
|
||||
chartLineStyles = [
|
||||
{ borderColor: '#3b82f6', backgroundColor: 'rgba(59, 130, 246, 0.15)' },
|
||||
{ borderColor: '#a855f7', backgroundColor: 'rgba(168, 85, 247, 0.15)' },
|
||||
{ borderColor: '#10b981', backgroundColor: 'rgba(16, 185, 129, 0.15)' },
|
||||
{ borderColor: '#f97316', backgroundColor: 'rgba(249, 115, 22, 0.15)' },
|
||||
{ borderColor: '#ec4899', backgroundColor: 'rgba(236, 72, 153, 0.15)' },
|
||||
{ borderColor: '#14b8a6', backgroundColor: 'rgba(20, 184, 166, 0.15)' },
|
||||
{ borderColor: '#8b5cf6', backgroundColor: 'rgba(139, 92, 246, 0.15)' },
|
||||
{ borderColor: '#f59e0b', backgroundColor: 'rgba(245, 158, 11, 0.15)' },
|
||||
{ borderColor: '#22c55e', backgroundColor: 'rgba(34, 197, 94, 0.15)' }
|
||||
];
|
||||
modelPriceStorageKey = 'cli-proxy-model-prices-v2';
|
||||
modelPrices = {};
|
||||
modelPriceInitialized = false;
|
||||
|
||||
showModal() {
|
||||
const modal = document.getElementById('modal');
|
||||
if (modal) {
|
||||
modal.style.display = 'block';
|
||||
}
|
||||
}
|
||||
|
||||
// 关闭模态框
|
||||
closeModal() {
|
||||
document.getElementById('modal').style.display = 'none';
|
||||
if (typeof this.closeOpenAIModelDiscovery === 'function') {
|
||||
this.closeOpenAIModelDiscovery();
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
Object.assign(
|
||||
CLIProxyManager.prototype,
|
||||
themeModule,
|
||||
navigationModule,
|
||||
languageModule,
|
||||
loginModule,
|
||||
configEditorModule,
|
||||
logsModule,
|
||||
apiKeysModule,
|
||||
authFilesModule,
|
||||
oauthModule,
|
||||
usageModule,
|
||||
settingsModule,
|
||||
aiProvidersModule,
|
||||
connectionModule
|
||||
);
|
||||
|
||||
// 将工具函数绑定到原型上,供模块使用
|
||||
CLIProxyManager.prototype.escapeHtml = escapeHtml;
|
||||
CLIProxyManager.prototype.maskApiKey = maskApiKey;
|
||||
CLIProxyManager.prototype.formatFileSize = formatFileSize;
|
||||
CLIProxyManager.prototype.normalizeArrayResponse = normalizeArrayResponse;
|
||||
CLIProxyManager.prototype.debounce = debounce;
|
||||
|
||||
// 全局管理器实例
|
||||
let manager;
|
||||
|
||||
// 让内联事件处理器可以访问到 manager 实例
|
||||
function exposeManagerInstance(instance) {
|
||||
if (typeof window !== 'undefined') {
|
||||
window.manager = instance;
|
||||
} else if (typeof globalThis !== 'undefined') {
|
||||
globalThis.manager = instance;
|
||||
}
|
||||
}
|
||||
|
||||
// 尝试自动加载根目录 Logo(支持多种常见文件名/扩展名)
|
||||
function setupSiteLogo() {
|
||||
const img = document.getElementById('site-logo');
|
||||
const loginImg = document.getElementById('login-logo');
|
||||
if (!img && !loginImg) return;
|
||||
|
||||
const inlineLogo = typeof window !== 'undefined' ? window.__INLINE_LOGO__ : null;
|
||||
if (inlineLogo) {
|
||||
if (img) {
|
||||
img.src = inlineLogo;
|
||||
img.style.display = 'inline-block';
|
||||
}
|
||||
if (loginImg) {
|
||||
loginImg.src = inlineLogo;
|
||||
loginImg.style.display = 'inline-block';
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const candidates = [
|
||||
'../logo.svg', '../logo.png', '../logo.jpg', '../logo.jpeg', '../logo.webp', '../logo.gif',
|
||||
'logo.svg', 'logo.png', 'logo.jpg', 'logo.jpeg', 'logo.webp', 'logo.gif',
|
||||
'/logo.svg', '/logo.png', '/logo.jpg', '/logo.jpeg', '/logo.webp', '/logo.gif'
|
||||
];
|
||||
let idx = 0;
|
||||
const tryNext = () => {
|
||||
if (idx >= candidates.length) return;
|
||||
const test = new Image();
|
||||
test.onload = () => {
|
||||
if (img) {
|
||||
img.src = test.src;
|
||||
img.style.display = 'inline-block';
|
||||
}
|
||||
if (loginImg) {
|
||||
loginImg.src = test.src;
|
||||
loginImg.style.display = 'inline-block';
|
||||
}
|
||||
};
|
||||
test.onerror = () => {
|
||||
idx++;
|
||||
tryNext();
|
||||
};
|
||||
test.src = candidates[idx];
|
||||
};
|
||||
tryNext();
|
||||
}
|
||||
|
||||
// 页面加载完成后初始化
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
// 初始化国际化
|
||||
i18n.init();
|
||||
|
||||
setupSiteLogo();
|
||||
manager = new CLIProxyManager();
|
||||
exposeManagerInstance(manager);
|
||||
});
|
||||
@@ -1,30 +0,0 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
// Read the original index.html
|
||||
const indexPath = path.resolve(__dirname, '../index.html');
|
||||
const outputPath = path.resolve(__dirname, '../index.build.html');
|
||||
|
||||
let htmlContent = fs.readFileSync(indexPath, 'utf8');
|
||||
|
||||
// Remove local CSS reference
|
||||
htmlContent = htmlContent.replace(
|
||||
/<link rel="stylesheet" href="styles\.css">\n?/g,
|
||||
''
|
||||
);
|
||||
|
||||
// Remove local JavaScript references
|
||||
htmlContent = htmlContent.replace(
|
||||
/<script src="i18n\.js"><\/script>\n?/g,
|
||||
''
|
||||
);
|
||||
|
||||
htmlContent = htmlContent.replace(
|
||||
/<script src="app\.js"><\/script>\n?/g,
|
||||
''
|
||||
);
|
||||
|
||||
// Write the modified HTML to a temporary build file
|
||||
fs.writeFileSync(outputPath, htmlContent, 'utf8');
|
||||
|
||||
console.log('✓ Generated index.build.html for webpack processing');
|
||||
225
build.cjs
@@ -1,225 +0,0 @@
|
||||
'use strict';
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const projectRoot = __dirname;
|
||||
const distDir = path.join(projectRoot, 'dist');
|
||||
|
||||
const sourceFiles = {
|
||||
html: path.join(projectRoot, 'index.html'),
|
||||
css: path.join(projectRoot, 'styles.css'),
|
||||
i18n: path.join(projectRoot, 'i18n.js'),
|
||||
app: path.join(projectRoot, 'app.js')
|
||||
};
|
||||
|
||||
const logoCandidates = ['logo.png', 'logo.jpg', 'logo.jpeg', 'logo.svg', 'logo.webp', 'logo.gif'];
|
||||
const logoMimeMap = {
|
||||
'.png': 'image/png',
|
||||
'.jpg': 'image/jpeg',
|
||||
'.jpeg': 'image/jpeg',
|
||||
'.svg': 'image/svg+xml',
|
||||
'.webp': 'image/webp',
|
||||
'.gif': 'image/gif'
|
||||
};
|
||||
|
||||
function readFile(filePath) {
|
||||
try {
|
||||
return fs.readFileSync(filePath, 'utf8');
|
||||
} catch (err) {
|
||||
console.error(`读取文件失败: ${filePath}`);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
function readBinary(filePath) {
|
||||
try {
|
||||
return fs.readFileSync(filePath);
|
||||
} catch (err) {
|
||||
console.error(`读取文件失败: ${filePath}`);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
function escapeForScript(content) {
|
||||
return content.replace(/<\/(script)/gi, '<\\/$1');
|
||||
}
|
||||
|
||||
function escapeForStyle(content) {
|
||||
return content.replace(/<\/(style)/gi, '<\\/$1');
|
||||
}
|
||||
|
||||
function getVersion() {
|
||||
// 1. 优先从环境变量获取(GitHub Actions 会设置)
|
||||
if (process.env.VERSION) {
|
||||
return process.env.VERSION;
|
||||
}
|
||||
|
||||
// 2. 尝试从 git tag 获取
|
||||
try {
|
||||
const { execSync } = require('child_process');
|
||||
const gitTag = execSync('git describe --tags --exact-match 2>/dev/null || git describe --tags 2>/dev/null || echo ""', { encoding: 'utf8' }).trim();
|
||||
if (gitTag) {
|
||||
return gitTag;
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('无法从 git 获取版本号');
|
||||
}
|
||||
|
||||
// 3. 回退到 package.json
|
||||
try {
|
||||
const packageJson = JSON.parse(fs.readFileSync(path.join(projectRoot, 'package.json'), 'utf8'));
|
||||
return 'v' + packageJson.version;
|
||||
} catch (err) {
|
||||
console.warn('无法从 package.json 读取版本号');
|
||||
}
|
||||
|
||||
// 4. 最后使用默认值
|
||||
return 'v0.0.0-dev';
|
||||
}
|
||||
|
||||
function ensureDistDir() {
|
||||
if (fs.existsSync(distDir)) {
|
||||
fs.rmSync(distDir, { recursive: true, force: true });
|
||||
}
|
||||
fs.mkdirSync(distDir);
|
||||
}
|
||||
|
||||
// 匹配各种 import 语句
|
||||
const importRegex = /import\s+(?:{[^}]*}|[\w*\s,{}]+)\s+from\s+['"]([^'"]+)['"];?/gm;
|
||||
// 匹配 export 关键字(包括 export const, export function, export class, export async function 等)
|
||||
const exportRegex = /^export\s+(?=const|let|var|function|class|default|async)/gm;
|
||||
// 匹配单独的 export {} 或 export { ... } from '...'
|
||||
const exportBraceRegex = /^export\s*{[^}]*}\s*(?:from\s+['"][^'"]+['"];?)?$/gm;
|
||||
|
||||
function bundleApp(entryPath) {
|
||||
const visited = new Set();
|
||||
const modules = [];
|
||||
|
||||
function inlineFile(filePath) {
|
||||
let content = readFile(filePath);
|
||||
const dir = path.dirname(filePath);
|
||||
|
||||
// 收集所有 import 语句
|
||||
const imports = [];
|
||||
content = content.replace(importRegex, (match, specifier) => {
|
||||
const targetPath = path.resolve(dir, specifier);
|
||||
const normalized = path.normalize(targetPath);
|
||||
if (!fs.existsSync(normalized)) {
|
||||
throw new Error(`无法解析模块: ${specifier} (from ${filePath})`);
|
||||
}
|
||||
if (!visited.has(normalized)) {
|
||||
visited.add(normalized);
|
||||
imports.push(normalized);
|
||||
}
|
||||
return ''; // 移除 import 语句
|
||||
});
|
||||
|
||||
// 移除 export 关键字
|
||||
content = content.replace(exportRegex, '');
|
||||
content = content.replace(exportBraceRegex, '');
|
||||
|
||||
// 处理依赖的模块
|
||||
for (const importPath of imports) {
|
||||
const moduleContent = inlineFile(importPath);
|
||||
const relativePath = path.relative(projectRoot, importPath);
|
||||
modules.push(`\n// ============ ${relativePath} ============\n${moduleContent}\n`);
|
||||
}
|
||||
|
||||
return content;
|
||||
}
|
||||
|
||||
const mainContent = inlineFile(entryPath);
|
||||
|
||||
// 将所有模块内容组合在一起,模块在前,主文件在后
|
||||
return modules.join('\n') + '\n// ============ Main ============\n' + mainContent;
|
||||
}
|
||||
|
||||
|
||||
function loadLogoDataUrl() {
|
||||
for (const candidate of logoCandidates) {
|
||||
const filePath = path.join(projectRoot, candidate);
|
||||
if (!fs.existsSync(filePath)) continue;
|
||||
|
||||
const ext = path.extname(candidate).toLowerCase();
|
||||
const mime = logoMimeMap[ext];
|
||||
if (!mime) {
|
||||
console.warn(`未知 Logo 文件类型,跳过内联: ${candidate}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const buffer = readBinary(filePath);
|
||||
const base64 = buffer.toString('base64');
|
||||
return `data:${mime};base64,${base64}`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function build() {
|
||||
ensureDistDir();
|
||||
|
||||
let html = readFile(sourceFiles.html);
|
||||
const css = escapeForStyle(readFile(sourceFiles.css));
|
||||
const i18n = escapeForScript(readFile(sourceFiles.i18n));
|
||||
const bundledApp = bundleApp(sourceFiles.app);
|
||||
const app = escapeForScript(bundledApp);
|
||||
|
||||
// 获取版本号并替换
|
||||
const version = getVersion();
|
||||
console.log(`使用版本号: ${version}`);
|
||||
html = html.replace(/__VERSION__/g, version);
|
||||
|
||||
html = html.replace(
|
||||
'<link rel="stylesheet" href="styles.css">',
|
||||
() => `<style>
|
||||
${css}
|
||||
</style>`
|
||||
);
|
||||
|
||||
html = html.replace(
|
||||
'<script src="i18n.js"></script>',
|
||||
() => `<script>
|
||||
${i18n}
|
||||
</script>`
|
||||
);
|
||||
|
||||
const scriptTagRegex = /<script[^>]*src="app\.js"[^>]*><\/script>/i;
|
||||
if (scriptTagRegex.test(html)) {
|
||||
html = html.replace(
|
||||
scriptTagRegex,
|
||||
() => `<script>
|
||||
${app}
|
||||
</script>`
|
||||
);
|
||||
} else {
|
||||
console.warn('未找到 app.js 脚本标签,未内联应用代码。');
|
||||
}
|
||||
|
||||
const logoDataUrl = loadLogoDataUrl();
|
||||
if (logoDataUrl) {
|
||||
const logoScript = `<script>window.__INLINE_LOGO__ = "${logoDataUrl}";</script>`;
|
||||
const closingBodyTag = '</body>';
|
||||
const closingBodyIndex = html.lastIndexOf(closingBodyTag);
|
||||
if (closingBodyIndex !== -1) {
|
||||
html = `${html.slice(0, closingBodyIndex)}${logoScript}\n${closingBodyTag}${html.slice(closingBodyIndex + closingBodyTag.length)}`;
|
||||
} else {
|
||||
html += `\n${logoScript}`;
|
||||
}
|
||||
} else {
|
||||
console.warn('未找到可内联的 Logo 文件,将保持运行时加载。');
|
||||
}
|
||||
|
||||
const outputPath = path.join(distDir, 'index.html');
|
||||
fs.writeFileSync(outputPath, html, 'utf8');
|
||||
|
||||
console.log('构建完成: dist/index.html');
|
||||
}
|
||||
|
||||
try {
|
||||
build();
|
||||
} catch (error) {
|
||||
console.error('构建失败:', error);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
// Import CSS
|
||||
import './styles.css';
|
||||
|
||||
// Import JavaScript modules
|
||||
import './i18n.js';
|
||||
import './app.js';
|
||||
|
||||
// Import logo image
|
||||
import logoImg from './logo.jpg';
|
||||
|
||||
// Set logo after DOM is loaded
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const loginLogo = document.getElementById('login-logo');
|
||||
const siteLogo = document.getElementById('site-logo');
|
||||
|
||||
if (loginLogo) {
|
||||
loginLogo.src = logoImg;
|
||||
loginLogo.style.display = 'block';
|
||||
}
|
||||
|
||||
if (siteLogo) {
|
||||
siteLogo.src = logoImg;
|
||||
siteLogo.style.display = 'block';
|
||||
}
|
||||
});
|
||||
BIN
dashboard-preview.png
Normal file
|
After Width: | Height: | Size: 2.5 MiB |
30
eslint.config.js
Normal file
@@ -0,0 +1,30 @@
|
||||
import js from '@eslint/js';
|
||||
import globals from 'globals';
|
||||
import reactHooks from 'eslint-plugin-react-hooks';
|
||||
import reactRefresh from 'eslint-plugin-react-refresh';
|
||||
import tseslint from 'typescript-eslint';
|
||||
|
||||
export default tseslint.config(
|
||||
{ ignores: ['dist'] },
|
||||
{
|
||||
extends: [js.configs.recommended, ...tseslint.configs.recommended],
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
languageOptions: {
|
||||
ecmaVersion: 2020,
|
||||
globals: globals.browser,
|
||||
},
|
||||
plugins: {
|
||||
'react-hooks': reactHooks,
|
||||
'react-refresh': reactRefresh,
|
||||
},
|
||||
rules: {
|
||||
...reactHooks.configs.recommended.rules,
|
||||
'react-refresh/only-export-components': [
|
||||
'warn',
|
||||
{ allowConstantExport: true },
|
||||
],
|
||||
'@typescript-eslint/no-explicit-any': 'warn',
|
||||
'@typescript-eslint/no-unused-vars': ['warn', { argsIgnorePattern: '^_' }],
|
||||
},
|
||||
},
|
||||
);
|
||||
1394
index.html
5430
package-lock.json
generated
79
package.json
@@ -1,31 +1,48 @@
|
||||
{
|
||||
"name": "cli-proxy-api-webui",
|
||||
"version": "1.0.0",
|
||||
"description": "CLI Proxy API 管理界面",
|
||||
"main": "index.html",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"start": "npx serve .",
|
||||
"dev": "npx serve . -l 3090",
|
||||
"build": "node build.cjs",
|
||||
"lint": "echo '使用浏览器开发者工具检查代码'"
|
||||
},
|
||||
"keywords": [
|
||||
"cli-proxy-api",
|
||||
"webui",
|
||||
"management",
|
||||
"api"
|
||||
],
|
||||
"author": "CLI Proxy API WebUI",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"serve": "^14.2.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "local"
|
||||
}
|
||||
}
|
||||
{
|
||||
"name": "cli-proxy-webui-react",
|
||||
"private": true,
|
||||
"version": "1.1.7",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"preview": "vite preview",
|
||||
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives",
|
||||
"format": "prettier --write \"src/**/*.{ts,tsx,css,scss}\"",
|
||||
"type-check": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@codemirror/lang-yaml": "^6.1.2",
|
||||
"@tanstack/react-virtual": "^3.13.18",
|
||||
"@uiw/react-codemirror": "^4.25.3",
|
||||
"axios": "^1.13.2",
|
||||
"chart.js": "^4.5.1",
|
||||
"gsap": "^3.14.2",
|
||||
"i18next": "^25.7.1",
|
||||
"react": "^19.2.1",
|
||||
"react-chartjs-2": "^5.3.1",
|
||||
"react-dom": "^19.2.1",
|
||||
"react-i18next": "^16.4.0",
|
||||
"react-router-dom": "^7.10.1",
|
||||
"yaml": "^2.8.2",
|
||||
"zustand": "^5.0.9"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.39.1",
|
||||
"@types/react": "^19.2.7",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@typescript-eslint/eslint-plugin": "^8.48.1",
|
||||
"@typescript-eslint/parser": "^8.48.1",
|
||||
"@vitejs/plugin-react": "^5.1.1",
|
||||
"eslint": "^9.39.1",
|
||||
"eslint-plugin-react-hooks": "^7.0.1",
|
||||
"eslint-plugin-react-refresh": "^0.4.24",
|
||||
"globals": "^16.5.0",
|
||||
"prettier": "^3.7.4",
|
||||
"sass": "^1.94.2",
|
||||
"typescript": "^5.9.3",
|
||||
"typescript-eslint": "^8.48.1",
|
||||
"vite": "^7.2.6",
|
||||
"vite-plugin-singlefile": "^2.3.0"
|
||||
}
|
||||
}
|
||||
|
||||
42
src/App.css
Normal file
@@ -0,0 +1,42 @@
|
||||
#root {
|
||||
max-width: 1280px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.logo {
|
||||
height: 6em;
|
||||
padding: 1.5em;
|
||||
will-change: filter;
|
||||
transition: filter 300ms;
|
||||
}
|
||||
.logo:hover {
|
||||
filter: drop-shadow(0 0 2em #646cffaa);
|
||||
}
|
||||
.logo.react:hover {
|
||||
filter: drop-shadow(0 0 2em #61dafbaa);
|
||||
}
|
||||
|
||||
@keyframes logo-spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
a:nth-of-type(2) .logo {
|
||||
animation: logo-spin infinite 20s linear;
|
||||
}
|
||||
}
|
||||
|
||||
.card {
|
||||
padding: 2em;
|
||||
}
|
||||
|
||||
.read-the-docs {
|
||||
color: #888;
|
||||
}
|
||||
48
src/App.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
import { useEffect } from 'react';
|
||||
import { HashRouter, Route, Routes } from 'react-router-dom';
|
||||
import { LoginPage } from '@/pages/LoginPage';
|
||||
import { NotificationContainer } from '@/components/common/NotificationContainer';
|
||||
import { ConfirmationModal } from '@/components/common/ConfirmationModal';
|
||||
import { MainLayout } from '@/components/layout/MainLayout';
|
||||
import { ProtectedRoute } from '@/router/ProtectedRoute';
|
||||
import { useLanguageStore, useThemeStore } from '@/stores';
|
||||
|
||||
function App() {
|
||||
const initializeTheme = useThemeStore((state) => state.initializeTheme);
|
||||
const language = useLanguageStore((state) => state.language);
|
||||
const setLanguage = useLanguageStore((state) => state.setLanguage);
|
||||
|
||||
useEffect(() => {
|
||||
const cleanupTheme = initializeTheme();
|
||||
return cleanupTheme;
|
||||
}, [initializeTheme]);
|
||||
|
||||
useEffect(() => {
|
||||
setLanguage(language);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []); // 仅用于首屏同步 i18n 语言
|
||||
|
||||
useEffect(() => {
|
||||
document.documentElement.lang = language;
|
||||
}, [language]);
|
||||
|
||||
return (
|
||||
<HashRouter>
|
||||
<NotificationContainer />
|
||||
<ConfirmationModal />
|
||||
<Routes>
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
<Route
|
||||
path="/*"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<MainLayout />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
</Routes>
|
||||
</HashRouter>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
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 |
25
src/assets/icons/codex_drak.svg
Normal file
@@ -0,0 +1,25 @@
|
||||
<?xml version="1.0" standalone="no"?>
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 20010904//EN"
|
||||
"http://www.w3.org/TR/2001/REC-SVG-20010904/DTD/svg10.dtd">
|
||||
<svg version="1.0" xmlns="http://www.w3.org/2000/svg"
|
||||
width="256.000000pt" height="256.000000pt" viewBox="0 0 256.000000 256.000000"
|
||||
preserveAspectRatio="xMidYMid meet">
|
||||
|
||||
<g transform="translate(0.000000,256.000000) scale(0.100000,-0.100000)"
|
||||
fill="#FFFFFF" stroke="none">
|
||||
<path d="M1107 2290 c-316 -57 -615 -283 -748 -565 -68 -144 -91 -241 -96
|
||||
-406 -6 -156 7 -249 49 -374 87 -254 291 -478 542 -596 146 -68 226 -84 426
|
||||
-84 152 0 186 3 260 23 182 50 327 136 465 277 147 150 245 334 282 529 23
|
||||
123 14 344 -20 456 -35 116 -69 190 -134 290 -131 200 -340 354 -578 426 -78
|
||||
23 -111 27 -245 30 -85 1 -177 -1 -203 -6z m362 -216 c91 -21 224 -86 310
|
||||
-152 133 -101 249 -275 293 -439 16 -60 21 -108 21 -203 0 -152 -21 -240 -88
|
||||
-368 -130 -253 -350 -407 -634 -443 -393 -50 -777 214 -882 607 -30 110 -30
|
||||
296 0 408 72 270 282 489 552 576 130 41 287 47 428 14z"/>
|
||||
<path d="M849 1637 c-31 -24 -52 -67 -46 -95 3 -15 35 -78 71 -139 36 -61 66
|
||||
-115 66 -119 0 -5 -30 -58 -66 -119 -36 -60 -68 -123 -70 -140 -7 -42 26 -90
|
||||
70 -105 31 -10 42 -9 72 7 31 15 51 43 125 173 93 162 101 188 73 243 -50 97
|
||||
-169 289 -185 297 -25 14 -91 12 -110 -3z"/>
|
||||
<path d="M1353 1139 c-42 -12 -73 -53 -73 -96 0 -27 8 -43 35 -70 l34 -34 216
|
||||
3 217 3 30 34 c26 29 29 40 25 73 -7 49 -29 75 -76 88 -45 12 -364 12 -408 -1z"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.4 KiB |
25
src/assets/icons/codex_light.svg
Normal file
@@ -0,0 +1,25 @@
|
||||
<?xml version="1.0" standalone="no"?>
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 20010904//EN"
|
||||
"http://www.w3.org/TR/2001/REC-SVG-20010904/DTD/svg10.dtd">
|
||||
<svg version="1.0" xmlns="http://www.w3.org/2000/svg"
|
||||
width="256.000000pt" height="256.000000pt" viewBox="0 0 256.000000 256.000000"
|
||||
preserveAspectRatio="xMidYMid meet">
|
||||
|
||||
<g transform="translate(0.000000,256.000000) scale(0.100000,-0.100000)"
|
||||
fill="#000000" stroke="none">
|
||||
<path d="M1107 2290 c-316 -57 -615 -283 -748 -565 -68 -144 -91 -241 -96
|
||||
-406 -6 -156 7 -249 49 -374 87 -254 291 -478 542 -596 146 -68 226 -84 426
|
||||
-84 152 0 186 3 260 23 182 50 327 136 465 277 147 150 245 334 282 529 23
|
||||
123 14 344 -20 456 -35 116 -69 190 -134 290 -131 200 -340 354 -578 426 -78
|
||||
23 -111 27 -245 30 -85 1 -177 -1 -203 -6z m362 -216 c91 -21 224 -86 310
|
||||
-152 133 -101 249 -275 293 -439 16 -60 21 -108 21 -203 0 -152 -21 -240 -88
|
||||
-368 -130 -253 -350 -407 -634 -443 -393 -50 -777 214 -882 607 -30 110 -30
|
||||
296 0 408 72 270 282 489 552 576 130 41 287 47 428 14z"/>
|
||||
<path d="M849 1637 c-31 -24 -52 -67 -46 -95 3 -15 35 -78 71 -139 36 -61 66
|
||||
-115 66 -119 0 -5 -30 -58 -66 -119 -36 -60 -68 -123 -70 -140 -7 -42 26 -90
|
||||
70 -105 31 -10 42 -9 72 7 31 15 51 43 125 173 93 162 101 188 73 243 -50 97
|
||||
-169 289 -185 297 -25 14 -91 12 -110 -3z"/>
|
||||
<path d="M1353 1139 c-42 -12 -73 -53 -73 -96 0 -27 8 -43 35 -70 l34 -34 216
|
||||
3 217 3 30 34 c26 29 29 40 25 73 -7 49 -29 75 -76 88 -45 12 -364 12 -408 -1z"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.4 KiB |
1
src/assets/icons/deepseek.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>DeepSeek</title><path d="M23.748 4.482c-.254-.124-.364.113-.512.234-.051.039-.094.09-.137.136-.372.397-.806.657-1.373.626-.829-.046-1.537.214-2.163.848-.133-.782-.575-1.248-1.247-1.548-.352-.156-.708-.311-.955-.65-.172-.241-.219-.51-.305-.774-.055-.16-.11-.323-.293-.35-.2-.031-.278.136-.356.276-.313.572-.434 1.202-.422 1.84.027 1.436.633 2.58 1.838 3.393.137.093.172.187.129.323-.082.28-.18.552-.266.833-.055.179-.137.217-.329.14a5.526 5.526 0 01-1.736-1.18c-.857-.828-1.631-1.742-2.597-2.458a11.365 11.365 0 00-.689-.471c-.985-.957.13-1.743.388-1.836.27-.098.093-.432-.779-.428-.872.004-1.67.295-2.687.684a3.055 3.055 0 01-.465.137 9.597 9.597 0 00-2.883-.102c-1.885.21-3.39 1.102-4.497 2.623C.082 8.606-.231 10.684.152 12.85c.403 2.284 1.569 4.175 3.36 5.653 1.858 1.533 3.997 2.284 6.438 2.14 1.482-.085 3.133-.284 4.994-1.86.47.234.962.327 1.78.397.63.059 1.236-.03 1.705-.128.735-.156.684-.837.419-.961-2.155-1.004-1.682-.595-2.113-.926 1.096-1.296 2.746-2.642 3.392-7.003.05-.347.007-.565 0-.845-.004-.17.035-.237.23-.256a4.173 4.173 0 001.545-.475c1.396-.763 1.96-2.015 2.093-3.517.02-.23-.004-.467-.247-.588zM11.581 18c-2.089-1.642-3.102-2.183-3.52-2.16-.392.024-.321.471-.235.763.09.288.207.486.371.739.114.167.192.416-.113.603-.673.416-1.842-.14-1.897-.167-1.361-.802-2.5-1.86-3.301-3.307-.774-1.393-1.224-2.887-1.298-4.482-.02-.386.093-.522.477-.592a4.696 4.696 0 011.529-.039c2.132.312 3.946 1.265 5.468 2.774.868.86 1.525 1.887 2.202 2.891.72 1.066 1.494 2.082 2.48 2.914.348.292.625.514.891.677-.802.09-2.14.11-3.054-.614zm1-6.44a.306.306 0 01.415-.287.302.302 0 01.2.288.306.306 0 01-.31.307.303.303 0 01-.304-.308zm3.11 1.596c-.2.081-.399.151-.59.16a1.245 1.245 0 01-.798-.254c-.274-.23-.47-.358-.552-.758a1.73 1.73 0 01.016-.588c.07-.327-.008-.537-.239-.727-.187-.156-.426-.199-.688-.199a.559.559 0 01-.254-.078c-.11-.054-.2-.19-.114-.358.028-.054.16-.186.192-.21.356-.202.767-.136 1.146.016.352.144.618.408 1.001.782.391.451.462.576.685.914.176.265.336.537.445.848.067.195-.019.354-.25.452z" fill="#4D6BFE"></path></svg>
|
||||
|
After Width: | Height: | Size: 2.1 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/glm.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>Zhipu</title><path d="M11.991 23.503a.24.24 0 00-.244.248.24.24 0 00.244.249.24.24 0 00.245-.249.24.24 0 00-.22-.247l-.025-.001zM9.671 5.365a1.697 1.697 0 011.099 2.132l-.071.172-.016.04-.018.054c-.07.16-.104.32-.104.498-.035.71.47 1.279 1.186 1.314h.366c1.309.053 2.338 1.173 2.286 2.523-.052 1.332-1.152 2.38-2.478 2.327h-.174c-.715.018-1.274.64-1.239 1.368 0 .124.018.23.053.337.209.373.54.658.96.8.75.23 1.517-.125 1.9-.782l.018-.035c.402-.64 1.17-.96 1.92-.711.854.284 1.378 1.226 1.099 2.167a1.661 1.661 0 01-2.077 1.102 1.711 1.711 0 01-.907-.711l-.017-.035c-.2-.323-.463-.58-.851-.711l-.056-.018a1.646 1.646 0 00-1.954.746 1.66 1.66 0 01-1.065.764 1.677 1.677 0 01-1.989-1.279c-.209-.906.332-1.83 1.257-2.043a1.51 1.51 0 01.296-.035h.018c.68-.071 1.151-.622 1.116-1.333a1.307 1.307 0 00-.227-.693 2.515 2.515 0 01-.366-1.403 2.39 2.39 0 01.366-1.208c.14-.195.21-.444.227-.693.018-.71-.506-1.261-1.186-1.332l-.07-.018a1.43 1.43 0 01-.299-.07l-.05-.019a1.7 1.7 0 01-1.047-2.114 1.68 1.68 0 012.094-1.101zm-5.575 10.11c.26-.264.639-.367.994-.27.355.096.633.379.728.74.095.362-.007.748-.267 1.013-.402.41-1.053.41-1.455 0a1.062 1.062 0 010-1.482zm14.845-.294c.359-.09.738.024.992.297.254.274.344.665.237 1.025-.107.36-.396.634-.756.718-.551.128-1.1-.22-1.23-.781a1.05 1.05 0 01.757-1.26zm-.064-4.39c.314.32.49.753.49 1.206 0 .452-.176.886-.49 1.206-.315.32-.74.5-1.185.5-.444 0-.87-.18-1.184-.5a1.727 1.727 0 010-2.412 1.654 1.654 0 012.369 0zm-11.243.163c.364.484.447 1.128.218 1.691a1.665 1.665 0 01-2.188.923c-.855-.36-1.26-1.358-.907-2.228a1.68 1.68 0 011.33-1.038c.593-.08 1.183.169 1.547.652zm11.545-4.221c.368 0 .708.2.892.524.184.324.184.724 0 1.048a1.026 1.026 0 01-.892.524c-.568 0-1.03-.47-1.03-1.048 0-.579.462-1.048 1.03-1.048zm-14.358 0c.368 0 .707.2.891.524.184.324.184.724 0 1.048a1.026 1.026 0 01-.891.524c-.569 0-1.03-.47-1.03-1.048 0-.579.461-1.048 1.03-1.048zm10.031-1.475c.925 0 1.675.764 1.675 1.706s-.75 1.705-1.675 1.705-1.674-.763-1.674-1.705c0-.942.75-1.706 1.674-1.706zm-2.626-.684c.362-.082.653-.356.761-.718a1.062 1.062 0 00-.238-1.028 1.017 1.017 0 00-.996-.294c-.547.14-.881.7-.752 1.257.13.558.675.907 1.225.783zm0 16.876c.359-.087.644-.36.75-.72a1.062 1.062 0 00-.237-1.019 1.018 1.018 0 00-.985-.301 1.037 1.037 0 00-.762.717c-.108.361-.017.754.239 1.028.245.263.606.377.953.305l.043-.01zM17.19 3.5a.631.631 0 00.628-.64c0-.355-.279-.64-.628-.64a.631.631 0 00-.628.64c0 .355.28.64.628.64zm-10.38 0a.631.631 0 00.628-.64c0-.355-.28-.64-.628-.64a.631.631 0 00-.628.64c0 .355.279.64.628.64zm-5.182 7.852a.631.631 0 00-.628.64c0 .354.28.639.628.639a.63.63 0 00.627-.606l.001-.034a.62.62 0 00-.628-.64zm5.182 9.13a.631.631 0 00-.628.64c0 .355.279.64.628.64a.631.631 0 00.628-.64c0-.355-.28-.64-.628-.64zm10.38.018a.631.631 0 00-.628.64c0 .355.28.64.628.64a.631.631 0 00.628-.64c0-.355-.279-.64-.628-.64zm5.182-9.148a.631.631 0 00-.628.64c0 .354.279.639.628.639a.631.631 0 00.628-.64c0-.355-.28-.64-.628-.64zm-.384-4.992a.24.24 0 00.244-.249.24.24 0 00-.244-.249.24.24 0 00-.244.249c0 .142.122.249.244.249zM11.991.497a.24.24 0 00.245-.248A.24.24 0 0011.99 0a.24.24 0 00-.244.249c0 .133.108.236.223.247l.021.001zM2.011 6.36a.24.24 0 00.245-.249.24.24 0 00-.244-.249.24.24 0 00-.244.249.24.24 0 00.244.249zm0 11.263a.24.24 0 00-.243.248.24.24 0 00.244.249.24.24 0 00.244-.249.252.252 0 00-.244-.248zm19.995-.018a.24.24 0 00-.245.248.24.24 0 00.245.25.24.24 0 00.244-.25.252.252 0 00-.244-.248z" fill="#3859FF" fill-rule="nonzero"></path></svg>
|
||||
|
After Width: | Height: | Size: 3.5 KiB |
1
src/assets/icons/grok.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg fill="currentColor" 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>Grok</title><path d="M9.27 15.29l7.978-5.897c.391-.29.95-.177 1.137.272.98 2.369.542 5.215-1.41 7.169-1.951 1.954-4.667 2.382-7.149 1.406l-2.711 1.257c3.889 2.661 8.611 2.003 11.562-.953 2.341-2.344 3.066-5.539 2.388-8.42l.006.007c-.983-4.232.242-5.924 2.75-9.383.06-.082.12-.164.179-.248l-3.301 3.305v-.01L9.267 15.292M7.623 16.723c-2.792-2.67-2.31-6.801.071-9.184 1.761-1.763 4.647-2.483 7.166-1.425l2.705-1.25a7.808 7.808 0 00-1.829-1A8.975 8.975 0 005.984 5.83c-2.533 2.536-3.33 6.436-1.962 9.764 1.022 2.487-.653 4.246-2.34 6.022-.599.63-1.199 1.259-1.682 1.925l7.62-6.815"></path></svg>
|
||||
|
After Width: | Height: | Size: 756 B |
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/kimi-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>Kimi</title><path d="M19.738 5.776c.163-.209.306-.4.457-.585.07-.087.064-.153-.004-.244-.655-.861-.717-1.817-.34-2.787.283-.73.909-1.072 1.674-1.145.477-.045.945.004 1.379.236.57.305.902.77 1.01 1.412.086.512.07 1.012-.075 1.508-.257.878-.888 1.333-1.753 1.448-.718.096-1.446.108-2.17.157-.056.004-.113 0-.178 0z" fill="#FFFFFF"></path><path d="M17.962 1.844h-4.326l-3.425 7.81H5.369V1.878H1.5V22h3.87v-8.477h6.824a3.025 3.025 0 002.743-1.75V22h3.87v-8.477a3.87 3.87 0 00-3.588-3.86v-.01h-2.125a3.94 3.94 0 002.323-2.12l2.545-5.689z"></path></svg>
|
||||
|
After Width: | Height: | Size: 706 B |
1
src/assets/icons/kimi-light.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg fill="currentColor" 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>Kimi</title><path d="M19.738 5.776c.163-.209.306-.4.457-.585.07-.087.064-.153-.004-.244-.655-.861-.717-1.817-.34-2.787.283-.73.909-1.072 1.674-1.145.477-.045.945.004 1.379.236.57.305.902.77 1.01 1.412.086.512.07 1.012-.075 1.508-.257.878-.888 1.333-1.753 1.448-.718.096-1.446.108-2.17.157-.056.004-.113 0-.178 0z" fill="#027AFF"></path><path d="M17.962 1.844h-4.326l-3.425 7.81H5.369V1.878H1.5V22h3.87v-8.477h6.824a3.025 3.025 0 002.743-1.75V22h3.87v-8.477a3.87 3.87 0 00-3.588-3.86v-.01h-2.125a3.94 3.94 0 002.323-2.12l2.545-5.689z"></path></svg>
|
||||
|
After Width: | Height: | Size: 711 B |
4
src/assets/icons/kiro.svg
Normal file
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
|
||||
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2z" fill="#FF9900"/>
|
||||
<path d="M12 6L8 10h3v4H8l4 4 4-4h-3v-4h3l-4-4z" fill="#232F3E"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 244 B |
1
src/assets/icons/minimax.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>Minimax</title><defs><linearGradient id="lobe-icons-minimax-fill" x1="0%" x2="100.182%" y1="50.057%" y2="50.057%"><stop offset="0%" stop-color="#E2167E"></stop><stop offset="100%" stop-color="#FE603C"></stop></linearGradient></defs><path d="M16.278 2c1.156 0 2.093.927 2.093 2.07v12.501a.74.74 0 00.744.709.74.74 0 00.743-.709V9.099a2.06 2.06 0 012.071-2.049A2.06 2.06 0 0124 9.1v6.561a.649.649 0 01-.652.645.649.649 0 01-.653-.645V9.1a.762.762 0 00-.766-.758.762.762 0 00-.766.758v7.472a2.037 2.037 0 01-2.048 2.026 2.037 2.037 0 01-2.048-2.026v-12.5a.785.785 0 00-.788-.753.785.785 0 00-.789.752l-.001 15.904A2.037 2.037 0 0113.441 22a2.037 2.037 0 01-2.048-2.026V18.04c0-.356.292-.645.652-.645.36 0 .652.289.652.645v1.934c0 .263.142.506.372.638.23.131.514.131.744 0a.734.734 0 00.372-.638V4.07c0-1.143.937-2.07 2.093-2.07zm-5.674 0c1.156 0 2.093.927 2.093 2.07v11.523a.648.648 0 01-.652.645.648.648 0 01-.652-.645V4.07a.785.785 0 00-.789-.78.785.785 0 00-.789.78v14.013a2.06 2.06 0 01-2.07 2.048 2.06 2.06 0 01-2.071-2.048V9.1a.762.762 0 00-.766-.758.762.762 0 00-.766.758v3.8a2.06 2.06 0 01-2.071 2.049A2.06 2.06 0 010 12.9v-1.378c0-.357.292-.646.652-.646.36 0 .653.29.653.646V12.9c0 .418.343.757.766.757s.766-.339.766-.757V9.099a2.06 2.06 0 012.07-2.048 2.06 2.06 0 012.071 2.048v8.984c0 .419.343.758.767.758.423 0 .766-.339.766-.758V4.07c0-1.143.937-2.07 2.093-2.07z" fill="url(#lobe-icons-minimax-fill)" fill-rule="nonzero"></path></svg>
|
||||
|
After Width: | Height: | Size: 1.5 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 |
1
src/assets/logoInline.ts
Normal file
1
src/assets/react.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 4.0 KiB |
65
src/components/common/ConfirmationModal.tsx
Normal file
@@ -0,0 +1,65 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Modal } from '@/components/ui/Modal';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { useNotificationStore } from '@/stores';
|
||||
|
||||
export function ConfirmationModal() {
|
||||
const { t } = useTranslation();
|
||||
const confirmation = useNotificationStore((state) => state.confirmation);
|
||||
const hideConfirmation = useNotificationStore((state) => state.hideConfirmation);
|
||||
const setConfirmationLoading = useNotificationStore((state) => state.setConfirmationLoading);
|
||||
|
||||
const { isOpen, isLoading, options } = confirmation;
|
||||
|
||||
if (!isOpen || !options) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { title, message, onConfirm, onCancel, confirmText, cancelText, variant = 'primary' } = options;
|
||||
|
||||
const handleConfirm = async () => {
|
||||
try {
|
||||
setConfirmationLoading(true);
|
||||
await onConfirm();
|
||||
hideConfirmation();
|
||||
} catch (error) {
|
||||
console.error('Confirmation action failed:', error);
|
||||
// Optional: show error notification here if needed,
|
||||
// but usually the calling component handles specific errors.
|
||||
} finally {
|
||||
setConfirmationLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
if (isLoading) {
|
||||
return;
|
||||
}
|
||||
if (onCancel) {
|
||||
onCancel();
|
||||
}
|
||||
hideConfirmation();
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal open={isOpen} onClose={handleCancel} title={title} closeDisabled={isLoading}>
|
||||
{typeof message === 'string' ? (
|
||||
<p style={{ margin: '1rem 0' }}>{message}</p>
|
||||
) : (
|
||||
<div style={{ margin: '1rem 0' }}>{message}</div>
|
||||
)}
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: '1rem', marginTop: '2rem' }}>
|
||||
<Button variant="ghost" onClick={handleCancel} disabled={isLoading}>
|
||||
{cancelText || t('common.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
variant={variant}
|
||||
onClick={handleConfirm}
|
||||
loading={isLoading}
|
||||
>
|
||||
{confirmText || t('common.confirm')}
|
||||
</Button>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
94
src/components/common/NotificationContainer.tsx
Normal file
@@ -0,0 +1,94 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useNotificationStore } from '@/stores';
|
||||
import { IconX } from '@/components/ui/icons';
|
||||
import type { Notification } from '@/types';
|
||||
|
||||
interface AnimatedNotification extends Notification {
|
||||
isExiting?: boolean;
|
||||
}
|
||||
|
||||
const ANIMATION_DURATION = 300; // ms
|
||||
|
||||
export function NotificationContainer() {
|
||||
const { notifications, removeNotification } = useNotificationStore();
|
||||
const [animatedNotifications, setAnimatedNotifications] = useState<AnimatedNotification[]>([]);
|
||||
const prevNotificationsRef = useRef<Notification[]>([]);
|
||||
|
||||
// Track notifications and manage animation states
|
||||
useEffect(() => {
|
||||
const prevNotifications = prevNotificationsRef.current;
|
||||
const prevIds = new Set(prevNotifications.map((n) => n.id));
|
||||
const currentIds = new Set(notifications.map((n) => n.id));
|
||||
|
||||
// Find new notifications (for enter animation)
|
||||
const newNotifications = notifications.filter((n) => !prevIds.has(n.id));
|
||||
|
||||
// Find removed notifications (for exit animation)
|
||||
const removedIds = new Set(
|
||||
prevNotifications.filter((n) => !currentIds.has(n.id)).map((n) => n.id)
|
||||
);
|
||||
|
||||
setAnimatedNotifications((prev) => {
|
||||
// Mark removed notifications as exiting
|
||||
let updated = prev.map((n) =>
|
||||
removedIds.has(n.id) ? { ...n, isExiting: true } : n
|
||||
);
|
||||
|
||||
// Add new notifications
|
||||
newNotifications.forEach((n) => {
|
||||
if (!updated.find((an) => an.id === n.id)) {
|
||||
updated.push({ ...n, isExiting: false });
|
||||
}
|
||||
});
|
||||
|
||||
// Remove notifications that are not in current and not exiting
|
||||
// (they've already completed their exit animation)
|
||||
updated = updated.filter(
|
||||
(n) => currentIds.has(n.id) || n.isExiting
|
||||
);
|
||||
|
||||
return updated;
|
||||
});
|
||||
|
||||
// Clean up exited notifications after animation
|
||||
if (removedIds.size > 0) {
|
||||
setTimeout(() => {
|
||||
setAnimatedNotifications((prev) =>
|
||||
prev.filter((n) => !removedIds.has(n.id))
|
||||
);
|
||||
}, ANIMATION_DURATION);
|
||||
}
|
||||
|
||||
prevNotificationsRef.current = notifications;
|
||||
}, [notifications]);
|
||||
|
||||
const handleClose = (id: string) => {
|
||||
// Start exit animation
|
||||
setAnimatedNotifications((prev) =>
|
||||
prev.map((n) => (n.id === id ? { ...n, isExiting: true } : n))
|
||||
);
|
||||
|
||||
// Actually remove after animation
|
||||
setTimeout(() => {
|
||||
removeNotification(id);
|
||||
}, ANIMATION_DURATION);
|
||||
};
|
||||
|
||||
if (!animatedNotifications.length) return null;
|
||||
|
||||
return (
|
||||
<div className="notification-container">
|
||||
{animatedNotifications.map((notification) => (
|
||||
<div
|
||||
key={notification.id}
|
||||
className={`notification ${notification.type} ${notification.isExiting ? 'exiting' : 'entering'}`}
|
||||
>
|
||||
<div className="message">{notification.message}</div>
|
||||
<button className="close-btn" onClick={() => handleClose(notification.id)} aria-label="Close">
|
||||
<IconX size={16} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
54
src/components/common/PageTransition.scss
Normal file
@@ -0,0 +1,54 @@
|
||||
@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;
|
||||
background: var(--bg-secondary);
|
||||
backface-visibility: hidden;
|
||||
transform: translateZ(0);
|
||||
|
||||
// During animation, exit layer uses absolute positioning
|
||||
&--exit {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
overflow: hidden;
|
||||
pointer-events: none;
|
||||
will-change: transform, opacity;
|
||||
}
|
||||
|
||||
&--stacked {
|
||||
display: none;
|
||||
|
||||
// Keep the previous layer rendered (but invisible) to avoid a blank flash when popping back.
|
||||
// Older stacked layers remain `display: none` for performance.
|
||||
&.page-transition__layer--stacked-keep {
|
||||
display: flex;
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
overflow: hidden;
|
||||
pointer-events: none;
|
||||
opacity: 0;
|
||||
will-change: transform, opacity;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&--animating &__layer {
|
||||
will-change: transform, opacity;
|
||||
}
|
||||
|
||||
&--animating &__layer:not(.page-transition__layer--exit):not(.page-transition__layer--stacked) {
|
||||
position: relative;
|
||||
}
|
||||
}
|
||||
380
src/components/common/PageTransition.tsx
Normal file
@@ -0,0 +1,380 @@
|
||||
import {
|
||||
ReactNode,
|
||||
useCallback,
|
||||
useLayoutEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { useLocation, type Location } from 'react-router-dom';
|
||||
import gsap from 'gsap';
|
||||
import { PageTransitionLayerContext, type LayerStatus } from './PageTransitionLayer';
|
||||
import './PageTransition.scss';
|
||||
|
||||
interface PageTransitionProps {
|
||||
render: (location: Location) => ReactNode;
|
||||
getRouteOrder?: (pathname: string) => number | null;
|
||||
getTransitionVariant?: (fromPathname: string, toPathname: string) => TransitionVariant;
|
||||
scrollContainerRef?: React.RefObject<HTMLElement | null>;
|
||||
}
|
||||
|
||||
const VERTICAL_TRANSITION_DURATION = 0.35;
|
||||
const VERTICAL_TRAVEL_DISTANCE = 60;
|
||||
const IOS_TRANSITION_DURATION = 0.42;
|
||||
const IOS_ENTER_FROM_X_PERCENT = 100;
|
||||
const IOS_EXIT_TO_X_PERCENT_FORWARD = -30;
|
||||
const IOS_EXIT_TO_X_PERCENT_BACKWARD = 100;
|
||||
const IOS_ENTER_FROM_X_PERCENT_BACKWARD = -30;
|
||||
const IOS_EXIT_DIM_OPACITY = 0.72;
|
||||
|
||||
type Layer = {
|
||||
key: string;
|
||||
location: Location;
|
||||
status: LayerStatus;
|
||||
};
|
||||
|
||||
type TransitionDirection = 'forward' | 'backward';
|
||||
|
||||
type TransitionVariant = 'vertical' | 'ios';
|
||||
|
||||
export function PageTransition({
|
||||
render,
|
||||
getRouteOrder,
|
||||
getTransitionVariant,
|
||||
scrollContainerRef,
|
||||
}: PageTransitionProps) {
|
||||
const location = useLocation();
|
||||
const currentLayerRef = useRef<HTMLDivElement>(null);
|
||||
const exitingLayerRef = useRef<HTMLDivElement>(null);
|
||||
const transitionDirectionRef = useRef<TransitionDirection>('forward');
|
||||
const transitionVariantRef = useRef<TransitionVariant>('vertical');
|
||||
const exitScrollOffsetRef = useRef(0);
|
||||
const enterScrollOffsetRef = useRef(0);
|
||||
const scrollPositionsRef = useRef(new Map<string, number>());
|
||||
const nextLayersRef = useRef<Layer[] | null>(null);
|
||||
|
||||
const [isAnimating, setIsAnimating] = useState(false);
|
||||
const [layers, setLayers] = useState<Layer[]>(() => [
|
||||
{
|
||||
key: location.key,
|
||||
location,
|
||||
status: 'current',
|
||||
},
|
||||
]);
|
||||
const currentLayer =
|
||||
layers.find((layer) => layer.status === 'current') ?? layers[layers.length - 1];
|
||||
const currentLayerKey = currentLayer?.key ?? location.key;
|
||||
const currentLayerPathname = currentLayer?.location.pathname;
|
||||
|
||||
const resolveScrollContainer = useCallback(() => {
|
||||
if (scrollContainerRef?.current) return scrollContainerRef.current;
|
||||
if (typeof document === 'undefined') return null;
|
||||
return document.scrollingElement as HTMLElement | null;
|
||||
}, [scrollContainerRef]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (isAnimating) return;
|
||||
if (location.key === currentLayerKey) return;
|
||||
if (currentLayerPathname === location.pathname) return;
|
||||
const scrollContainer = resolveScrollContainer();
|
||||
const exitScrollOffset = scrollContainer?.scrollTop ?? 0;
|
||||
exitScrollOffsetRef.current = exitScrollOffset;
|
||||
scrollPositionsRef.current.set(currentLayerKey, exitScrollOffset);
|
||||
|
||||
enterScrollOffsetRef.current = scrollPositionsRef.current.get(location.key) ?? 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 nextVariant: TransitionVariant = getTransitionVariant
|
||||
? getTransitionVariant(currentLayerPathname ?? '', location.pathname)
|
||||
: 'vertical';
|
||||
|
||||
let nextDirection: TransitionDirection =
|
||||
fromIndex === null || toIndex === null || fromIndex === toIndex
|
||||
? 'forward'
|
||||
: toIndex > fromIndex
|
||||
? 'forward'
|
||||
: 'backward';
|
||||
|
||||
// When using iOS-style stacking, history POP within the same "section" can have equal route order.
|
||||
// In that case, prefer treating navigation to an existing layer as a backward (pop) transition.
|
||||
if (nextVariant === 'ios' && layers.some((layer) => layer.key === location.key)) {
|
||||
nextDirection = 'backward';
|
||||
}
|
||||
|
||||
transitionDirectionRef.current = nextDirection;
|
||||
transitionVariantRef.current = nextVariant;
|
||||
|
||||
const shouldSkipExitLayer = (() => {
|
||||
if (nextVariant !== 'ios' || nextDirection !== 'backward') return false;
|
||||
const normalizeSegments = (pathname: string) =>
|
||||
pathname
|
||||
.split('/')
|
||||
.filter(Boolean)
|
||||
.filter((segment) => segment.length > 0);
|
||||
const fromSegments = normalizeSegments(currentLayerPathname ?? '');
|
||||
const toSegments = normalizeSegments(location.pathname);
|
||||
if (!fromSegments.length || !toSegments.length) return false;
|
||||
return fromSegments[0] === toSegments[0] && toSegments.length === 1;
|
||||
})();
|
||||
|
||||
setLayers((prev) => {
|
||||
const variant = transitionVariantRef.current;
|
||||
const direction = transitionDirectionRef.current;
|
||||
const previousCurrentIndex = prev.findIndex((layer) => layer.status === 'current');
|
||||
const resolvedCurrentIndex =
|
||||
previousCurrentIndex >= 0 ? previousCurrentIndex : prev.length - 1;
|
||||
const previousCurrent = prev[resolvedCurrentIndex];
|
||||
const previousStack: Layer[] = prev
|
||||
.filter((_, idx) => idx !== resolvedCurrentIndex)
|
||||
.map((layer): Layer => ({ ...layer, status: 'stacked' }));
|
||||
|
||||
const nextCurrent: Layer = { key: location.key, location, status: 'current' };
|
||||
|
||||
if (!previousCurrent) {
|
||||
nextLayersRef.current = [nextCurrent];
|
||||
return [nextCurrent];
|
||||
}
|
||||
|
||||
if (variant === 'ios') {
|
||||
if (direction === 'forward') {
|
||||
const exitingLayer: Layer = { ...previousCurrent, status: 'exiting' };
|
||||
const stackedLayer: Layer = { ...previousCurrent, status: 'stacked' };
|
||||
|
||||
nextLayersRef.current = [...previousStack, stackedLayer, nextCurrent];
|
||||
return [...previousStack, exitingLayer, nextCurrent];
|
||||
}
|
||||
|
||||
const targetIndex = prev.findIndex((layer) => layer.key === location.key);
|
||||
if (targetIndex !== -1) {
|
||||
const targetStack: Layer[] = prev
|
||||
.slice(0, targetIndex + 1)
|
||||
.map((layer, idx): Layer => {
|
||||
const isTarget = idx === targetIndex;
|
||||
return {
|
||||
...layer,
|
||||
location: isTarget ? location : layer.location,
|
||||
status: isTarget ? 'current' : 'stacked',
|
||||
};
|
||||
});
|
||||
|
||||
if (shouldSkipExitLayer) {
|
||||
nextLayersRef.current = targetStack;
|
||||
return targetStack;
|
||||
}
|
||||
|
||||
const exitingLayer: Layer = { ...previousCurrent, status: 'exiting' };
|
||||
nextLayersRef.current = targetStack;
|
||||
return [...targetStack, exitingLayer];
|
||||
}
|
||||
}
|
||||
|
||||
if (shouldSkipExitLayer) {
|
||||
nextLayersRef.current = [nextCurrent];
|
||||
return [nextCurrent];
|
||||
}
|
||||
|
||||
const exitingLayer: Layer = { ...previousCurrent, status: 'exiting' };
|
||||
|
||||
nextLayersRef.current = [nextCurrent];
|
||||
return [exitingLayer, nextCurrent];
|
||||
});
|
||||
setIsAnimating(true);
|
||||
}, [
|
||||
isAnimating,
|
||||
location,
|
||||
currentLayerKey,
|
||||
currentLayerPathname,
|
||||
getRouteOrder,
|
||||
getTransitionVariant,
|
||||
resolveScrollContainer,
|
||||
layers,
|
||||
]);
|
||||
|
||||
// Run GSAP animation when animating starts
|
||||
useLayoutEffect(() => {
|
||||
if (!isAnimating) return;
|
||||
|
||||
if (!currentLayerRef.current) return;
|
||||
|
||||
const currentLayerEl = currentLayerRef.current;
|
||||
const exitingLayerEl = exitingLayerRef.current;
|
||||
const transitionVariant = transitionVariantRef.current;
|
||||
|
||||
gsap.set(currentLayerEl, { clearProps: 'transform,opacity,boxShadow' });
|
||||
if (exitingLayerEl) {
|
||||
gsap.set(exitingLayerEl, { clearProps: 'transform,opacity,boxShadow' });
|
||||
}
|
||||
|
||||
const scrollContainer = resolveScrollContainer();
|
||||
const exitScrollOffset = exitScrollOffsetRef.current;
|
||||
const enterScrollOffset = enterScrollOffsetRef.current;
|
||||
if (scrollContainer && exitScrollOffset !== enterScrollOffset) {
|
||||
scrollContainer.scrollTo({ top: enterScrollOffset, left: 0, behavior: 'auto' });
|
||||
}
|
||||
|
||||
const transitionDirection = transitionDirectionRef.current;
|
||||
const isForward = transitionDirection === 'forward';
|
||||
const enterFromY = isForward ? VERTICAL_TRAVEL_DISTANCE : -VERTICAL_TRAVEL_DISTANCE;
|
||||
const exitToY = isForward ? -VERTICAL_TRAVEL_DISTANCE : VERTICAL_TRAVEL_DISTANCE;
|
||||
const exitBaseY = enterScrollOffset - exitScrollOffset;
|
||||
|
||||
const tl = gsap.timeline({
|
||||
onComplete: () => {
|
||||
const nextLayers = nextLayersRef.current;
|
||||
nextLayersRef.current = null;
|
||||
setLayers((prev) => nextLayers ?? prev.filter((layer) => layer.status !== 'exiting'));
|
||||
setIsAnimating(false);
|
||||
|
||||
if (currentLayerEl) {
|
||||
gsap.set(currentLayerEl, { clearProps: 'transform,opacity,boxShadow' });
|
||||
}
|
||||
if (exitingLayerEl) {
|
||||
gsap.set(exitingLayerEl, { clearProps: 'transform,opacity,boxShadow' });
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
if (transitionVariant === 'ios') {
|
||||
const exitToXPercent = isForward
|
||||
? IOS_EXIT_TO_X_PERCENT_FORWARD
|
||||
: IOS_EXIT_TO_X_PERCENT_BACKWARD;
|
||||
const enterFromXPercent = isForward
|
||||
? IOS_ENTER_FROM_X_PERCENT
|
||||
: IOS_ENTER_FROM_X_PERCENT_BACKWARD;
|
||||
|
||||
if (exitingLayerEl) {
|
||||
gsap.set(exitingLayerEl, {
|
||||
y: exitBaseY,
|
||||
xPercent: 0,
|
||||
opacity: 1,
|
||||
});
|
||||
}
|
||||
|
||||
gsap.set(currentLayerEl, {
|
||||
xPercent: enterFromXPercent,
|
||||
opacity: 1,
|
||||
});
|
||||
|
||||
const shadowValue = '-14px 0 24px rgba(0, 0, 0, 0.16)';
|
||||
|
||||
const topLayerEl = isForward ? currentLayerEl : exitingLayerEl;
|
||||
if (topLayerEl) {
|
||||
gsap.set(topLayerEl, { boxShadow: shadowValue });
|
||||
}
|
||||
|
||||
if (exitingLayerEl) {
|
||||
tl.to(
|
||||
exitingLayerEl,
|
||||
{
|
||||
xPercent: exitToXPercent,
|
||||
opacity: isForward ? IOS_EXIT_DIM_OPACITY : 1,
|
||||
duration: IOS_TRANSITION_DURATION,
|
||||
ease: 'power2.out',
|
||||
force3D: true,
|
||||
},
|
||||
0
|
||||
);
|
||||
}
|
||||
|
||||
tl.to(
|
||||
currentLayerEl,
|
||||
{
|
||||
xPercent: 0,
|
||||
opacity: 1,
|
||||
duration: IOS_TRANSITION_DURATION,
|
||||
ease: 'power2.out',
|
||||
force3D: true,
|
||||
},
|
||||
0
|
||||
);
|
||||
} else {
|
||||
// Exit animation: fade out with slight movement (runs simultaneously)
|
||||
if (exitingLayerEl) {
|
||||
gsap.set(exitingLayerEl, { y: exitBaseY });
|
||||
tl.to(
|
||||
exitingLayerEl,
|
||||
{
|
||||
y: exitBaseY + exitToY,
|
||||
opacity: 0,
|
||||
duration: VERTICAL_TRANSITION_DURATION,
|
||||
ease: 'circ.out',
|
||||
force3D: true,
|
||||
},
|
||||
0
|
||||
);
|
||||
}
|
||||
|
||||
// Enter animation: fade in with slight movement (runs simultaneously)
|
||||
tl.fromTo(
|
||||
currentLayerEl,
|
||||
{ y: enterFromY, opacity: 0 },
|
||||
{
|
||||
y: 0,
|
||||
opacity: 1,
|
||||
duration: VERTICAL_TRANSITION_DURATION,
|
||||
ease: 'circ.out',
|
||||
force3D: true,
|
||||
onComplete: () => {
|
||||
if (currentLayerEl) {
|
||||
gsap.set(currentLayerEl, { clearProps: 'transform,opacity' });
|
||||
}
|
||||
},
|
||||
},
|
||||
0
|
||||
);
|
||||
}
|
||||
|
||||
return () => {
|
||||
tl.kill();
|
||||
gsap.killTweensOf([currentLayerEl, exitingLayerEl]);
|
||||
};
|
||||
}, [isAnimating, resolveScrollContainer]);
|
||||
|
||||
return (
|
||||
<div className={`page-transition${isAnimating ? ' page-transition--animating' : ''}`}>
|
||||
{(() => {
|
||||
const currentIndex = layers.findIndex((layer) => layer.status === 'current');
|
||||
const resolvedCurrentIndex = currentIndex === -1 ? layers.length - 1 : currentIndex;
|
||||
const keepStackedIndex = layers
|
||||
.slice(0, resolvedCurrentIndex)
|
||||
.map((layer, index) => ({ layer, index }))
|
||||
.reverse()
|
||||
.find(({ layer }) => layer.status === 'stacked')?.index;
|
||||
|
||||
return layers.map((layer, index) => {
|
||||
const shouldKeepStacked = layer.status === 'stacked' && index === keepStackedIndex;
|
||||
return (
|
||||
<div
|
||||
key={layer.key}
|
||||
className={[
|
||||
'page-transition__layer',
|
||||
layer.status === 'exiting' ? 'page-transition__layer--exit' : '',
|
||||
layer.status === 'stacked' ? 'page-transition__layer--stacked' : '',
|
||||
shouldKeepStacked ? 'page-transition__layer--stacked-keep' : '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
aria-hidden={layer.status !== 'current'}
|
||||
inert={layer.status !== 'current'}
|
||||
ref={
|
||||
layer.status === 'exiting'
|
||||
? exitingLayerRef
|
||||
: layer.status === 'current'
|
||||
? currentLayerRef
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<PageTransitionLayerContext.Provider value={{ status: layer.status }}>
|
||||
{render(layer.location)}
|
||||
</PageTransitionLayerContext.Provider>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
})()}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
15
src/components/common/PageTransitionLayer.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { createContext, useContext } from 'react';
|
||||
|
||||
export type LayerStatus = 'current' | 'exiting' | 'stacked';
|
||||
|
||||
type PageTransitionLayerContextValue = {
|
||||
status: LayerStatus;
|
||||
};
|
||||
|
||||
export const PageTransitionLayerContext =
|
||||
createContext<PageTransitionLayerContextValue | null>(null);
|
||||
|
||||
export function usePageTransitionLayer() {
|
||||
return useContext(PageTransitionLayerContext);
|
||||
}
|
||||
|
||||
84
src/components/common/SecondaryScreenShell.module.scss
Normal file
@@ -0,0 +1,84 @@
|
||||
@use '../../styles/variables' as *;
|
||||
|
||||
.container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $spacing-lg;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.topBar {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 5;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto 1fr;
|
||||
align-items: center;
|
||||
gap: $spacing-md;
|
||||
padding: $spacing-sm $spacing-md;
|
||||
background: var(--bg-secondary);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
min-height: 44px;
|
||||
}
|
||||
|
||||
.topBarTitle {
|
||||
min-width: 0;
|
||||
text-align: center;
|
||||
font-size: 16px;
|
||||
font-weight: 650;
|
||||
color: var(--text-primary);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
justify-self: center;
|
||||
}
|
||||
|
||||
.backButton {
|
||||
padding-left: 6px;
|
||||
padding-right: 10px;
|
||||
justify-self: start;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.backButton > span:last-child {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.backIcon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
svg {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
.backText {
|
||||
font-weight: 600;
|
||||
line-height: 18px;
|
||||
}
|
||||
|
||||
.rightSlot {
|
||||
justify-self: end;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.loadingState {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: $spacing-sm;
|
||||
padding: $spacing-2xl 0;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $spacing-lg;
|
||||
}
|
||||
|
||||
78
src/components/common/SecondaryScreenShell.tsx
Normal file
@@ -0,0 +1,78 @@
|
||||
import { forwardRef, type ReactNode } from 'react';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { LoadingSpinner } from '@/components/ui/LoadingSpinner';
|
||||
import { IconChevronLeft } from '@/components/ui/icons';
|
||||
import styles from './SecondaryScreenShell.module.scss';
|
||||
|
||||
export type SecondaryScreenShellProps = {
|
||||
title: ReactNode;
|
||||
onBack?: () => void;
|
||||
backLabel?: string;
|
||||
backAriaLabel?: string;
|
||||
rightAction?: ReactNode;
|
||||
isLoading?: boolean;
|
||||
loadingLabel?: ReactNode;
|
||||
className?: string;
|
||||
contentClassName?: string;
|
||||
children?: ReactNode;
|
||||
};
|
||||
|
||||
export const SecondaryScreenShell = forwardRef<HTMLDivElement, SecondaryScreenShellProps>(
|
||||
function SecondaryScreenShell(
|
||||
{
|
||||
title,
|
||||
onBack,
|
||||
backLabel = 'Back',
|
||||
backAriaLabel,
|
||||
rightAction,
|
||||
isLoading = false,
|
||||
loadingLabel = 'Loading...',
|
||||
className = '',
|
||||
contentClassName = '',
|
||||
children,
|
||||
},
|
||||
ref
|
||||
) {
|
||||
const containerClassName = [styles.container, className].filter(Boolean).join(' ');
|
||||
const contentClasses = [styles.content, contentClassName].filter(Boolean).join(' ');
|
||||
const titleTooltip = typeof title === 'string' ? title : undefined;
|
||||
const resolvedBackAriaLabel = backAriaLabel ?? backLabel;
|
||||
|
||||
return (
|
||||
<div className={containerClassName} ref={ref}>
|
||||
<div className={styles.topBar}>
|
||||
{onBack ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onBack}
|
||||
className={styles.backButton}
|
||||
aria-label={resolvedBackAriaLabel}
|
||||
>
|
||||
<span className={styles.backIcon}>
|
||||
<IconChevronLeft size={18} />
|
||||
</span>
|
||||
<span className={styles.backText}>{backLabel}</span>
|
||||
</Button>
|
||||
) : (
|
||||
<div />
|
||||
)}
|
||||
<div className={styles.topBarTitle} title={titleTooltip}>
|
||||
{title}
|
||||
</div>
|
||||
<div className={styles.rightSlot}>{rightAction}</div>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className={styles.loadingState}>
|
||||
<LoadingSpinner size={16} />
|
||||
<span>{loadingLabel}</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className={contentClasses}>{children}</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>
|
||||
);
|
||||
}
|
||||
22
src/components/config/ConfigSection.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import type { PropsWithChildren, ReactNode } from 'react';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
|
||||
interface ConfigSectionProps {
|
||||
title: ReactNode;
|
||||
description?: ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function ConfigSection({ title, description, className, children }: PropsWithChildren<ConfigSectionProps>) {
|
||||
return (
|
||||
<Card title={title} className={className}>
|
||||
{description && (
|
||||
<p style={{ margin: '-4px 0 16px 0', color: 'var(--text-secondary)', fontSize: 13 }}>
|
||||
{description}
|
||||
</p>
|
||||
)}
|
||||
{children}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
37
src/components/config/VisualConfigEditor.module.scss
Normal file
@@ -0,0 +1,37 @@
|
||||
.payloadRuleModelRow {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 160px auto;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.payloadRuleModelRowProtocolFirst {
|
||||
grid-template-columns: 160px 1fr auto;
|
||||
}
|
||||
|
||||
.payloadRuleParamRow {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 140px 1fr auto;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.payloadFilterModelRow {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 160px auto;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.payloadRuleModelRow,
|
||||
.payloadRuleModelRowProtocolFirst,
|
||||
.payloadRuleParamRow,
|
||||
.payloadFilterModelRow {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.payloadRowActionButton {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
1177
src/components/config/VisualConfigEditor.tsx
Normal file
619
src/components/layout/MainLayout.tsx
Normal file
@@ -0,0 +1,619 @@
|
||||
import {
|
||||
ReactNode,
|
||||
SVGProps,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { NavLink, useLocation } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { PageTransition } from '@/components/common/PageTransition';
|
||||
import { MainRoutes } from '@/router/MainRoutes';
|
||||
import {
|
||||
IconBot,
|
||||
IconChartLine,
|
||||
IconFileText,
|
||||
IconInfo,
|
||||
IconLayoutDashboard,
|
||||
IconScrollText,
|
||||
IconSettings,
|
||||
IconShield,
|
||||
IconTimer,
|
||||
IconActivity,
|
||||
} from '@/components/ui/icons';
|
||||
import { INLINE_LOGO_JPEG } from '@/assets/logoInline';
|
||||
import {
|
||||
useAuthStore,
|
||||
useConfigStore,
|
||||
useLanguageStore,
|
||||
useNotificationStore,
|
||||
useThemeStore,
|
||||
} from '@/stores';
|
||||
import { versionApi } from '@/services/api';
|
||||
import { triggerHeaderRefresh } from '@/hooks/useHeaderRefresh';
|
||||
import { LANGUAGE_LABEL_KEYS, LANGUAGE_ORDER } from '@/utils/constants';
|
||||
import { isSupportedLanguage } from '@/utils/language';
|
||||
|
||||
const sidebarIcons: Record<string, ReactNode> = {
|
||||
dashboard: <IconLayoutDashboard size={18} />,
|
||||
aiProviders: <IconBot size={18} />,
|
||||
authFiles: <IconFileText size={18} />,
|
||||
oauth: <IconShield size={18} />,
|
||||
quota: <IconTimer size={18} />,
|
||||
usage: <IconChartLine size={18} />,
|
||||
config: <IconSettings size={18} />,
|
||||
logs: <IconScrollText size={18} />,
|
||||
system: <IconInfo size={18} />,
|
||||
monitor: <IconActivity size={18} />,
|
||||
};
|
||||
|
||||
// Header action icons - smaller size for header buttons
|
||||
const headerIconProps: SVGProps<SVGSVGElement> = {
|
||||
width: 16,
|
||||
height: 16,
|
||||
viewBox: '0 0 24 24',
|
||||
fill: 'none',
|
||||
stroke: 'currentColor',
|
||||
strokeWidth: 2,
|
||||
strokeLinecap: 'round',
|
||||
strokeLinejoin: 'round',
|
||||
'aria-hidden': 'true',
|
||||
focusable: 'false',
|
||||
};
|
||||
|
||||
const headerIcons = {
|
||||
refresh: (
|
||||
<svg {...headerIconProps}>
|
||||
<path d="M21 12a9 9 0 1 1-9-9c2.52 0 4.93 1 6.74 2.74L21 8" />
|
||||
<path d="M21 3v5h-5" />
|
||||
</svg>
|
||||
),
|
||||
update: (
|
||||
<svg {...headerIconProps}>
|
||||
<path d="M12 19V5" />
|
||||
<path d="m5 12 7-7 7 7" />
|
||||
</svg>
|
||||
),
|
||||
menu: (
|
||||
<svg {...headerIconProps}>
|
||||
<path d="M4 7h16" />
|
||||
<path d="M4 12h16" />
|
||||
<path d="M4 17h16" />
|
||||
</svg>
|
||||
),
|
||||
chevronLeft: (
|
||||
<svg {...headerIconProps}>
|
||||
<path d="m14 18-6-6 6-6" />
|
||||
</svg>
|
||||
),
|
||||
chevronRight: (
|
||||
<svg {...headerIconProps}>
|
||||
<path d="m10 6 6 6-6 6" />
|
||||
</svg>
|
||||
),
|
||||
language: (
|
||||
<svg {...headerIconProps}>
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<path d="M2 12h20" />
|
||||
<path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z" />
|
||||
</svg>
|
||||
),
|
||||
sun: (
|
||||
<svg {...headerIconProps}>
|
||||
<circle cx="12" cy="12" r="4" />
|
||||
<path d="M12 2v2" />
|
||||
<path d="M12 20v2" />
|
||||
<path d="m4.93 4.93 1.41 1.41" />
|
||||
<path d="m17.66 17.66 1.41 1.41" />
|
||||
<path d="M2 12h2" />
|
||||
<path d="M20 12h2" />
|
||||
<path d="m6.34 17.66-1.41 1.41" />
|
||||
<path d="m19.07 4.93-1.41 1.41" />
|
||||
</svg>
|
||||
),
|
||||
moon: (
|
||||
<svg {...headerIconProps}>
|
||||
<path d="M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9z" />
|
||||
</svg>
|
||||
),
|
||||
autoTheme: (
|
||||
<svg {...headerIconProps}>
|
||||
<defs>
|
||||
<clipPath id="mainLayoutAutoThemeSunLeftHalf">
|
||||
<rect x="0" y="0" width="12" height="24" />
|
||||
</clipPath>
|
||||
</defs>
|
||||
<circle cx="12" cy="12" r="4" />
|
||||
<circle cx="12" cy="12" r="4" clipPath="url(#mainLayoutAutoThemeSunLeftHalf)" fill="currentColor" />
|
||||
<path d="M12 2v2" />
|
||||
<path d="M12 20v2" />
|
||||
<path d="M4.93 4.93l1.41 1.41" />
|
||||
<path d="M17.66 17.66l1.41 1.41" />
|
||||
<path d="M2 12h2" />
|
||||
<path d="M20 12h2" />
|
||||
<path d="M6.34 17.66l-1.41 1.41" />
|
||||
<path d="M19.07 4.93l-1.41 1.41" />
|
||||
</svg>
|
||||
),
|
||||
logout: (
|
||||
<svg {...headerIconProps}>
|
||||
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" />
|
||||
<path d="m16 17 5-5-5-5" />
|
||||
<path d="M21 12H9" />
|
||||
</svg>
|
||||
),
|
||||
};
|
||||
|
||||
const parseVersionSegments = (version?: string | null) => {
|
||||
if (!version) return null;
|
||||
const cleaned = version.trim().replace(/^v/i, '');
|
||||
if (!cleaned) return null;
|
||||
const parts = cleaned
|
||||
.split(/[^0-9]+/)
|
||||
.filter(Boolean)
|
||||
.map((segment) => Number.parseInt(segment, 10))
|
||||
.filter(Number.isFinite);
|
||||
return parts.length ? parts : null;
|
||||
};
|
||||
|
||||
const compareVersions = (latest?: string | null, current?: string | null) => {
|
||||
const latestParts = parseVersionSegments(latest);
|
||||
const currentParts = parseVersionSegments(current);
|
||||
if (!latestParts || !currentParts) return null;
|
||||
const length = Math.max(latestParts.length, currentParts.length);
|
||||
for (let i = 0; i < length; i++) {
|
||||
const l = latestParts[i] || 0;
|
||||
const c = currentParts[i] || 0;
|
||||
if (l > c) return 1;
|
||||
if (l < c) return -1;
|
||||
}
|
||||
return 0;
|
||||
};
|
||||
|
||||
export function MainLayout() {
|
||||
const { t } = useTranslation();
|
||||
const { showNotification } = useNotificationStore();
|
||||
const location = useLocation();
|
||||
|
||||
const apiBase = useAuthStore((state) => state.apiBase);
|
||||
const serverVersion = useAuthStore((state) => state.serverVersion);
|
||||
const connectionStatus = useAuthStore((state) => state.connectionStatus);
|
||||
const logout = useAuthStore((state) => state.logout);
|
||||
|
||||
const config = useConfigStore((state) => state.config);
|
||||
const fetchConfig = useConfigStore((state) => state.fetchConfig);
|
||||
const clearCache = useConfigStore((state) => state.clearCache);
|
||||
|
||||
const theme = useThemeStore((state) => state.theme);
|
||||
const cycleTheme = useThemeStore((state) => state.cycleTheme);
|
||||
const language = useLanguageStore((state) => state.language);
|
||||
const setLanguage = useLanguageStore((state) => state.setLanguage);
|
||||
|
||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
|
||||
const [checkingVersion, setCheckingVersion] = useState(false);
|
||||
const [languageMenuOpen, setLanguageMenuOpen] = useState(false);
|
||||
const [brandExpanded, setBrandExpanded] = useState(true);
|
||||
const contentRef = useRef<HTMLDivElement | null>(null);
|
||||
const languageMenuRef = useRef<HTMLDivElement | null>(null);
|
||||
const brandCollapseTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const headerRef = useRef<HTMLElement | null>(null);
|
||||
|
||||
const fullBrandName = 'CLI Proxy API Management Center';
|
||||
const abbrBrandName = t('title.abbr');
|
||||
const isLogsPage = location.pathname.startsWith('/logs');
|
||||
|
||||
// 将顶栏高度写入 CSS 变量,确保侧栏/内容区计算一致,防止滚动时抖动
|
||||
useLayoutEffect(() => {
|
||||
const updateHeaderHeight = () => {
|
||||
const height = headerRef.current?.offsetHeight;
|
||||
if (height) {
|
||||
document.documentElement.style.setProperty('--header-height', `${height}px`);
|
||||
}
|
||||
};
|
||||
|
||||
updateHeaderHeight();
|
||||
|
||||
const resizeObserver =
|
||||
typeof ResizeObserver !== 'undefined' && headerRef.current
|
||||
? new ResizeObserver(updateHeaderHeight)
|
||||
: null;
|
||||
if (resizeObserver && headerRef.current) {
|
||||
resizeObserver.observe(headerRef.current);
|
||||
}
|
||||
|
||||
window.addEventListener('resize', updateHeaderHeight);
|
||||
|
||||
return () => {
|
||||
if (resizeObserver) {
|
||||
resizeObserver.disconnect();
|
||||
}
|
||||
window.removeEventListener('resize', updateHeaderHeight);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// 将主内容区的中心点写入 CSS 变量,供底部浮层(配置面板操作栏、提供商导航)对齐到内容区
|
||||
useLayoutEffect(() => {
|
||||
const updateContentCenter = () => {
|
||||
const el = contentRef.current;
|
||||
if (!el) return;
|
||||
const rect = el.getBoundingClientRect();
|
||||
const centerX = rect.left + rect.width / 2;
|
||||
document.documentElement.style.setProperty('--content-center-x', `${centerX}px`);
|
||||
};
|
||||
|
||||
updateContentCenter();
|
||||
|
||||
const resizeObserver =
|
||||
typeof ResizeObserver !== 'undefined' && contentRef.current
|
||||
? new ResizeObserver(updateContentCenter)
|
||||
: null;
|
||||
|
||||
if (resizeObserver && contentRef.current) {
|
||||
resizeObserver.observe(contentRef.current);
|
||||
}
|
||||
|
||||
window.addEventListener('resize', updateContentCenter);
|
||||
|
||||
return () => {
|
||||
if (resizeObserver) {
|
||||
resizeObserver.disconnect();
|
||||
}
|
||||
window.removeEventListener('resize', updateContentCenter);
|
||||
document.documentElement.style.removeProperty('--content-center-x');
|
||||
};
|
||||
}, []);
|
||||
|
||||
// 5秒后自动收起品牌名称
|
||||
useEffect(() => {
|
||||
brandCollapseTimer.current = setTimeout(() => {
|
||||
setBrandExpanded(false);
|
||||
}, 5000);
|
||||
|
||||
return () => {
|
||||
if (brandCollapseTimer.current) {
|
||||
clearTimeout(brandCollapseTimer.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!languageMenuOpen) {
|
||||
return;
|
||||
}
|
||||
|
||||
const handlePointerDown = (event: MouseEvent) => {
|
||||
if (!languageMenuRef.current?.contains(event.target as Node)) {
|
||||
setLanguageMenuOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleEscape = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') {
|
||||
setLanguageMenuOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('mousedown', handlePointerDown);
|
||||
document.addEventListener('keydown', handleEscape);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handlePointerDown);
|
||||
document.removeEventListener('keydown', handleEscape);
|
||||
};
|
||||
}, [languageMenuOpen]);
|
||||
|
||||
const handleBrandClick = useCallback(() => {
|
||||
if (!brandExpanded) {
|
||||
setBrandExpanded(true);
|
||||
// 点击展开后,5秒后再次收起
|
||||
if (brandCollapseTimer.current) {
|
||||
clearTimeout(brandCollapseTimer.current);
|
||||
}
|
||||
brandCollapseTimer.current = setTimeout(() => {
|
||||
setBrandExpanded(false);
|
||||
}, 5000);
|
||||
}
|
||||
}, [brandExpanded]);
|
||||
|
||||
const toggleLanguageMenu = useCallback(() => {
|
||||
setLanguageMenuOpen((prev) => !prev);
|
||||
}, []);
|
||||
|
||||
const handleLanguageSelect = useCallback(
|
||||
(nextLanguage: string) => {
|
||||
if (!isSupportedLanguage(nextLanguage)) {
|
||||
return;
|
||||
}
|
||||
setLanguage(nextLanguage);
|
||||
setLanguageMenuOpen(false);
|
||||
},
|
||||
[setLanguage]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
fetchConfig().catch(() => {
|
||||
// ignore initial failure; login flow会提示
|
||||
});
|
||||
}, [fetchConfig]);
|
||||
|
||||
|
||||
const statusClass =
|
||||
connectionStatus === 'connected'
|
||||
? 'success'
|
||||
: connectionStatus === 'connecting'
|
||||
? 'warning'
|
||||
: connectionStatus === 'error'
|
||||
? 'error'
|
||||
: 'muted';
|
||||
|
||||
const navItems = [
|
||||
{ path: '/', label: t('nav.dashboard'), icon: sidebarIcons.dashboard },
|
||||
{ path: '/config', label: t('nav.config_management'), icon: sidebarIcons.config },
|
||||
{ path: '/ai-providers', label: t('nav.ai_providers'), icon: sidebarIcons.aiProviders },
|
||||
{ path: '/auth-files', label: t('nav.auth_files'), icon: sidebarIcons.authFiles },
|
||||
{ path: '/oauth', label: t('nav.oauth', { defaultValue: 'OAuth' }), icon: sidebarIcons.oauth },
|
||||
{ path: '/quota', label: t('nav.quota_management'), icon: sidebarIcons.quota },
|
||||
{ path: '/usage', label: t('nav.usage_stats'), icon: sidebarIcons.usage },
|
||||
...(config?.loggingToFile
|
||||
? [{ path: '/logs', label: t('nav.logs'), icon: sidebarIcons.logs }]
|
||||
: []),
|
||||
{ path: '/system', label: t('nav.system_info'), icon: sidebarIcons.system },
|
||||
{ path: '/monitor', label: t('nav.monitor'), icon: sidebarIcons.monitor },
|
||||
];
|
||||
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 aiProvidersIndex = navOrder.indexOf('/ai-providers');
|
||||
if (aiProvidersIndex !== -1) {
|
||||
if (normalizedPath === '/ai-providers') return aiProvidersIndex;
|
||||
if (normalizedPath.startsWith('/ai-providers/')) {
|
||||
if (normalizedPath.startsWith('/ai-providers/gemini')) return aiProvidersIndex + 0.1;
|
||||
if (normalizedPath.startsWith('/ai-providers/codex')) return aiProvidersIndex + 0.2;
|
||||
if (normalizedPath.startsWith('/ai-providers/claude')) return aiProvidersIndex + 0.3;
|
||||
if (normalizedPath.startsWith('/ai-providers/vertex')) return aiProvidersIndex + 0.4;
|
||||
if (normalizedPath.startsWith('/ai-providers/ampcode')) return aiProvidersIndex + 0.5;
|
||||
if (normalizedPath.startsWith('/ai-providers/openai')) return aiProvidersIndex + 0.6;
|
||||
return aiProvidersIndex + 0.05;
|
||||
}
|
||||
}
|
||||
|
||||
const authFilesIndex = navOrder.indexOf('/auth-files');
|
||||
if (authFilesIndex !== -1) {
|
||||
if (normalizedPath === '/auth-files') return authFilesIndex;
|
||||
if (normalizedPath.startsWith('/auth-files/')) {
|
||||
if (normalizedPath.startsWith('/auth-files/oauth-excluded')) return authFilesIndex + 0.1;
|
||||
if (normalizedPath.startsWith('/auth-files/oauth-model-alias')) return authFilesIndex + 0.2;
|
||||
return authFilesIndex + 0.05;
|
||||
}
|
||||
}
|
||||
|
||||
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 getTransitionVariant = useCallback((fromPathname: string, toPathname: string) => {
|
||||
const normalize = (pathname: string) => {
|
||||
const trimmed =
|
||||
pathname.length > 1 && pathname.endsWith('/') ? pathname.slice(0, -1) : pathname;
|
||||
return trimmed === '/dashboard' ? '/' : trimmed;
|
||||
};
|
||||
|
||||
const from = normalize(fromPathname);
|
||||
const to = normalize(toPathname);
|
||||
const isAuthFiles = (pathname: string) =>
|
||||
pathname === '/auth-files' || pathname.startsWith('/auth-files/');
|
||||
const isAiProviders = (pathname: string) =>
|
||||
pathname === '/ai-providers' || pathname.startsWith('/ai-providers/');
|
||||
if (isAuthFiles(from) && isAuthFiles(to)) return 'ios';
|
||||
if (isAiProviders(from) && isAiProviders(to)) return 'ios';
|
||||
return 'vertical';
|
||||
}, []);
|
||||
|
||||
const handleRefreshAll = async () => {
|
||||
clearCache();
|
||||
const results = await Promise.allSettled([
|
||||
fetchConfig(undefined, true),
|
||||
triggerHeaderRefresh()
|
||||
]);
|
||||
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 () => {
|
||||
setCheckingVersion(true);
|
||||
try {
|
||||
const data = await versionApi.checkLatest();
|
||||
const latestRaw = data?.['latest-version'] ?? data?.latest_version ?? data?.latest ?? '';
|
||||
const latest = typeof latestRaw === 'string' ? latestRaw : String(latestRaw ?? '');
|
||||
const comparison = compareVersions(latest, serverVersion);
|
||||
|
||||
if (!latest) {
|
||||
showNotification(t('system_info.version_check_error'), 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
if (comparison === null) {
|
||||
showNotification(t('system_info.version_current_missing'), 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
if (comparison > 0) {
|
||||
showNotification(t('system_info.version_update_available', { version: latest }), 'warning');
|
||||
} else {
|
||||
showNotification(t('system_info.version_is_latest'), 'success');
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
const message =
|
||||
error instanceof Error ? error.message : typeof error === 'string' ? error : '';
|
||||
const suffix = message ? `: ${message}` : '';
|
||||
showNotification(`${t('system_info.version_check_error')}${suffix}`, 'error');
|
||||
} finally {
|
||||
setCheckingVersion(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="app-shell">
|
||||
<header className="main-header" ref={headerRef}>
|
||||
<div className="left">
|
||||
<button
|
||||
className="sidebar-toggle-header"
|
||||
onClick={() => setSidebarCollapsed((prev) => !prev)}
|
||||
title={
|
||||
sidebarCollapsed
|
||||
? t('sidebar.expand', { defaultValue: '展开' })
|
||||
: t('sidebar.collapse', { defaultValue: '收起' })
|
||||
}
|
||||
>
|
||||
{sidebarCollapsed ? headerIcons.chevronRight : headerIcons.chevronLeft}
|
||||
</button>
|
||||
<img src={INLINE_LOGO_JPEG} alt="CPAMC logo" className="brand-logo" />
|
||||
<div
|
||||
className={`brand-header ${brandExpanded ? 'expanded' : 'collapsed'}`}
|
||||
onClick={handleBrandClick}
|
||||
title={brandExpanded ? undefined : fullBrandName}
|
||||
>
|
||||
<span className="brand-full">{fullBrandName}</span>
|
||||
<span className="brand-abbr">{abbrBrandName}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="right">
|
||||
<div className="connection">
|
||||
<span className={`status-badge ${statusClass}`}>
|
||||
{t(
|
||||
connectionStatus === 'connected'
|
||||
? 'common.connected_status'
|
||||
: connectionStatus === 'connecting'
|
||||
? 'common.connecting_status'
|
||||
: 'common.disconnected_status'
|
||||
)}
|
||||
</span>
|
||||
<span className="base">{apiBase || '-'}</span>
|
||||
</div>
|
||||
|
||||
<div className="header-actions">
|
||||
<Button
|
||||
className="mobile-menu-btn"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setSidebarOpen((prev) => !prev)}
|
||||
>
|
||||
{headerIcons.menu}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleRefreshAll}
|
||||
title={t('header.refresh_all')}
|
||||
>
|
||||
{headerIcons.refresh}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleVersionCheck}
|
||||
loading={checkingVersion}
|
||||
title={t('system_info.version_check_button')}
|
||||
>
|
||||
{headerIcons.update}
|
||||
</Button>
|
||||
<div className={`language-menu ${languageMenuOpen ? 'open' : ''}`} ref={languageMenuRef}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={toggleLanguageMenu}
|
||||
title={t('language.switch')}
|
||||
aria-label={t('language.switch')}
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={languageMenuOpen}
|
||||
>
|
||||
{headerIcons.language}
|
||||
</Button>
|
||||
{languageMenuOpen && (
|
||||
<div className="notification entering language-menu-popover" role="menu" aria-label={t('language.switch')}>
|
||||
{LANGUAGE_ORDER.map((lang) => (
|
||||
<button
|
||||
key={lang}
|
||||
type="button"
|
||||
className={`language-menu-option ${language === lang ? 'active' : ''}`}
|
||||
onClick={() => handleLanguageSelect(lang)}
|
||||
role="menuitemradio"
|
||||
aria-checked={language === lang}
|
||||
>
|
||||
<span>{t(LANGUAGE_LABEL_KEYS[lang])}</span>
|
||||
{language === lang ? <span className="language-menu-check">✓</span> : null}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Button variant="ghost" size="sm" onClick={cycleTheme} title={t('theme.switch')}>
|
||||
{theme === 'auto'
|
||||
? headerIcons.autoTheme
|
||||
: theme === 'dark'
|
||||
? headerIcons.moon
|
||||
: headerIcons.sun}
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" onClick={logout} title={t('header.logout')}>
|
||||
{headerIcons.logout}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="main-body">
|
||||
<aside
|
||||
className={`sidebar ${sidebarOpen ? 'open' : ''} ${sidebarCollapsed ? 'collapsed' : ''}`}
|
||||
>
|
||||
<div className="nav-section">
|
||||
{navItems.map((item) => (
|
||||
<NavLink
|
||||
key={item.path}
|
||||
to={item.path}
|
||||
className={({ isActive }) => `nav-item ${isActive ? 'active' : ''}`}
|
||||
onClick={() => setSidebarOpen(false)}
|
||||
title={sidebarCollapsed ? item.label : undefined}
|
||||
>
|
||||
<span className="nav-icon">{item.icon}</span>
|
||||
{!sidebarCollapsed && <span className="nav-label">{item.label}</span>}
|
||||
</NavLink>
|
||||
))}
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<div className={`content${isLogsPage ? ' content-logs' : ''}`} ref={contentRef}>
|
||||
<main className={`main-content${isLogsPage ? ' main-content-logs' : ''}`}>
|
||||
<PageTransition
|
||||
render={(location) => <MainRoutes location={location} />}
|
||||
getRouteOrder={getRouteOrder}
|
||||
getTransitionVariant={getTransitionVariant}
|
||||
scrollContainerRef={contentRef}
|
||||
/>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
359
src/components/modelAlias/ModelMappingDiagram.module.scss
Normal file
@@ -0,0 +1,359 @@
|
||||
@use '../../styles/variables' as *;
|
||||
|
||||
.scrollContainer {
|
||||
width: 100%;
|
||||
overflow-x: auto;
|
||||
overscroll-behavior-x: contain;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
.tapHint {
|
||||
position: sticky;
|
||||
left: 0;
|
||||
z-index: 3;
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
padding: 0 4px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.container {
|
||||
display: inline-flex;
|
||||
position: relative;
|
||||
min-width: 100%;
|
||||
min-height: 300px;
|
||||
justify-content: space-between;
|
||||
padding: 20px 0;
|
||||
user-select: none;
|
||||
|
||||
@media (max-width: 768px) {
|
||||
// Give mobile extra horizontal room to reduce line overlap; users can swipe to scroll.
|
||||
min-width: max(100%, 960px);
|
||||
padding: 12px 0;
|
||||
}
|
||||
}
|
||||
|
||||
// SVG layer for connection lines (behind columns so links are visible)
|
||||
.connections {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
pointer-events: none;
|
||||
z-index: 1;
|
||||
overflow: visible;
|
||||
|
||||
path {
|
||||
fill: none;
|
||||
stroke-width: 2;
|
||||
}
|
||||
}
|
||||
|
||||
.column {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
z-index: 2;
|
||||
flex: 0 0 auto;
|
||||
|
||||
&.providers {
|
||||
align-items: flex-end;
|
||||
min-width: 140px;
|
||||
}
|
||||
|
||||
&.sources {
|
||||
align-items: flex-start;
|
||||
min-width: 200px;
|
||||
}
|
||||
|
||||
&.aliases {
|
||||
align-items: flex-start;
|
||||
min-width: 200px;
|
||||
}
|
||||
}
|
||||
|
||||
.columnHeader {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
text-transform: uppercase;
|
||||
margin-bottom: 8px;
|
||||
padding: 0 4px;
|
||||
}
|
||||
|
||||
.item {
|
||||
background: var(--bg-primary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
padding: 10px 14px;
|
||||
font-size: 13px;
|
||||
color: var(--text-primary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
max-width: 280px;
|
||||
position: relative;
|
||||
transition: all 0.2s ease;
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
|
||||
|
||||
&:hover {
|
||||
border-color: var(--primary-color);
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.05);
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
&.dropTarget {
|
||||
background-color: var(--bg-secondary);
|
||||
border-color: var(--primary-color);
|
||||
border-width: 2px;
|
||||
}
|
||||
|
||||
&.selected {
|
||||
border-color: var(--primary-color);
|
||||
background-color: var(--bg-secondary);
|
||||
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.15);
|
||||
}
|
||||
}
|
||||
|
||||
// Mindmap-style provider branch (root node)
|
||||
.providerItem {
|
||||
border-left: 3px solid transparent;
|
||||
padding-left: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
|
||||
.providerLabel {
|
||||
font-weight: 600;
|
||||
font-size: 13px;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.collapseBtn {
|
||||
flex-shrink: 0;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: none;
|
||||
background: var(--bg-secondary);
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
color: var(--text-secondary);
|
||||
transition: background-color 0.15s, color 0.15s;
|
||||
|
||||
&:hover {
|
||||
background: var(--border-color);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.chevronDown,
|
||||
.chevronRight {
|
||||
display: inline-block;
|
||||
width: 0;
|
||||
height: 0;
|
||||
border-style: solid;
|
||||
}
|
||||
|
||||
.chevronDown {
|
||||
border-width: 5px 4px 0 4px;
|
||||
border-color: currentColor transparent transparent transparent;
|
||||
}
|
||||
|
||||
.chevronRight {
|
||||
border-width: 4px 0 4px 5px;
|
||||
border-color: transparent transparent transparent currentColor;
|
||||
}
|
||||
}
|
||||
|
||||
.providerGroup {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.sourceItem,
|
||||
.aliasItem {
|
||||
cursor: grab;
|
||||
|
||||
&:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
&.dragging {
|
||||
opacity: 0.5;
|
||||
border-style: dashed;
|
||||
}
|
||||
}
|
||||
|
||||
.dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
margin-top: -3px;
|
||||
flex-shrink: 0;
|
||||
|
||||
&.dotLeft {
|
||||
left: -3px;
|
||||
background: var(--text-tertiary);
|
||||
}
|
||||
}
|
||||
|
||||
.sourceItem .dot {
|
||||
right: -3px;
|
||||
}
|
||||
|
||||
.providerBadge {
|
||||
font-size: 11px;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text-secondary);
|
||||
margin-right: 8px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.itemName {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.itemCount {
|
||||
font-size: 11px;
|
||||
color: var(--text-tertiary);
|
||||
margin-left: 8px;
|
||||
background: var(--bg-secondary);
|
||||
padding: 1px 6px;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.contextMenu {
|
||||
position: fixed;
|
||||
background: var(--bg-primary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
z-index: 9999;
|
||||
min-width: 120px;
|
||||
overflow: hidden;
|
||||
padding: 4px 0;
|
||||
|
||||
.menuItem {
|
||||
padding: 8px 12px;
|
||||
font-size: 13px;
|
||||
color: var(--text-primary);
|
||||
cursor: pointer;
|
||||
transition: background-color 0.1s;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--bg-secondary);
|
||||
}
|
||||
|
||||
&.danger {
|
||||
color: var(--error-color);
|
||||
|
||||
&:hover {
|
||||
background-color: var(--bg-error-light);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.menuDivider {
|
||||
height: 1px;
|
||||
margin: 4px 0;
|
||||
background: var(--border-color);
|
||||
padding: 0;
|
||||
cursor: default;
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
|
||||
.settingsEmpty {
|
||||
color: var(--text-tertiary);
|
||||
font-size: 13px;
|
||||
text-align: center;
|
||||
padding: $spacing-lg 0;
|
||||
}
|
||||
|
||||
.settingsList {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $spacing-sm;
|
||||
}
|
||||
|
||||
.settingsRow {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(200px, 1fr) auto;
|
||||
gap: $spacing-md;
|
||||
align-items: center;
|
||||
padding: $spacing-sm $spacing-md;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: $radius-md;
|
||||
background: var(--bg-secondary);
|
||||
|
||||
@media (max-width: 768px) {
|
||||
grid-template-columns: 1fr;
|
||||
align-items: flex-start;
|
||||
}
|
||||
}
|
||||
|
||||
.settingsNames {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: $spacing-xs;
|
||||
font-size: 13px;
|
||||
color: var(--text-primary);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.settingsSource,
|
||||
.settingsAlias {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
max-width: 220px;
|
||||
}
|
||||
|
||||
.settingsArrow {
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.settingsActions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: $spacing-sm;
|
||||
}
|
||||
|
||||
.settingsLabel {
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.settingsDelete {
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--error-color);
|
||||
padding: 6px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
background: var(--bg-error-light);
|
||||
}
|
||||
}
|
||||
657
src/components/modelAlias/ModelMappingDiagram.tsx
Normal file
@@ -0,0 +1,657 @@
|
||||
import { forwardRef, useCallback, useEffect, useImperativeHandle, useLayoutEffect, useMemo, useRef, useState, type DragEvent, type MouseEvent as ReactMouseEvent } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { OAuthModelAliasEntry } from '@/types';
|
||||
import { useThemeStore } from '@/stores';
|
||||
import { AliasColumn, ProviderColumn, SourceColumn } from './ModelMappingDiagramColumns';
|
||||
import { DiagramContextMenu } from './ModelMappingDiagramContextMenu';
|
||||
import {
|
||||
AddAliasModal,
|
||||
RenameAliasModal,
|
||||
SettingsAliasModal,
|
||||
SettingsSourceModal
|
||||
} from './ModelMappingDiagramModals';
|
||||
import type {
|
||||
AliasNode,
|
||||
AuthFileModelItem,
|
||||
ContextMenuState,
|
||||
DiagramLine,
|
||||
SourceNode
|
||||
} from './ModelMappingDiagramTypes';
|
||||
import styles from './ModelMappingDiagram.module.scss';
|
||||
|
||||
export interface ModelMappingDiagramProps {
|
||||
modelAlias: Record<string, OAuthModelAliasEntry[]>;
|
||||
allProviderModels?: Record<string, AuthFileModelItem[]>;
|
||||
onUpdate?: (provider: string, sourceModel: string, newAlias: string) => void;
|
||||
onDeleteLink?: (provider: string, sourceModel: string, alias: string) => void;
|
||||
onToggleFork?: (provider: string, sourceModel: string, alias: string, fork: boolean) => void;
|
||||
onRenameAlias?: (oldAlias: string, newAlias: string) => void;
|
||||
onDeleteAlias?: (alias: string) => void;
|
||||
onEditProvider?: (provider: string) => void;
|
||||
onDeleteProvider?: (provider: string) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const PROVIDER_COLORS = [
|
||||
'#3b82f6', '#10b981', '#f59e0b', '#ef4444',
|
||||
'#8b5cf6', '#ec4899', '#06b6d4', '#84cc16'
|
||||
];
|
||||
|
||||
function getProviderColor(provider: string): string {
|
||||
const hash = provider.split('').reduce((acc, c) => acc + c.charCodeAt(0), 0);
|
||||
return PROVIDER_COLORS[hash % PROVIDER_COLORS.length];
|
||||
}
|
||||
|
||||
export interface ModelMappingDiagramRef {
|
||||
collapseAll: () => void;
|
||||
refreshLayout: () => void;
|
||||
}
|
||||
|
||||
export const ModelMappingDiagram = forwardRef<ModelMappingDiagramRef, ModelMappingDiagramProps>(function ModelMappingDiagram({
|
||||
modelAlias,
|
||||
allProviderModels = {},
|
||||
onUpdate,
|
||||
onDeleteLink,
|
||||
onToggleFork,
|
||||
onRenameAlias,
|
||||
onDeleteAlias,
|
||||
onEditProvider,
|
||||
onDeleteProvider,
|
||||
className
|
||||
}, ref) {
|
||||
const { t } = useTranslation();
|
||||
const resolvedTheme = useThemeStore((state) => state.resolvedTheme);
|
||||
const isDark = resolvedTheme === 'dark';
|
||||
const enableTapLinking = useMemo(() => {
|
||||
if (typeof window === 'undefined' || typeof window.matchMedia === 'undefined') return false;
|
||||
return (
|
||||
window.matchMedia('(any-pointer: coarse)').matches &&
|
||||
!window.matchMedia('(any-pointer: fine)').matches
|
||||
);
|
||||
}, []);
|
||||
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [lines, setLines] = useState<DiagramLine[]>([]);
|
||||
const [draggedSource, setDraggedSource] = useState<SourceNode | null>(null);
|
||||
const [draggedAlias, setDraggedAlias] = useState<string | null>(null);
|
||||
const [dropTargetAlias, setDropTargetAlias] = useState<string | null>(null);
|
||||
const [dropTargetSource, setDropTargetSource] = useState<string | null>(null);
|
||||
const [tapSourceId, setTapSourceId] = useState<string | null>(null);
|
||||
const [tapAlias, setTapAlias] = useState<string | null>(null);
|
||||
const [extraAliases, setExtraAliases] = useState<string[]>([]);
|
||||
const [contextMenu, setContextMenu] = useState<ContextMenuState | null>(null);
|
||||
const [collapsedProviders, setCollapsedProviders] = useState<Set<string>>(new Set());
|
||||
const [providerGroupHeights, setProviderGroupHeights] = useState<Record<string, number>>({});
|
||||
const [renameState, setRenameState] = useState<{ oldAlias: string } | null>(null);
|
||||
const [renameValue, setRenameValue] = useState('');
|
||||
const [renameError, setRenameError] = useState('');
|
||||
const [addAliasOpen, setAddAliasOpen] = useState(false);
|
||||
const [addAliasValue, setAddAliasValue] = useState('');
|
||||
const [addAliasError, setAddAliasError] = useState('');
|
||||
const [settingsAlias, setSettingsAlias] = useState<string | null>(null);
|
||||
const [settingsSourceId, setSettingsSourceId] = useState<string | null>(null);
|
||||
|
||||
// Parse data: each source model (provider+name) and each alias is distinct by id; 1 source -> many aliases.
|
||||
const { aliasNodes, providerNodes } = useMemo(() => {
|
||||
const sourceMap = new Map<
|
||||
string,
|
||||
{ provider: string; name: string; aliases: Map<string, boolean> }
|
||||
>();
|
||||
const aliasSet = new Set<string>();
|
||||
|
||||
// 1. Existing mappings: group by (provider, name), each source has a set of aliases
|
||||
Object.entries(modelAlias).forEach(([provider, mappings]) => {
|
||||
(mappings ?? []).forEach((m) => {
|
||||
const name = (m?.name || '').trim();
|
||||
const alias = (m?.alias || '').trim();
|
||||
if (!name || !alias) return;
|
||||
|
||||
const pk = `${provider.toLowerCase()}::${name.toLowerCase()}`;
|
||||
if (!sourceMap.has(pk)) {
|
||||
sourceMap.set(pk, { provider, name, aliases: new Map() });
|
||||
}
|
||||
sourceMap.get(pk)!.aliases.set(alias, m?.fork === true);
|
||||
aliasSet.add(alias);
|
||||
});
|
||||
});
|
||||
|
||||
// 2. Unmapped models from allProviderModels (no mapping yet)
|
||||
Object.entries(allProviderModels).forEach(([provider, models]) => {
|
||||
(models ?? []).forEach((m) => {
|
||||
const name = (m.id || '').trim();
|
||||
if (!name) return;
|
||||
const pk = `${provider.toLowerCase()}::${name.toLowerCase()}`;
|
||||
if (sourceMap.has(pk)) {
|
||||
// Already in sourceMap from mappings; keep provider from mapping for correct grouping.
|
||||
return;
|
||||
}
|
||||
sourceMap.set(pk, { provider, name, aliases: new Map() });
|
||||
});
|
||||
});
|
||||
|
||||
// 3. Source nodes: distinct by id = provider::name
|
||||
const sources: SourceNode[] = Array.from(sourceMap.entries())
|
||||
.map(([id, v]) => ({
|
||||
id,
|
||||
provider: v.provider,
|
||||
name: v.name,
|
||||
aliases: Array.from(v.aliases.entries()).map(([alias, fork]) => ({ alias, fork }))
|
||||
}))
|
||||
.sort((a, b) => {
|
||||
if (a.provider !== b.provider) return a.provider.localeCompare(b.provider);
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
|
||||
// 4. Extra aliases (no mapping yet)
|
||||
extraAliases.forEach((alias) => aliasSet.add(alias));
|
||||
|
||||
// 5. Alias nodes: distinct by id = alias; sources = SourceNodes that have this alias in their aliases
|
||||
const aliasNodesList: AliasNode[] = Array.from(aliasSet)
|
||||
.map((alias) => ({
|
||||
id: alias,
|
||||
alias,
|
||||
sources: sources.filter((s) => s.aliases.some((entry) => entry.alias === alias))
|
||||
}))
|
||||
.sort((a, b) => {
|
||||
if (b.sources.length !== a.sources.length) return b.sources.length - a.sources.length;
|
||||
return a.alias.localeCompare(b.alias);
|
||||
});
|
||||
|
||||
// 6. Group sources by provider
|
||||
const providerMap = new Map<string, SourceNode[]>();
|
||||
sources.forEach((s) => {
|
||||
if (!providerMap.has(s.provider)) providerMap.set(s.provider, []);
|
||||
providerMap.get(s.provider)!.push(s);
|
||||
});
|
||||
const providerNodesList = Array.from(providerMap.entries())
|
||||
.map(([provider, providerSources]) => ({ provider, sources: providerSources }))
|
||||
.sort((a, b) => a.provider.localeCompare(b.provider));
|
||||
|
||||
return { aliasNodes: aliasNodesList, providerNodes: providerNodesList };
|
||||
}, [modelAlias, allProviderModels, extraAliases]);
|
||||
|
||||
// Track element positions
|
||||
const providerRefs = useRef<Map<string, HTMLDivElement>>(new Map());
|
||||
const sourceRefs = useRef<Map<string, HTMLDivElement>>(new Map());
|
||||
const aliasRefs = useRef<Map<string, HTMLDivElement>>(new Map());
|
||||
|
||||
const toggleProviderCollapse = (provider: string) => {
|
||||
setCollapsedProviders((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(provider)) next.delete(provider);
|
||||
else next.add(provider);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
// Calculate lines: provider→source, source→alias (when expanded); midpoint + linkData for source→alias
|
||||
const updateLines = useCallback(() => {
|
||||
if (!containerRef.current) return;
|
||||
const containerRect = containerRef.current.getBoundingClientRect();
|
||||
const newLines: { path: string; color: string; id: string }[] = [];
|
||||
const nextProviderGroupHeights: Record<string, number> = {};
|
||||
|
||||
const bezier = (
|
||||
x1: number, y1: number,
|
||||
x2: number, y2: number
|
||||
) => {
|
||||
const cpx1 = x1 + (x2 - x1) * 0.5;
|
||||
const cpx2 = x2 - (x2 - x1) * 0.5;
|
||||
return `M ${x1} ${y1} C ${cpx1} ${y1}, ${cpx2} ${y2}, ${x2} ${y2}`;
|
||||
};
|
||||
|
||||
providerNodes.forEach(({ provider, sources }) => {
|
||||
const collapsed = collapsedProviders.has(provider);
|
||||
if (collapsed) return;
|
||||
|
||||
if (sources.length > 0) {
|
||||
const firstEl = sourceRefs.current.get(sources[0].id);
|
||||
const lastEl = sourceRefs.current.get(sources[sources.length - 1].id);
|
||||
if (firstEl && lastEl) {
|
||||
const height = Math.max(0, Math.round(lastEl.getBoundingClientRect().bottom - firstEl.getBoundingClientRect().top));
|
||||
if (height > 0) nextProviderGroupHeights[provider] = height;
|
||||
}
|
||||
}
|
||||
|
||||
const providerEl = providerRefs.current.get(provider);
|
||||
if (!providerEl) return;
|
||||
const providerRect = providerEl.getBoundingClientRect();
|
||||
const px = providerRect.right - containerRect.left;
|
||||
const py = providerRect.top + providerRect.height / 2 - containerRect.top;
|
||||
const color = getProviderColor(provider);
|
||||
|
||||
// Provider → Source (branch link, no dot)
|
||||
sources.forEach((source) => {
|
||||
const sourceEl = sourceRefs.current.get(source.id);
|
||||
if (!sourceEl) return;
|
||||
const sourceRect = sourceEl.getBoundingClientRect();
|
||||
const sx = sourceRect.left - containerRect.left;
|
||||
const sy = sourceRect.top + sourceRect.height / 2 - containerRect.top;
|
||||
newLines.push({
|
||||
id: `provider-${provider}-source-${source.id}`,
|
||||
path: bezier(px, py, sx, sy),
|
||||
color
|
||||
});
|
||||
});
|
||||
// Source → Alias: one line per alias
|
||||
sources.forEach((source) => {
|
||||
if (!source.aliases || source.aliases.length === 0) return;
|
||||
|
||||
source.aliases.forEach((aliasEntry) => {
|
||||
const sourceEl = sourceRefs.current.get(source.id);
|
||||
const aliasEl = aliasRefs.current.get(aliasEntry.alias);
|
||||
if (!sourceEl || !aliasEl) return;
|
||||
|
||||
const sourceRect = sourceEl.getBoundingClientRect();
|
||||
const aliasRect = aliasEl.getBoundingClientRect();
|
||||
|
||||
// Calculate coordinates relative to the container
|
||||
const x1 = sourceRect.right - containerRect.left;
|
||||
const y1 = sourceRect.top + sourceRect.height / 2 - containerRect.top;
|
||||
const x2 = aliasRect.left - containerRect.left;
|
||||
const y2 = aliasRect.top + aliasRect.height / 2 - containerRect.top;
|
||||
|
||||
newLines.push({
|
||||
id: `${source.id}-${aliasEntry.alias}`,
|
||||
path: bezier(x1, y1, x2, y2),
|
||||
color
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
setLines(newLines);
|
||||
setProviderGroupHeights((prev) => {
|
||||
const prevKeys = Object.keys(prev);
|
||||
const nextKeys = Object.keys(nextProviderGroupHeights);
|
||||
if (prevKeys.length !== nextKeys.length) return nextProviderGroupHeights;
|
||||
for (const key of nextKeys) {
|
||||
if (!(key in prev) || prev[key] !== nextProviderGroupHeights[key]) {
|
||||
return nextProviderGroupHeights;
|
||||
}
|
||||
}
|
||||
return prev;
|
||||
});
|
||||
}, [providerNodes, collapsedProviders]);
|
||||
|
||||
useImperativeHandle(
|
||||
ref,
|
||||
() => ({
|
||||
collapseAll: () => setCollapsedProviders(new Set(providerNodes.map((p) => p.provider))),
|
||||
refreshLayout: () => updateLines()
|
||||
}),
|
||||
[providerNodes, updateLines]
|
||||
);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
// updateLines is called after layout is calculated, ensuring elements are in place.
|
||||
const raf = requestAnimationFrame(updateLines);
|
||||
window.addEventListener('resize', updateLines);
|
||||
return () => {
|
||||
cancelAnimationFrame(raf);
|
||||
window.removeEventListener('resize', updateLines);
|
||||
};
|
||||
}, [updateLines, aliasNodes]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const raf = requestAnimationFrame(updateLines);
|
||||
return () => cancelAnimationFrame(raf);
|
||||
}, [providerGroupHeights, updateLines]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!containerRef.current || typeof ResizeObserver === 'undefined') return;
|
||||
const observer = new ResizeObserver(() => updateLines());
|
||||
observer.observe(containerRef.current);
|
||||
return () => observer.disconnect();
|
||||
}, [updateLines]);
|
||||
|
||||
// Drag and Drop handlers
|
||||
// 1. Source -> Alias
|
||||
const handleDragStart = (e: DragEvent, source: SourceNode) => {
|
||||
setTapSourceId(null);
|
||||
setTapAlias(null);
|
||||
setDraggedSource(source);
|
||||
e.dataTransfer.setData('text/plain', source.id);
|
||||
e.dataTransfer.effectAllowed = 'link';
|
||||
};
|
||||
|
||||
const handleDragOver = (e: DragEvent, alias: string) => {
|
||||
if (!draggedSource || draggedSource.aliases.some((entry) => entry.alias === alias)) return;
|
||||
e.preventDefault(); // Allow drop
|
||||
e.dataTransfer.dropEffect = 'link';
|
||||
setDropTargetAlias(alias);
|
||||
};
|
||||
|
||||
const handleDragLeave = () => {
|
||||
setDropTargetAlias(null);
|
||||
};
|
||||
|
||||
const handleDrop = (e: DragEvent, alias: string) => {
|
||||
e.preventDefault();
|
||||
if (draggedSource && !draggedSource.aliases.some((entry) => entry.alias === alias) && onUpdate) {
|
||||
onUpdate(draggedSource.provider, draggedSource.name, alias);
|
||||
}
|
||||
setDraggedSource(null);
|
||||
setDropTargetAlias(null);
|
||||
};
|
||||
|
||||
// 2. Alias -> Source
|
||||
const handleDragStartAlias = (e: DragEvent, alias: string) => {
|
||||
setTapSourceId(null);
|
||||
setTapAlias(null);
|
||||
setDraggedAlias(alias);
|
||||
e.dataTransfer.setData('text/plain', alias);
|
||||
e.dataTransfer.effectAllowed = 'link';
|
||||
};
|
||||
|
||||
const handleDragOverSource = (e: DragEvent, source: SourceNode) => {
|
||||
if (!draggedAlias || source.aliases.some((entry) => entry.alias === draggedAlias)) return;
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = 'link';
|
||||
setDropTargetSource(source.id);
|
||||
};
|
||||
|
||||
const handleDragLeaveSource = () => {
|
||||
setDropTargetSource(null);
|
||||
};
|
||||
|
||||
const handleDropOnSource = (e: DragEvent, source: SourceNode) => {
|
||||
e.preventDefault();
|
||||
if (draggedAlias && !source.aliases.some((entry) => entry.alias === draggedAlias) && onUpdate) {
|
||||
onUpdate(source.provider, source.name, draggedAlias);
|
||||
}
|
||||
setDraggedAlias(null);
|
||||
setDropTargetSource(null);
|
||||
};
|
||||
|
||||
const handleContextMenu = (
|
||||
e: ReactMouseEvent,
|
||||
type: 'alias' | 'background' | 'provider' | 'source',
|
||||
data?: string
|
||||
) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setContextMenu({
|
||||
x: e.clientX,
|
||||
y: e.clientY,
|
||||
type,
|
||||
data
|
||||
});
|
||||
};
|
||||
|
||||
const closeContextMenu = () => setContextMenu(null);
|
||||
|
||||
const resolveSourceById = useCallback(
|
||||
(id: string | null) => {
|
||||
if (!id) return null;
|
||||
for (const { sources } of providerNodes) {
|
||||
const found = sources.find((source) => source.id === id);
|
||||
if (found) return found;
|
||||
}
|
||||
return null;
|
||||
},
|
||||
[providerNodes]
|
||||
);
|
||||
|
||||
const handleTapSelectSource = (source: SourceNode) => {
|
||||
if (!onUpdate) return;
|
||||
if (tapSourceId === source.id) {
|
||||
setTapSourceId(null);
|
||||
return;
|
||||
}
|
||||
|
||||
if (tapAlias) {
|
||||
onUpdate(source.provider, source.name, tapAlias);
|
||||
setTapSourceId(null);
|
||||
setTapAlias(null);
|
||||
return;
|
||||
}
|
||||
|
||||
setTapSourceId(source.id);
|
||||
setTapAlias(null);
|
||||
};
|
||||
|
||||
const handleTapSelectAlias = (alias: string) => {
|
||||
if (!onUpdate) return;
|
||||
if (tapAlias === alias) {
|
||||
setTapAlias(null);
|
||||
return;
|
||||
}
|
||||
|
||||
if (tapSourceId) {
|
||||
const source = resolveSourceById(tapSourceId);
|
||||
if (source) {
|
||||
onUpdate(source.provider, source.name, alias);
|
||||
}
|
||||
setTapSourceId(null);
|
||||
setTapAlias(null);
|
||||
return;
|
||||
}
|
||||
|
||||
setTapAlias(alias);
|
||||
setTapSourceId(null);
|
||||
};
|
||||
|
||||
const handleUnlinkSource = (provider: string, sourceModel: string, alias: string) => {
|
||||
if (onDeleteLink) onDeleteLink(provider, sourceModel, alias);
|
||||
};
|
||||
|
||||
const handleToggleFork = (
|
||||
provider: string,
|
||||
sourceModel: string,
|
||||
alias: string,
|
||||
value: boolean
|
||||
) => {
|
||||
if (onToggleFork) onToggleFork(provider, sourceModel, alias, value);
|
||||
};
|
||||
|
||||
const handleAddAlias = () => {
|
||||
closeContextMenu();
|
||||
setAddAliasOpen(true);
|
||||
setAddAliasValue('');
|
||||
setAddAliasError('');
|
||||
};
|
||||
|
||||
const handleAddAliasSubmit = () => {
|
||||
const trimmed = addAliasValue.trim();
|
||||
if (!trimmed) {
|
||||
setAddAliasError(t('oauth_model_alias.diagram_please_enter_alias'));
|
||||
return;
|
||||
}
|
||||
if (aliasNodes.some(a => a.alias === trimmed)) {
|
||||
setAddAliasError(t('oauth_model_alias.diagram_alias_exists'));
|
||||
return;
|
||||
}
|
||||
setExtraAliases(prev => [...prev, trimmed]);
|
||||
setAddAliasOpen(false);
|
||||
};
|
||||
|
||||
const handleRenameClick = (oldAlias: string) => {
|
||||
closeContextMenu();
|
||||
setRenameState({ oldAlias });
|
||||
setRenameValue(oldAlias);
|
||||
setRenameError('');
|
||||
};
|
||||
|
||||
const handleRenameSubmit = () => {
|
||||
const trimmed = renameValue.trim();
|
||||
if (!trimmed) {
|
||||
setRenameError(t('oauth_model_alias.diagram_please_enter_alias'));
|
||||
return;
|
||||
}
|
||||
if (trimmed === renameState?.oldAlias) {
|
||||
setRenameState(null);
|
||||
return;
|
||||
}
|
||||
if (aliasNodes.some(a => a.alias === trimmed)) {
|
||||
setRenameError(t('oauth_model_alias.diagram_alias_exists'));
|
||||
return;
|
||||
}
|
||||
if (onRenameAlias && renameState) onRenameAlias(renameState.oldAlias, trimmed);
|
||||
if (extraAliases.includes(renameState?.oldAlias ?? '')) {
|
||||
setExtraAliases(prev => prev.map(a => a === renameState?.oldAlias ? trimmed : a));
|
||||
}
|
||||
setRenameState(null);
|
||||
};
|
||||
|
||||
const handleDeleteClick = (alias: string) => {
|
||||
closeContextMenu();
|
||||
const node = aliasNodes.find(n => n.alias === alias);
|
||||
if (!node) return;
|
||||
|
||||
if (node.sources.length === 0) {
|
||||
setExtraAliases(prev => prev.filter(a => a !== alias));
|
||||
} else {
|
||||
if (onDeleteAlias) onDeleteAlias(alias);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
return (
|
||||
<div className={[styles.scrollContainer, className].filter(Boolean).join(' ')}>
|
||||
{enableTapLinking && onUpdate && (
|
||||
<div className={styles.tapHint}>{t('oauth_model_alias.diagram_tap_hint')}</div>
|
||||
)}
|
||||
<div
|
||||
className={styles.container}
|
||||
ref={containerRef}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
handleContextMenu(e, 'background');
|
||||
}}
|
||||
>
|
||||
<svg className={styles.connections}>
|
||||
{lines.map((line) => (
|
||||
<path
|
||||
key={line.id}
|
||||
d={line.path}
|
||||
stroke={line.color}
|
||||
strokeOpacity={isDark ? 0.4 : 0.3}
|
||||
/>
|
||||
))}
|
||||
</svg>
|
||||
|
||||
<ProviderColumn
|
||||
providerNodes={providerNodes}
|
||||
collapsedProviders={collapsedProviders}
|
||||
getProviderColor={getProviderColor}
|
||||
providerGroupHeights={providerGroupHeights}
|
||||
providerRefs={providerRefs}
|
||||
onToggleCollapse={toggleProviderCollapse}
|
||||
onContextMenu={(e, type, data) => handleContextMenu(e, type, data)}
|
||||
label={t('oauth_model_alias.diagram_providers')}
|
||||
expandLabel={t('oauth_model_alias.diagram_expand')}
|
||||
collapseLabel={t('oauth_model_alias.diagram_collapse')}
|
||||
/>
|
||||
<SourceColumn
|
||||
providerNodes={providerNodes}
|
||||
collapsedProviders={collapsedProviders}
|
||||
sourceRefs={sourceRefs}
|
||||
getProviderColor={getProviderColor}
|
||||
selectedSourceId={enableTapLinking ? tapSourceId : null}
|
||||
onSelectSource={enableTapLinking ? handleTapSelectSource : undefined}
|
||||
draggedSource={draggedSource}
|
||||
dropTargetSource={dropTargetSource}
|
||||
draggable={!!onUpdate}
|
||||
onDragStart={handleDragStart}
|
||||
onDragEnd={() => {
|
||||
setDraggedSource(null);
|
||||
setDropTargetAlias(null);
|
||||
}}
|
||||
onDragOver={handleDragOverSource}
|
||||
onDragLeave={handleDragLeaveSource}
|
||||
onDrop={handleDropOnSource}
|
||||
onContextMenu={(e, type, data) => handleContextMenu(e, type, data)}
|
||||
label={t('oauth_model_alias.diagram_source_models')}
|
||||
/>
|
||||
<AliasColumn
|
||||
aliasNodes={aliasNodes}
|
||||
aliasRefs={aliasRefs}
|
||||
dropTargetAlias={dropTargetAlias}
|
||||
draggedAlias={draggedAlias}
|
||||
selectedAlias={enableTapLinking ? tapAlias : null}
|
||||
onSelectAlias={enableTapLinking ? handleTapSelectAlias : undefined}
|
||||
draggable={!!onUpdate}
|
||||
onDragStart={handleDragStartAlias}
|
||||
onDragEnd={() => {
|
||||
setDraggedAlias(null);
|
||||
setDropTargetSource(null);
|
||||
}}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
onContextMenu={(e, type, data) => handleContextMenu(e, type, data)}
|
||||
label={t('oauth_model_alias.diagram_aliases')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<DiagramContextMenu
|
||||
contextMenu={contextMenu}
|
||||
t={t}
|
||||
onRequestClose={() => setContextMenu(null)}
|
||||
onAddAlias={handleAddAlias}
|
||||
onRenameAlias={handleRenameClick}
|
||||
onOpenAliasSettings={(alias) => {
|
||||
setContextMenu(null);
|
||||
setSettingsAlias(alias);
|
||||
}}
|
||||
onDeleteAlias={handleDeleteClick}
|
||||
onEditProvider={(provider) => {
|
||||
setContextMenu(null);
|
||||
onEditProvider?.(provider);
|
||||
}}
|
||||
onDeleteProvider={(provider) => {
|
||||
setContextMenu(null);
|
||||
onDeleteProvider?.(provider);
|
||||
}}
|
||||
onOpenSourceSettings={(sourceId) => {
|
||||
setContextMenu(null);
|
||||
setSettingsSourceId(sourceId);
|
||||
}}
|
||||
/>
|
||||
|
||||
<RenameAliasModal
|
||||
open={!!renameState}
|
||||
t={t}
|
||||
value={renameValue}
|
||||
error={renameError}
|
||||
onChange={(value) => {
|
||||
setRenameValue(value);
|
||||
setRenameError('');
|
||||
}}
|
||||
onClose={() => setRenameState(null)}
|
||||
onSubmit={handleRenameSubmit}
|
||||
/>
|
||||
<AddAliasModal
|
||||
open={addAliasOpen}
|
||||
t={t}
|
||||
value={addAliasValue}
|
||||
error={addAliasError}
|
||||
onChange={(value) => {
|
||||
setAddAliasValue(value);
|
||||
setAddAliasError('');
|
||||
}}
|
||||
onClose={() => setAddAliasOpen(false)}
|
||||
onSubmit={handleAddAliasSubmit}
|
||||
/>
|
||||
<SettingsAliasModal
|
||||
open={Boolean(settingsAlias)}
|
||||
t={t}
|
||||
alias={settingsAlias}
|
||||
aliasNodes={aliasNodes}
|
||||
onClose={() => setSettingsAlias(null)}
|
||||
onToggleFork={handleToggleFork}
|
||||
onUnlink={handleUnlinkSource}
|
||||
/>
|
||||
<SettingsSourceModal
|
||||
open={Boolean(settingsSourceId)}
|
||||
t={t}
|
||||
source={resolveSourceById(settingsSourceId)}
|
||||
onClose={() => setSettingsSourceId(null)}
|
||||
onToggleFork={handleToggleFork}
|
||||
onUnlink={handleUnlinkSource}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
251
src/components/modelAlias/ModelMappingDiagramColumns.tsx
Normal file
@@ -0,0 +1,251 @@
|
||||
import type { DragEvent, MouseEvent as ReactMouseEvent, RefObject } from 'react';
|
||||
import type { AliasNode, ProviderNode, SourceNode } from './ModelMappingDiagramTypes';
|
||||
import styles from './ModelMappingDiagram.module.scss';
|
||||
|
||||
interface ProviderColumnProps {
|
||||
providerNodes: ProviderNode[];
|
||||
collapsedProviders: Set<string>;
|
||||
getProviderColor: (provider: string) => string;
|
||||
providerGroupHeights?: Record<string, number>;
|
||||
providerRefs: RefObject<Map<string, HTMLDivElement>>;
|
||||
onToggleCollapse: (provider: string) => void;
|
||||
onContextMenu: (e: ReactMouseEvent, type: 'provider' | 'background', data?: string) => void;
|
||||
label: string;
|
||||
expandLabel: string;
|
||||
collapseLabel: string;
|
||||
}
|
||||
|
||||
export function ProviderColumn({
|
||||
providerNodes,
|
||||
collapsedProviders,
|
||||
getProviderColor,
|
||||
providerGroupHeights = {},
|
||||
providerRefs,
|
||||
onToggleCollapse,
|
||||
onContextMenu,
|
||||
label,
|
||||
expandLabel,
|
||||
collapseLabel
|
||||
}: ProviderColumnProps) {
|
||||
return (
|
||||
<div
|
||||
className={`${styles.column} ${styles.providers}`}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onContextMenu(e, 'background');
|
||||
}}
|
||||
>
|
||||
<div className={styles.columnHeader}>{label}</div>
|
||||
{providerNodes.map(({ provider, sources }) => {
|
||||
const collapsed = collapsedProviders.has(provider);
|
||||
const groupHeight = collapsed ? undefined : providerGroupHeights[provider];
|
||||
return (
|
||||
<div
|
||||
key={provider}
|
||||
className={styles.providerGroup}
|
||||
style={groupHeight ? { height: groupHeight } : undefined}
|
||||
>
|
||||
<div
|
||||
ref={(el) => {
|
||||
if (el) providerRefs.current?.set(provider, el);
|
||||
else providerRefs.current?.delete(provider);
|
||||
}}
|
||||
className={`${styles.item} ${styles.providerItem}`}
|
||||
style={{ borderLeftColor: getProviderColor(provider) }}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onContextMenu(e, 'provider', provider);
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.collapseBtn}
|
||||
onClick={() => onToggleCollapse(provider)}
|
||||
aria-label={collapsed ? expandLabel : collapseLabel}
|
||||
title={collapsed ? expandLabel : collapseLabel}
|
||||
>
|
||||
<span className={collapsed ? styles.chevronRight : styles.chevronDown} />
|
||||
</button>
|
||||
<span className={styles.providerLabel} style={{ color: getProviderColor(provider) }}>
|
||||
{provider}
|
||||
</span>
|
||||
<span className={styles.itemCount}>{sources.length}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface SourceColumnProps {
|
||||
providerNodes: ProviderNode[];
|
||||
collapsedProviders: Set<string>;
|
||||
sourceRefs: RefObject<Map<string, HTMLDivElement>>;
|
||||
getProviderColor: (provider: string) => string;
|
||||
selectedSourceId?: string | null;
|
||||
onSelectSource?: (source: SourceNode) => void;
|
||||
draggedSource: SourceNode | null;
|
||||
dropTargetSource: string | null;
|
||||
draggable: boolean;
|
||||
onDragStart: (e: DragEvent, source: SourceNode) => void;
|
||||
onDragEnd: () => void;
|
||||
onDragOver: (e: DragEvent, source: SourceNode) => void;
|
||||
onDragLeave: () => void;
|
||||
onDrop: (e: DragEvent, source: SourceNode) => void;
|
||||
onContextMenu: (e: ReactMouseEvent, type: 'source' | 'background', data?: string) => void;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export function SourceColumn({
|
||||
providerNodes,
|
||||
collapsedProviders,
|
||||
sourceRefs,
|
||||
getProviderColor,
|
||||
selectedSourceId,
|
||||
onSelectSource,
|
||||
draggedSource,
|
||||
dropTargetSource,
|
||||
draggable,
|
||||
onDragStart,
|
||||
onDragEnd,
|
||||
onDragOver,
|
||||
onDragLeave,
|
||||
onDrop,
|
||||
onContextMenu,
|
||||
label
|
||||
}: SourceColumnProps) {
|
||||
return (
|
||||
<div
|
||||
className={`${styles.column} ${styles.sources}`}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onContextMenu(e, 'background');
|
||||
}}
|
||||
>
|
||||
<div className={styles.columnHeader}>{label}</div>
|
||||
{providerNodes.flatMap(({ provider, sources }) => {
|
||||
if (collapsedProviders.has(provider)) return [];
|
||||
return sources.map((source) => (
|
||||
<div
|
||||
key={source.id}
|
||||
ref={(el) => {
|
||||
if (el) sourceRefs.current?.set(source.id, el);
|
||||
else sourceRefs.current?.delete(source.id);
|
||||
}}
|
||||
className={`${styles.item} ${styles.sourceItem} ${
|
||||
draggedSource?.id === source.id ? styles.dragging : ''
|
||||
} ${dropTargetSource === source.id ? styles.dropTarget : ''} ${
|
||||
selectedSourceId === source.id ? styles.selected : ''
|
||||
}`}
|
||||
onClick={() => onSelectSource?.(source)}
|
||||
draggable={draggable}
|
||||
onDragStart={(e) => onDragStart(e, source)}
|
||||
onDragEnd={onDragEnd}
|
||||
onDragOver={(e) => onDragOver(e, source)}
|
||||
onDragLeave={onDragLeave}
|
||||
onDrop={(e) => onDrop(e, source)}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onContextMenu(e, 'source', source.id);
|
||||
}}
|
||||
>
|
||||
<span className={styles.itemName} title={source.name}>
|
||||
{source.name}
|
||||
</span>
|
||||
<div
|
||||
className={styles.dot}
|
||||
style={{
|
||||
background: getProviderColor(source.provider),
|
||||
opacity: source.aliases.length > 0 ? 1 : 0.3
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
));
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface AliasColumnProps {
|
||||
aliasNodes: AliasNode[];
|
||||
aliasRefs: RefObject<Map<string, HTMLDivElement>>;
|
||||
dropTargetAlias: string | null;
|
||||
draggedAlias: string | null;
|
||||
selectedAlias?: string | null;
|
||||
onSelectAlias?: (alias: string) => void;
|
||||
draggable: boolean;
|
||||
onDragStart: (e: DragEvent, alias: string) => void;
|
||||
onDragEnd: () => void;
|
||||
onDragOver: (e: DragEvent, alias: string) => void;
|
||||
onDragLeave: () => void;
|
||||
onDrop: (e: DragEvent, alias: string) => void;
|
||||
onContextMenu: (e: ReactMouseEvent, type: 'alias' | 'background', data?: string) => void;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export function AliasColumn({
|
||||
aliasNodes,
|
||||
aliasRefs,
|
||||
dropTargetAlias,
|
||||
draggedAlias,
|
||||
selectedAlias,
|
||||
onSelectAlias,
|
||||
draggable,
|
||||
onDragStart,
|
||||
onDragEnd,
|
||||
onDragOver,
|
||||
onDragLeave,
|
||||
onDrop,
|
||||
onContextMenu,
|
||||
label
|
||||
}: AliasColumnProps) {
|
||||
return (
|
||||
<div
|
||||
className={`${styles.column} ${styles.aliases}`}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onContextMenu(e, 'background');
|
||||
}}
|
||||
>
|
||||
<div className={styles.columnHeader}>{label}</div>
|
||||
{aliasNodes.map((node) => (
|
||||
<div
|
||||
key={node.id}
|
||||
ref={(el) => {
|
||||
if (el) aliasRefs.current?.set(node.id, el);
|
||||
else aliasRefs.current?.delete(node.id);
|
||||
}}
|
||||
className={`${styles.item} ${styles.aliasItem} ${
|
||||
dropTargetAlias === node.alias ? styles.dropTarget : ''
|
||||
} ${draggedAlias === node.alias ? styles.dragging : ''} ${
|
||||
selectedAlias === node.alias ? styles.selected : ''
|
||||
}`}
|
||||
onClick={() => onSelectAlias?.(node.alias)}
|
||||
draggable={draggable}
|
||||
onDragStart={(e) => onDragStart(e, node.alias)}
|
||||
onDragEnd={onDragEnd}
|
||||
onDragOver={(e) => onDragOver(e, node.alias)}
|
||||
onDragLeave={onDragLeave}
|
||||
onDrop={(e) => onDrop(e, node.alias)}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onContextMenu(e, 'alias', node.alias);
|
||||
}}
|
||||
>
|
||||
<div className={`${styles.dot} ${styles.dotLeft}`} />
|
||||
<span className={styles.itemName} title={node.alias}>
|
||||
{node.alias}
|
||||
</span>
|
||||
<span className={styles.itemCount}>{node.sources.length}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
111
src/components/modelAlias/ModelMappingDiagramContextMenu.tsx
Normal file
@@ -0,0 +1,111 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import type { TFunction } from 'i18next';
|
||||
import type { ContextMenuState } from './ModelMappingDiagramTypes';
|
||||
import styles from './ModelMappingDiagram.module.scss';
|
||||
|
||||
interface DiagramContextMenuProps {
|
||||
contextMenu: ContextMenuState | null;
|
||||
t: TFunction;
|
||||
onRequestClose: () => void;
|
||||
onAddAlias: () => void;
|
||||
onRenameAlias: (alias: string) => void;
|
||||
onOpenAliasSettings: (alias: string) => void;
|
||||
onDeleteAlias: (alias: string) => void;
|
||||
onEditProvider: (provider: string) => void;
|
||||
onDeleteProvider: (provider: string) => void;
|
||||
onOpenSourceSettings: (sourceId: string) => void;
|
||||
}
|
||||
|
||||
export function DiagramContextMenu({
|
||||
contextMenu,
|
||||
t,
|
||||
onRequestClose,
|
||||
onAddAlias,
|
||||
onRenameAlias,
|
||||
onOpenAliasSettings,
|
||||
onDeleteAlias,
|
||||
onEditProvider,
|
||||
onDeleteProvider,
|
||||
onOpenSourceSettings
|
||||
}: DiagramContextMenuProps) {
|
||||
const menuRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!contextMenu) return;
|
||||
const handleClick = (event: globalThis.MouseEvent) => {
|
||||
if (!menuRef.current?.contains(event.target as Node)) {
|
||||
onRequestClose();
|
||||
}
|
||||
};
|
||||
document.addEventListener('mousedown', handleClick);
|
||||
return () => document.removeEventListener('mousedown', handleClick);
|
||||
}, [contextMenu, onRequestClose]);
|
||||
|
||||
if (!contextMenu) return null;
|
||||
|
||||
const { type, data } = contextMenu;
|
||||
|
||||
const renderBackground = () => (
|
||||
<div className={styles.menuItem} onClick={onAddAlias}>
|
||||
<span>{t('oauth_model_alias.diagram_add_alias')}</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
const renderAlias = () => {
|
||||
if (!data) return null;
|
||||
return (
|
||||
<>
|
||||
<div className={styles.menuItem} onClick={() => onRenameAlias(data)}>
|
||||
<span>{t('oauth_model_alias.diagram_rename')}</span>
|
||||
</div>
|
||||
<div className={styles.menuItem} onClick={() => onOpenAliasSettings(data)}>
|
||||
<span>{t('oauth_model_alias.diagram_settings')}</span>
|
||||
</div>
|
||||
<div className={styles.menuDivider} />
|
||||
<div className={`${styles.menuItem} ${styles.danger}`} onClick={() => onDeleteAlias(data)}>
|
||||
<span>{t('oauth_model_alias.diagram_delete_alias')}</span>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const renderProvider = () => {
|
||||
if (!data) return null;
|
||||
return (
|
||||
<>
|
||||
<div className={styles.menuItem} onClick={() => onEditProvider(data)}>
|
||||
<span>{t('common.edit')}</span>
|
||||
</div>
|
||||
<div className={styles.menuDivider} />
|
||||
<div className={`${styles.menuItem} ${styles.danger}`} onClick={() => onDeleteProvider(data)}>
|
||||
<span>{t('oauth_model_alias.delete')}</span>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const renderSource = () => {
|
||||
if (!data) return null;
|
||||
return (
|
||||
<div className={styles.menuItem} onClick={() => onOpenSourceSettings(data)}>
|
||||
<span>{t('oauth_model_alias.diagram_settings')}</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
ref={menuRef}
|
||||
className={styles.contextMenu}
|
||||
style={{ top: contextMenu.y, left: contextMenu.x }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{type === 'background' && renderBackground()}
|
||||
{type === 'alias' && renderAlias()}
|
||||
{type === 'provider' && renderProvider()}
|
||||
{type === 'source' && renderSource()}
|
||||
</div>,
|
||||
document.body
|
||||
);
|
||||
}
|
||||
267
src/components/modelAlias/ModelMappingDiagramModals.tsx
Normal file
@@ -0,0 +1,267 @@
|
||||
import type { KeyboardEvent } from 'react';
|
||||
import type { TFunction } from 'i18next';
|
||||
import { Modal } from '@/components/ui/Modal';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { ToggleSwitch } from '@/components/ui/ToggleSwitch';
|
||||
import { IconTrash2 } from '@/components/ui/icons';
|
||||
import type { AliasNode, SourceNode } from './ModelMappingDiagramTypes';
|
||||
import styles from './ModelMappingDiagram.module.scss';
|
||||
|
||||
interface RenameAliasModalProps {
|
||||
open: boolean;
|
||||
t: TFunction;
|
||||
value: string;
|
||||
error: string;
|
||||
onChange: (value: string) => void;
|
||||
onClose: () => void;
|
||||
onSubmit: () => void;
|
||||
}
|
||||
|
||||
export function RenameAliasModal({
|
||||
open,
|
||||
t,
|
||||
value,
|
||||
error,
|
||||
onChange,
|
||||
onClose,
|
||||
onSubmit
|
||||
}: RenameAliasModalProps) {
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
title={t('oauth_model_alias.diagram_rename_alias_title')}
|
||||
width={400}
|
||||
footer={
|
||||
<>
|
||||
<Button variant="secondary" onClick={onClose}>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button onClick={onSubmit}>{t('oauth_model_alias.diagram_rename_btn')}</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<Input
|
||||
label={t('oauth_model_alias.diagram_rename_alias_label')}
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
onKeyDown={(e: KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === 'Enter') onSubmit();
|
||||
}}
|
||||
error={error}
|
||||
placeholder={t('oauth_model_alias.diagram_rename_placeholder')}
|
||||
autoFocus
|
||||
/>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
interface AddAliasModalProps {
|
||||
open: boolean;
|
||||
t: TFunction;
|
||||
value: string;
|
||||
error: string;
|
||||
onChange: (value: string) => void;
|
||||
onClose: () => void;
|
||||
onSubmit: () => void;
|
||||
}
|
||||
|
||||
export function AddAliasModal({
|
||||
open,
|
||||
t,
|
||||
value,
|
||||
error,
|
||||
onChange,
|
||||
onClose,
|
||||
onSubmit
|
||||
}: AddAliasModalProps) {
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
title={t('oauth_model_alias.diagram_add_alias_title')}
|
||||
width={400}
|
||||
footer={
|
||||
<>
|
||||
<Button variant="secondary" onClick={onClose}>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button onClick={onSubmit}>{t('oauth_model_alias.diagram_add_btn')}</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<Input
|
||||
label={t('oauth_model_alias.diagram_add_alias_label')}
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
onKeyDown={(e: KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === 'Enter') onSubmit();
|
||||
}}
|
||||
error={error}
|
||||
placeholder={t('oauth_model_alias.diagram_add_placeholder')}
|
||||
autoFocus
|
||||
/>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
interface SettingsAliasModalProps {
|
||||
open: boolean;
|
||||
t: TFunction;
|
||||
alias: string | null;
|
||||
aliasNodes: AliasNode[];
|
||||
onClose: () => void;
|
||||
onToggleFork: (provider: string, sourceModel: string, alias: string, fork: boolean) => void;
|
||||
onUnlink: (provider: string, sourceModel: string, alias: string) => void;
|
||||
}
|
||||
|
||||
export function SettingsAliasModal({
|
||||
open,
|
||||
t,
|
||||
alias,
|
||||
aliasNodes,
|
||||
onClose,
|
||||
onToggleFork,
|
||||
onUnlink
|
||||
}: SettingsAliasModalProps) {
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
title={t('oauth_model_alias.diagram_settings_title', { alias: alias ?? '' })}
|
||||
width={720}
|
||||
footer={
|
||||
<Button variant="secondary" onClick={onClose}>
|
||||
{t('common.close')}
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
{alias ? (
|
||||
(() => {
|
||||
const node = aliasNodes.find((n) => n.alias === alias);
|
||||
if (!node || node.sources.length === 0) {
|
||||
return <div className={styles.settingsEmpty}>{t('oauth_model_alias.diagram_settings_empty')}</div>;
|
||||
}
|
||||
return (
|
||||
<div className={styles.settingsList}>
|
||||
{node.sources.map((source) => {
|
||||
const entry = source.aliases.find((item) => item.alias === alias);
|
||||
const forkEnabled = entry?.fork === true;
|
||||
return (
|
||||
<div key={source.id} className={styles.settingsRow}>
|
||||
<div className={styles.settingsNames}>
|
||||
<span className={styles.settingsSource}>{source.name}</span>
|
||||
<span className={styles.settingsArrow}>→</span>
|
||||
<span className={styles.settingsAlias}>{alias}</span>
|
||||
</div>
|
||||
<div className={styles.settingsActions}>
|
||||
<span className={styles.settingsLabel}>
|
||||
{t('oauth_model_alias.alias_fork_label')}
|
||||
</span>
|
||||
<ToggleSwitch
|
||||
checked={forkEnabled}
|
||||
onChange={(value) => onToggleFork(source.provider, source.name, alias, value)}
|
||||
ariaLabel={t('oauth_model_alias.alias_fork_label')}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.settingsDelete}
|
||||
onClick={() => onUnlink(source.provider, source.name, alias)}
|
||||
aria-label={t('oauth_model_alias.diagram_delete_link', {
|
||||
provider: source.provider,
|
||||
name: source.name
|
||||
})}
|
||||
title={t('oauth_model_alias.diagram_delete_link', {
|
||||
provider: source.provider,
|
||||
name: source.name
|
||||
})}
|
||||
>
|
||||
<IconTrash2 size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
})()
|
||||
) : null}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
interface SettingsSourceModalProps {
|
||||
open: boolean;
|
||||
t: TFunction;
|
||||
source: SourceNode | null;
|
||||
onClose: () => void;
|
||||
onToggleFork: (provider: string, sourceModel: string, alias: string, fork: boolean) => void;
|
||||
onUnlink: (provider: string, sourceModel: string, alias: string) => void;
|
||||
}
|
||||
|
||||
export function SettingsSourceModal({
|
||||
open,
|
||||
t,
|
||||
source,
|
||||
onClose,
|
||||
onToggleFork,
|
||||
onUnlink
|
||||
}: SettingsSourceModalProps) {
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
title={t('oauth_model_alias.diagram_settings_source_title')}
|
||||
width={720}
|
||||
footer={
|
||||
<Button variant="secondary" onClick={onClose}>
|
||||
{t('common.close')}
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
{source ? (
|
||||
source.aliases.length === 0 ? (
|
||||
<div className={styles.settingsEmpty}>{t('oauth_model_alias.diagram_settings_empty')}</div>
|
||||
) : (
|
||||
<div className={styles.settingsList}>
|
||||
{source.aliases.map((entry) => (
|
||||
<div key={`${source.id}-${entry.alias}`} className={styles.settingsRow}>
|
||||
<div className={styles.settingsNames}>
|
||||
<span className={styles.settingsSource}>{source.name}</span>
|
||||
<span className={styles.settingsArrow}>→</span>
|
||||
<span className={styles.settingsAlias}>{entry.alias}</span>
|
||||
</div>
|
||||
<div className={styles.settingsActions}>
|
||||
<span className={styles.settingsLabel}>
|
||||
{t('oauth_model_alias.alias_fork_label')}
|
||||
</span>
|
||||
<ToggleSwitch
|
||||
checked={entry.fork === true}
|
||||
onChange={(value) => onToggleFork(source.provider, source.name, entry.alias, value)}
|
||||
ariaLabel={t('oauth_model_alias.alias_fork_label')}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.settingsDelete}
|
||||
onClick={() => onUnlink(source.provider, source.name, entry.alias)}
|
||||
aria-label={t('oauth_model_alias.diagram_delete_link', {
|
||||
provider: source.provider,
|
||||
name: source.name
|
||||
})}
|
||||
title={t('oauth_model_alias.diagram_delete_link', {
|
||||
provider: source.provider,
|
||||
name: source.name
|
||||
})}
|
||||
>
|
||||
<IconTrash2 size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
) : null}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
33
src/components/modelAlias/ModelMappingDiagramTypes.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
export interface AuthFileModelItem {
|
||||
id: string;
|
||||
display_name?: string;
|
||||
type?: string;
|
||||
owned_by?: string;
|
||||
}
|
||||
|
||||
export interface SourceNode {
|
||||
id: string; // unique: provider::name
|
||||
provider: string;
|
||||
name: string;
|
||||
aliases: { alias: string; fork: boolean }[]; // all aliases this source maps to
|
||||
}
|
||||
|
||||
export interface AliasNode {
|
||||
id: string; // alias
|
||||
alias: string;
|
||||
sources: SourceNode[];
|
||||
}
|
||||
|
||||
export interface ProviderNode {
|
||||
provider: string;
|
||||
sources: SourceNode[];
|
||||
}
|
||||
|
||||
export interface ContextMenuState {
|
||||
x: number;
|
||||
y: number;
|
||||
type: 'alias' | 'background' | 'provider' | 'source';
|
||||
data?: string;
|
||||
}
|
||||
|
||||
export type DiagramLine = { path: string; color: string; id: string };
|
||||
2
src/components/modelAlias/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { ModelMappingDiagram } from './ModelMappingDiagram';
|
||||
export type { ModelMappingDiagramProps, ModelMappingDiagramRef } from './ModelMappingDiagram';
|
||||
409
src/components/monitor/ChannelStats.tsx
Normal file
@@ -0,0 +1,409 @@
|
||||
import { useMemo, useState, useCallback, Fragment } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
import { useDisableModel } from '@/hooks';
|
||||
import { TimeRangeSelector, formatTimeRangeCaption, type TimeRange } from './TimeRangeSelector';
|
||||
import { DisableModelModal } from './DisableModelModal';
|
||||
import {
|
||||
formatTimestamp,
|
||||
getRateClassName,
|
||||
filterDataByTimeRange,
|
||||
getProviderDisplayParts,
|
||||
type DateRange,
|
||||
} from '@/utils/monitor';
|
||||
import type { UsageData } from '@/pages/MonitorPage';
|
||||
import styles from '@/pages/MonitorPage.module.scss';
|
||||
|
||||
interface ChannelStatsProps {
|
||||
data: UsageData | null;
|
||||
loading: boolean;
|
||||
providerMap: Record<string, string>;
|
||||
providerModels: Record<string, Set<string>>;
|
||||
}
|
||||
|
||||
interface ModelStat {
|
||||
requests: number;
|
||||
success: number;
|
||||
failed: number;
|
||||
successRate: number;
|
||||
recentRequests: { failed: boolean; timestamp: number }[];
|
||||
lastTimestamp: number;
|
||||
}
|
||||
|
||||
interface ChannelStat {
|
||||
source: string;
|
||||
displayName: string;
|
||||
providerName: string | null;
|
||||
maskedKey: string;
|
||||
totalRequests: number;
|
||||
successRequests: number;
|
||||
failedRequests: number;
|
||||
successRate: number;
|
||||
lastRequestTime: number;
|
||||
recentRequests: { failed: boolean; timestamp: number }[];
|
||||
models: Record<string, ModelStat>;
|
||||
}
|
||||
|
||||
export function ChannelStats({ data, loading, providerMap, providerModels }: ChannelStatsProps) {
|
||||
const { t } = useTranslation();
|
||||
const [expandedChannel, setExpandedChannel] = useState<string | null>(null);
|
||||
const [filterChannel, setFilterChannel] = useState('');
|
||||
const [filterModel, setFilterModel] = useState('');
|
||||
const [filterStatus, setFilterStatus] = useState<'' | 'success' | 'failed'>('');
|
||||
|
||||
// 时间范围状态
|
||||
const [timeRange, setTimeRange] = useState<TimeRange>(7);
|
||||
const [customRange, setCustomRange] = useState<DateRange | undefined>();
|
||||
|
||||
// 使用禁用模型 Hook
|
||||
const {
|
||||
disableState,
|
||||
disabling,
|
||||
isModelDisabled,
|
||||
handleDisableClick: onDisableClick,
|
||||
handleConfirmDisable,
|
||||
handleCancelDisable,
|
||||
} = useDisableModel({ providerMap, providerModels });
|
||||
|
||||
// 处理时间范围变化
|
||||
const handleTimeRangeChange = useCallback((range: TimeRange, custom?: DateRange) => {
|
||||
setTimeRange(range);
|
||||
if (custom) {
|
||||
setCustomRange(custom);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 根据时间范围过滤数据
|
||||
const timeFilteredData = useMemo(() => {
|
||||
return filterDataByTimeRange(data, timeRange, customRange);
|
||||
}, [data, timeRange, customRange]);
|
||||
|
||||
// 计算渠道统计数据
|
||||
const channelStats = useMemo(() => {
|
||||
if (!timeFilteredData?.apis) return [];
|
||||
|
||||
const stats: Record<string, ChannelStat> = {};
|
||||
|
||||
Object.values(timeFilteredData.apis).forEach((apiData) => {
|
||||
Object.entries(apiData.models).forEach(([modelName, modelData]) => {
|
||||
modelData.details.forEach((detail) => {
|
||||
const source = detail.source || 'unknown';
|
||||
// 获取渠道显示信息
|
||||
const { provider, masked } = getProviderDisplayParts(source, providerMap);
|
||||
// 只统计在 providerMap 中存在的渠道
|
||||
if (!provider) return;
|
||||
|
||||
const displayName = `${provider} (${masked})`;
|
||||
const timestamp = detail.timestamp ? new Date(detail.timestamp).getTime() : 0;
|
||||
|
||||
if (!stats[displayName]) {
|
||||
stats[displayName] = {
|
||||
source,
|
||||
displayName,
|
||||
providerName: provider,
|
||||
maskedKey: masked,
|
||||
totalRequests: 0,
|
||||
successRequests: 0,
|
||||
failedRequests: 0,
|
||||
successRate: 0,
|
||||
lastRequestTime: 0,
|
||||
recentRequests: [],
|
||||
models: {},
|
||||
};
|
||||
}
|
||||
|
||||
stats[displayName].totalRequests++;
|
||||
if (detail.failed) {
|
||||
stats[displayName].failedRequests++;
|
||||
} else {
|
||||
stats[displayName].successRequests++;
|
||||
}
|
||||
|
||||
// 更新最近请求时间
|
||||
if (timestamp > stats[displayName].lastRequestTime) {
|
||||
stats[displayName].lastRequestTime = timestamp;
|
||||
}
|
||||
|
||||
// 收集请求状态
|
||||
stats[displayName].recentRequests.push({ failed: detail.failed, timestamp });
|
||||
|
||||
// 模型统计
|
||||
if (!stats[displayName].models[modelName]) {
|
||||
stats[displayName].models[modelName] = {
|
||||
requests: 0,
|
||||
success: 0,
|
||||
failed: 0,
|
||||
successRate: 0,
|
||||
recentRequests: [],
|
||||
lastTimestamp: 0,
|
||||
};
|
||||
}
|
||||
stats[displayName].models[modelName].requests++;
|
||||
if (detail.failed) {
|
||||
stats[displayName].models[modelName].failed++;
|
||||
} else {
|
||||
stats[displayName].models[modelName].success++;
|
||||
}
|
||||
stats[displayName].models[modelName].recentRequests.push({ failed: detail.failed, timestamp });
|
||||
if (timestamp > stats[displayName].models[modelName].lastTimestamp) {
|
||||
stats[displayName].models[modelName].lastTimestamp = timestamp;
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// 计算成功率并排序请求
|
||||
Object.values(stats).forEach((stat) => {
|
||||
stat.successRate = stat.totalRequests > 0
|
||||
? (stat.successRequests / stat.totalRequests) * 100
|
||||
: 0;
|
||||
// 按时间排序,取最近12个
|
||||
stat.recentRequests.sort((a, b) => a.timestamp - b.timestamp);
|
||||
stat.recentRequests = stat.recentRequests.slice(-12);
|
||||
|
||||
Object.values(stat.models).forEach((model) => {
|
||||
model.successRate = model.requests > 0
|
||||
? (model.success / model.requests) * 100
|
||||
: 0;
|
||||
model.recentRequests.sort((a, b) => a.timestamp - b.timestamp);
|
||||
model.recentRequests = model.recentRequests.slice(-12);
|
||||
});
|
||||
});
|
||||
|
||||
return Object.values(stats)
|
||||
.filter((stat) => stat.totalRequests > 0)
|
||||
.sort((a, b) => b.totalRequests - a.totalRequests)
|
||||
.slice(0, 10);
|
||||
}, [timeFilteredData, providerMap]);
|
||||
|
||||
// 获取所有渠道和模型列表
|
||||
const { channels, models } = useMemo(() => {
|
||||
const channelSet = new Set<string>();
|
||||
const modelSet = new Set<string>();
|
||||
|
||||
channelStats.forEach((stat) => {
|
||||
channelSet.add(stat.displayName);
|
||||
Object.keys(stat.models).forEach((model) => modelSet.add(model));
|
||||
});
|
||||
|
||||
return {
|
||||
channels: Array.from(channelSet).sort(),
|
||||
models: Array.from(modelSet).sort(),
|
||||
};
|
||||
}, [channelStats]);
|
||||
|
||||
// 过滤后的数据
|
||||
const filteredStats = useMemo(() => {
|
||||
return channelStats.filter((stat) => {
|
||||
if (filterChannel && stat.displayName !== filterChannel) return false;
|
||||
if (filterModel && !stat.models[filterModel]) return false;
|
||||
if (filterStatus === 'success' && stat.failedRequests > 0) return false;
|
||||
if (filterStatus === 'failed' && stat.failedRequests === 0) return false;
|
||||
return true;
|
||||
});
|
||||
}, [channelStats, filterChannel, filterModel, filterStatus]);
|
||||
|
||||
// 切换展开状态
|
||||
const toggleExpand = (displayName: string) => {
|
||||
setExpandedChannel(expandedChannel === displayName ? null : displayName);
|
||||
};
|
||||
|
||||
// 开始禁用流程(阻止事件冒泡)
|
||||
const handleDisableClick = (source: string, model: string, e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
onDisableClick(source, model);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card
|
||||
title={t('monitor.channel.title')}
|
||||
subtitle={
|
||||
<span>
|
||||
{formatTimeRangeCaption(timeRange, customRange, t)} · {t('monitor.channel.subtitle')}
|
||||
<span style={{ color: 'var(--text-tertiary)' }}> · {t('monitor.channel.click_hint')}</span>
|
||||
</span>
|
||||
}
|
||||
extra={
|
||||
<TimeRangeSelector
|
||||
value={timeRange}
|
||||
onChange={handleTimeRangeChange}
|
||||
customRange={customRange}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{/* 筛选器 */}
|
||||
<div className={styles.logFilters}>
|
||||
<select
|
||||
className={styles.logSelect}
|
||||
value={filterChannel}
|
||||
onChange={(e) => setFilterChannel(e.target.value)}
|
||||
>
|
||||
<option value="">{t('monitor.channel.all_channels')}</option>
|
||||
{channels.map((channel) => (
|
||||
<option key={channel} value={channel}>{channel}</option>
|
||||
))}
|
||||
</select>
|
||||
<select
|
||||
className={styles.logSelect}
|
||||
value={filterModel}
|
||||
onChange={(e) => setFilterModel(e.target.value)}
|
||||
>
|
||||
<option value="">{t('monitor.channel.all_models')}</option>
|
||||
{models.map((model) => (
|
||||
<option key={model} value={model}>{model}</option>
|
||||
))}
|
||||
</select>
|
||||
<select
|
||||
className={styles.logSelect}
|
||||
value={filterStatus}
|
||||
onChange={(e) => setFilterStatus(e.target.value as '' | 'success' | 'failed')}
|
||||
>
|
||||
<option value="">{t('monitor.channel.all_status')}</option>
|
||||
<option value="success">{t('monitor.channel.only_success')}</option>
|
||||
<option value="failed">{t('monitor.channel.only_failed')}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* 表格 */}
|
||||
<div className={styles.tableWrapper}>
|
||||
{loading ? (
|
||||
<div className={styles.emptyState}>{t('common.loading')}</div>
|
||||
) : filteredStats.length === 0 ? (
|
||||
<div className={styles.emptyState}>{t('monitor.no_data')}</div>
|
||||
) : (
|
||||
<table className={styles.table}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{t('monitor.channel.header_name')}</th>
|
||||
<th>{t('monitor.channel.header_count')}</th>
|
||||
<th>{t('monitor.channel.header_rate')}</th>
|
||||
<th>{t('monitor.channel.header_recent')}</th>
|
||||
<th>{t('monitor.channel.header_time')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filteredStats.map((stat) => (
|
||||
<Fragment key={stat.displayName}>
|
||||
<tr
|
||||
className={styles.expandable}
|
||||
onClick={() => toggleExpand(stat.displayName)}
|
||||
>
|
||||
<td>
|
||||
{stat.providerName ? (
|
||||
<>
|
||||
<span className={styles.channelName}>{stat.providerName}</span>
|
||||
<span className={styles.channelSecret}> ({stat.maskedKey})</span>
|
||||
</>
|
||||
) : (
|
||||
stat.maskedKey
|
||||
)}
|
||||
</td>
|
||||
<td>{stat.totalRequests.toLocaleString()}</td>
|
||||
<td className={getRateClassName(stat.successRate, styles)}>
|
||||
{stat.successRate.toFixed(1)}%
|
||||
</td>
|
||||
<td>
|
||||
<div className={styles.statusBars}>
|
||||
{stat.recentRequests.map((req, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={`${styles.statusBar} ${req.failed ? styles.failure : styles.success}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</td>
|
||||
<td>{formatTimestamp(stat.lastRequestTime)}</td>
|
||||
</tr>
|
||||
{expandedChannel === stat.displayName && (
|
||||
<tr key={`${stat.displayName}-detail`}>
|
||||
<td colSpan={5} className={styles.expandDetail}>
|
||||
<div className={styles.expandTableWrapper}>
|
||||
<table className={styles.table}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{t('monitor.channel.model')}</th>
|
||||
<th>{t('monitor.channel.header_count')}</th>
|
||||
<th>{t('monitor.channel.header_rate')}</th>
|
||||
<th>{t('monitor.channel.success')}/{t('monitor.channel.failed')}</th>
|
||||
<th>{t('monitor.channel.header_recent')}</th>
|
||||
<th>{t('monitor.channel.header_time')}</th>
|
||||
<th>{t('monitor.logs.header_actions')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{Object.entries(stat.models)
|
||||
.sort((a, b) => {
|
||||
const aDisabled = isModelDisabled(stat.source, a[0]);
|
||||
const bDisabled = isModelDisabled(stat.source, b[0]);
|
||||
// 已禁用的排在后面
|
||||
if (aDisabled !== bDisabled) {
|
||||
return aDisabled ? 1 : -1;
|
||||
}
|
||||
// 然后按请求数降序
|
||||
return b[1].requests - a[1].requests;
|
||||
})
|
||||
.map(([modelName, modelStat]) => {
|
||||
const disabled = isModelDisabled(stat.source, modelName);
|
||||
return (
|
||||
<tr key={modelName} className={disabled ? styles.modelDisabled : ''}>
|
||||
<td>{modelName}</td>
|
||||
<td>{modelStat.requests.toLocaleString()}</td>
|
||||
<td className={getRateClassName(modelStat.successRate, styles)}>
|
||||
{modelStat.successRate.toFixed(1)}%
|
||||
</td>
|
||||
<td>
|
||||
<span className={styles.kpiSuccess}>{modelStat.success}</span>
|
||||
{' / '}
|
||||
<span className={styles.kpiFailure}>{modelStat.failed}</span>
|
||||
</td>
|
||||
<td>
|
||||
<div className={styles.statusBars}>
|
||||
{modelStat.recentRequests.map((req, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={`${styles.statusBar} ${req.failed ? styles.failure : styles.success}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</td>
|
||||
<td>{formatTimestamp(modelStat.lastTimestamp)}</td>
|
||||
<td>
|
||||
{disabled ? (
|
||||
<span className={styles.disabledLabel}>{t('monitor.logs.removed')}</span>
|
||||
) : stat.source && stat.source !== '-' && stat.source !== 'unknown' ? (
|
||||
<button
|
||||
className={styles.disableBtn}
|
||||
onClick={(e) => handleDisableClick(stat.source, modelName, e)}
|
||||
>
|
||||
{t('monitor.logs.disable')}
|
||||
</button>
|
||||
) : '-'}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</Fragment>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* 禁用确认弹窗 */}
|
||||
<DisableModelModal
|
||||
disableState={disableState}
|
||||
disabling={disabling}
|
||||
onConfirm={handleConfirmDisable}
|
||||
onCancel={handleCancelDisable}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
279
src/components/monitor/DailyTrendChart.tsx
Normal file
@@ -0,0 +1,279 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Chart } from 'react-chartjs-2';
|
||||
import type { UsageData } from '@/pages/MonitorPage';
|
||||
import styles from '@/pages/MonitorPage.module.scss';
|
||||
|
||||
interface DailyTrendChartProps {
|
||||
data: UsageData | null;
|
||||
loading: boolean;
|
||||
isDark: boolean;
|
||||
timeRange: number;
|
||||
}
|
||||
|
||||
interface DailyStat {
|
||||
date: string;
|
||||
requests: number;
|
||||
successRequests: number;
|
||||
failedRequests: number;
|
||||
inputTokens: number;
|
||||
outputTokens: number;
|
||||
reasoningTokens: number;
|
||||
cachedTokens: number;
|
||||
}
|
||||
|
||||
export function DailyTrendChart({ data, loading, isDark, timeRange }: DailyTrendChartProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
// 按日期聚合数据
|
||||
const dailyData = useMemo((): DailyStat[] => {
|
||||
if (!data?.apis) return [];
|
||||
|
||||
const dailyStats: Record<string, {
|
||||
requests: number;
|
||||
successRequests: number;
|
||||
failedRequests: number;
|
||||
inputTokens: number;
|
||||
outputTokens: number;
|
||||
reasoningTokens: number;
|
||||
cachedTokens: number;
|
||||
}> = {};
|
||||
|
||||
// 辅助函数:获取本地日期字符串 YYYY-MM-DD
|
||||
const getLocalDateString = (timestamp: string): string => {
|
||||
const date = new Date(timestamp);
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(date.getDate()).padStart(2, '0');
|
||||
return `${year}-${month}-${day}`;
|
||||
};
|
||||
|
||||
Object.values(data.apis).forEach((apiData) => {
|
||||
Object.values(apiData.models).forEach((modelData) => {
|
||||
modelData.details.forEach((detail) => {
|
||||
// 使用本地日期而非 UTC 日期
|
||||
const date = getLocalDateString(detail.timestamp);
|
||||
if (!dailyStats[date]) {
|
||||
dailyStats[date] = {
|
||||
requests: 0,
|
||||
successRequests: 0,
|
||||
failedRequests: 0,
|
||||
inputTokens: 0,
|
||||
outputTokens: 0,
|
||||
reasoningTokens: 0,
|
||||
cachedTokens: 0,
|
||||
};
|
||||
}
|
||||
dailyStats[date].requests++;
|
||||
if (detail.failed) {
|
||||
dailyStats[date].failedRequests++;
|
||||
} else {
|
||||
dailyStats[date].successRequests++;
|
||||
// 只统计成功请求的 Token
|
||||
dailyStats[date].inputTokens += detail.tokens.input_tokens || 0;
|
||||
dailyStats[date].outputTokens += detail.tokens.output_tokens || 0;
|
||||
dailyStats[date].reasoningTokens += detail.tokens.reasoning_tokens || 0;
|
||||
dailyStats[date].cachedTokens += detail.tokens.cached_tokens || 0;
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// 转换为数组并按日期排序
|
||||
return Object.entries(dailyStats)
|
||||
.map(([date, stats]) => ({ date, ...stats }))
|
||||
.sort((a, b) => a.date.localeCompare(b.date));
|
||||
}, [data]);
|
||||
|
||||
// 图表数据
|
||||
const chartData = useMemo(() => {
|
||||
const labels = dailyData.map((item) => {
|
||||
const date = new Date(item.date);
|
||||
return `${date.getMonth() + 1}/${date.getDate()}`;
|
||||
});
|
||||
|
||||
return {
|
||||
labels,
|
||||
datasets: [
|
||||
{
|
||||
type: 'line' as const,
|
||||
label: t('monitor.trend.requests'),
|
||||
data: dailyData.map((item) => item.requests),
|
||||
borderColor: '#3b82f6',
|
||||
backgroundColor: '#3b82f6',
|
||||
borderWidth: 3,
|
||||
fill: false,
|
||||
tension: 0.35,
|
||||
yAxisID: 'y1',
|
||||
order: 0,
|
||||
pointRadius: 3,
|
||||
pointBackgroundColor: '#3b82f6',
|
||||
},
|
||||
{
|
||||
type: 'bar' as const,
|
||||
label: t('monitor.trend.input_tokens'),
|
||||
data: dailyData.map((item) => item.inputTokens / 1000),
|
||||
backgroundColor: 'rgba(34, 197, 94, 0.7)',
|
||||
borderColor: 'rgba(34, 197, 94, 0.7)',
|
||||
borderWidth: 1,
|
||||
borderRadius: 0,
|
||||
yAxisID: 'y',
|
||||
order: 1,
|
||||
stack: 'tokens',
|
||||
},
|
||||
{
|
||||
type: 'bar' as const,
|
||||
label: t('monitor.trend.output_tokens'),
|
||||
data: dailyData.map((item) => item.outputTokens / 1000),
|
||||
backgroundColor: 'rgba(249, 115, 22, 0.7)',
|
||||
borderColor: 'rgba(249, 115, 22, 0.7)',
|
||||
borderWidth: 1,
|
||||
borderRadius: 4,
|
||||
yAxisID: 'y',
|
||||
order: 1,
|
||||
stack: 'tokens',
|
||||
},
|
||||
],
|
||||
};
|
||||
}, [dailyData, t]);
|
||||
|
||||
// 图表配置
|
||||
const chartOptions = useMemo(() => ({
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
interaction: {
|
||||
mode: 'index' as const,
|
||||
intersect: false,
|
||||
},
|
||||
plugins: {
|
||||
legend: {
|
||||
display: true,
|
||||
position: 'bottom' as const,
|
||||
labels: {
|
||||
color: isDark ? '#9ca3af' : '#6b7280',
|
||||
usePointStyle: true,
|
||||
padding: 16,
|
||||
font: {
|
||||
size: 11,
|
||||
},
|
||||
generateLabels: (chart: any) => {
|
||||
return chart.data.datasets.map((dataset: any, i: number) => {
|
||||
const isLine = dataset.type === 'line';
|
||||
return {
|
||||
text: dataset.label,
|
||||
fillStyle: dataset.backgroundColor,
|
||||
strokeStyle: dataset.borderColor,
|
||||
lineWidth: 0,
|
||||
hidden: !chart.isDatasetVisible(i),
|
||||
datasetIndex: i,
|
||||
pointStyle: isLine ? 'circle' : 'rect',
|
||||
};
|
||||
});
|
||||
},
|
||||
},
|
||||
},
|
||||
tooltip: {
|
||||
backgroundColor: isDark ? '#374151' : '#ffffff',
|
||||
titleColor: isDark ? '#f3f4f6' : '#111827',
|
||||
bodyColor: isDark ? '#d1d5db' : '#4b5563',
|
||||
borderColor: isDark ? '#4b5563' : '#e5e7eb',
|
||||
borderWidth: 1,
|
||||
padding: 12,
|
||||
callbacks: {
|
||||
label: (context: any) => {
|
||||
const label = context.dataset.label || '';
|
||||
const value = context.raw;
|
||||
if (context.dataset.yAxisID === 'y1') {
|
||||
return `${label}: ${value.toLocaleString()}`;
|
||||
}
|
||||
return `${label}: ${value.toFixed(1)}K`;
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
grid: {
|
||||
color: isDark ? 'rgba(255, 255, 255, 0.06)' : 'rgba(0, 0, 0, 0.06)',
|
||||
},
|
||||
ticks: {
|
||||
color: isDark ? '#9ca3af' : '#6b7280',
|
||||
font: {
|
||||
size: 11,
|
||||
},
|
||||
},
|
||||
},
|
||||
y: {
|
||||
type: 'linear' as const,
|
||||
position: 'left' as const,
|
||||
stacked: true,
|
||||
grid: {
|
||||
color: isDark ? 'rgba(255, 255, 255, 0.06)' : 'rgba(0, 0, 0, 0.06)',
|
||||
},
|
||||
ticks: {
|
||||
color: isDark ? '#9ca3af' : '#6b7280',
|
||||
font: {
|
||||
size: 11,
|
||||
},
|
||||
callback: (value: string | number) => `${value}K`,
|
||||
},
|
||||
title: {
|
||||
display: true,
|
||||
text: 'Tokens (K)',
|
||||
color: isDark ? '#9ca3af' : '#6b7280',
|
||||
font: {
|
||||
size: 11,
|
||||
},
|
||||
},
|
||||
},
|
||||
y1: {
|
||||
type: 'linear' as const,
|
||||
position: 'right' as const,
|
||||
grid: {
|
||||
drawOnChartArea: false,
|
||||
},
|
||||
ticks: {
|
||||
color: isDark ? '#9ca3af' : '#6b7280',
|
||||
font: {
|
||||
size: 11,
|
||||
},
|
||||
},
|
||||
title: {
|
||||
display: true,
|
||||
text: t('monitor.trend.requests'),
|
||||
color: isDark ? '#9ca3af' : '#6b7280',
|
||||
font: {
|
||||
size: 11,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}), [isDark, t]);
|
||||
|
||||
const timeRangeLabel = timeRange === 1
|
||||
? t('monitor.today')
|
||||
: t('monitor.last_n_days', { n: timeRange });
|
||||
|
||||
return (
|
||||
<div className={styles.chartCard}>
|
||||
<div className={styles.chartHeader}>
|
||||
<div>
|
||||
<h3 className={styles.chartTitle}>{t('monitor.trend.title')}</h3>
|
||||
<p className={styles.chartSubtitle}>
|
||||
{timeRangeLabel} · {t('monitor.trend.subtitle')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.chartContent}>
|
||||
{loading || dailyData.length === 0 ? (
|
||||
<div className={styles.chartEmpty}>
|
||||
{loading ? t('common.loading') : t('monitor.no_data')}
|
||||
</div>
|
||||
) : (
|
||||
<Chart type="bar" data={chartData} options={chartOptions} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
101
src/components/monitor/DisableModelModal.tsx
Normal file
@@ -0,0 +1,101 @@
|
||||
/**
|
||||
* 禁用模型确认弹窗组件
|
||||
* 封装三次确认的 UI 逻辑
|
||||
*/
|
||||
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Modal } from '@/components/ui/Modal';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import type { DisableState } from '@/utils/monitor';
|
||||
|
||||
interface DisableModelModalProps {
|
||||
/** 禁用状态 */
|
||||
disableState: DisableState | null;
|
||||
/** 是否正在禁用中 */
|
||||
disabling: boolean;
|
||||
/** 确认回调 */
|
||||
onConfirm: () => void;
|
||||
/** 取消回调 */
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
export function DisableModelModal({
|
||||
disableState,
|
||||
disabling,
|
||||
onConfirm,
|
||||
onCancel,
|
||||
}: DisableModelModalProps) {
|
||||
const { t, i18n } = useTranslation();
|
||||
const isZh = i18n.language === 'zh-CN' || i18n.language === 'zh';
|
||||
|
||||
// 获取警告内容
|
||||
const getWarningContent = () => {
|
||||
if (!disableState) return null;
|
||||
|
||||
if (disableState.step === 1) {
|
||||
return (
|
||||
<p style={{ marginBottom: 16, lineHeight: 1.6 }}>
|
||||
{isZh ? '确定要禁用 ' : 'Are you sure you want to disable '}
|
||||
<strong>{disableState.displayName}</strong>
|
||||
{isZh ? ' 吗?' : '?'}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
if (disableState.step === 2) {
|
||||
return (
|
||||
<p style={{ marginBottom: 16, lineHeight: 1.6, color: 'var(--warning-color, #f59e0b)' }}>
|
||||
{isZh
|
||||
? '⚠️ 警告:此操作将从配置中移除该模型映射!'
|
||||
: '⚠️ Warning: this removes the model mapping from config!'}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<p style={{ marginBottom: 16, lineHeight: 1.6, color: 'var(--danger-color, #ef4444)' }}>
|
||||
{isZh
|
||||
? '🚨 最后确认:禁用后需要手动重新添加才能恢复!'
|
||||
: "🚨 Final confirmation: you'll need to add it back manually later!"}
|
||||
</p>
|
||||
);
|
||||
};
|
||||
|
||||
// 获取确认按钮文本
|
||||
const getConfirmButtonText = () => {
|
||||
if (!disableState) return '';
|
||||
const btnTexts = isZh
|
||||
? ['确认禁用 (3)', '我确定 (2)', '立即禁用 (1)']
|
||||
: ['Confirm (3)', "I'm sure (2)", 'Disable now (1)'];
|
||||
return btnTexts[disableState.step - 1] || btnTexts[0];
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={!!disableState}
|
||||
onClose={onCancel}
|
||||
title={t('monitor.logs.disable_confirm_title')}
|
||||
width={400}
|
||||
>
|
||||
<div style={{ padding: '16px 0' }}>
|
||||
{getWarningContent()}
|
||||
<div style={{ display: 'flex', gap: 12, justifyContent: 'flex-end' }}>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={onCancel}
|
||||
disabled={disabling}
|
||||
>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="danger"
|
||||
onClick={onConfirm}
|
||||
disabled={disabling}
|
||||
>
|
||||
{disabling ? t('monitor.logs.disabling') : getConfirmButtonText()}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
420
src/components/monitor/FailureAnalysis.tsx
Normal file
@@ -0,0 +1,420 @@
|
||||
import { useMemo, useState, useCallback, Fragment } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
import { useDisableModel } from '@/hooks';
|
||||
import { TimeRangeSelector, formatTimeRangeCaption, type TimeRange } from './TimeRangeSelector';
|
||||
import { DisableModelModal } from './DisableModelModal';
|
||||
import {
|
||||
formatTimestamp,
|
||||
getRateClassName,
|
||||
filterDataByTimeRange,
|
||||
getProviderDisplayParts,
|
||||
type DateRange,
|
||||
} from '@/utils/monitor';
|
||||
import type { UsageData } from '@/pages/MonitorPage';
|
||||
import styles from '@/pages/MonitorPage.module.scss';
|
||||
|
||||
interface FailureAnalysisProps {
|
||||
data: UsageData | null;
|
||||
loading: boolean;
|
||||
providerMap: Record<string, string>;
|
||||
providerModels: Record<string, Set<string>>;
|
||||
}
|
||||
|
||||
interface ModelFailureStat {
|
||||
success: number;
|
||||
failure: number;
|
||||
total: number;
|
||||
successRate: number;
|
||||
recentRequests: { failed: boolean; timestamp: number }[];
|
||||
lastTimestamp: number;
|
||||
}
|
||||
|
||||
interface FailureStat {
|
||||
source: string;
|
||||
displayName: string;
|
||||
providerName: string | null;
|
||||
maskedKey: string;
|
||||
failedCount: number;
|
||||
lastFailTime: number;
|
||||
models: Record<string, ModelFailureStat>;
|
||||
}
|
||||
|
||||
export function FailureAnalysis({ data, loading, providerMap, providerModels }: FailureAnalysisProps) {
|
||||
const { t } = useTranslation();
|
||||
const [expandedChannel, setExpandedChannel] = useState<string | null>(null);
|
||||
const [filterChannel, setFilterChannel] = useState('');
|
||||
const [filterModel, setFilterModel] = useState('');
|
||||
|
||||
// 时间范围状态
|
||||
const [timeRange, setTimeRange] = useState<TimeRange>(7);
|
||||
const [customRange, setCustomRange] = useState<DateRange | undefined>();
|
||||
|
||||
// 使用禁用模型 Hook
|
||||
const {
|
||||
disableState,
|
||||
disabling,
|
||||
isModelDisabled,
|
||||
handleDisableClick: onDisableClick,
|
||||
handleConfirmDisable,
|
||||
handleCancelDisable,
|
||||
} = useDisableModel({ providerMap, providerModels });
|
||||
|
||||
// 处理时间范围变化
|
||||
const handleTimeRangeChange = useCallback((range: TimeRange, custom?: DateRange) => {
|
||||
setTimeRange(range);
|
||||
if (custom) {
|
||||
setCustomRange(custom);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 根据时间范围过滤数据
|
||||
const timeFilteredData = useMemo(() => {
|
||||
return filterDataByTimeRange(data, timeRange, customRange);
|
||||
}, [data, timeRange, customRange]);
|
||||
|
||||
// 计算失败统计数据
|
||||
const failureStats = useMemo(() => {
|
||||
if (!timeFilteredData?.apis) return [];
|
||||
|
||||
// 首先收集有失败记录的渠道
|
||||
const failedSources = new Set<string>();
|
||||
Object.values(timeFilteredData.apis).forEach((apiData) => {
|
||||
Object.values(apiData.models).forEach((modelData) => {
|
||||
modelData.details.forEach((detail) => {
|
||||
if (detail.failed) {
|
||||
const source = detail.source || 'unknown';
|
||||
const { provider } = getProviderDisplayParts(source, providerMap);
|
||||
if (provider) {
|
||||
failedSources.add(source);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// 统计这些渠道的所有请求
|
||||
const stats: Record<string, FailureStat> = {};
|
||||
|
||||
Object.values(timeFilteredData.apis).forEach((apiData) => {
|
||||
Object.entries(apiData.models).forEach(([modelName, modelData]) => {
|
||||
modelData.details.forEach((detail) => {
|
||||
const source = detail.source || 'unknown';
|
||||
// 只统计有失败记录的渠道
|
||||
if (!failedSources.has(source)) return;
|
||||
|
||||
const { provider, masked } = getProviderDisplayParts(source, providerMap);
|
||||
const displayName = provider ? `${provider} (${masked})` : masked;
|
||||
const timestamp = detail.timestamp ? new Date(detail.timestamp).getTime() : 0;
|
||||
|
||||
if (!stats[displayName]) {
|
||||
stats[displayName] = {
|
||||
source,
|
||||
displayName,
|
||||
providerName: provider,
|
||||
maskedKey: masked,
|
||||
failedCount: 0,
|
||||
lastFailTime: 0,
|
||||
models: {},
|
||||
};
|
||||
}
|
||||
|
||||
if (detail.failed) {
|
||||
stats[displayName].failedCount++;
|
||||
if (timestamp > stats[displayName].lastFailTime) {
|
||||
stats[displayName].lastFailTime = timestamp;
|
||||
}
|
||||
}
|
||||
|
||||
// 按模型统计
|
||||
if (!stats[displayName].models[modelName]) {
|
||||
stats[displayName].models[modelName] = {
|
||||
success: 0,
|
||||
failure: 0,
|
||||
total: 0,
|
||||
successRate: 0,
|
||||
recentRequests: [],
|
||||
lastTimestamp: 0,
|
||||
};
|
||||
}
|
||||
stats[displayName].models[modelName].total++;
|
||||
if (detail.failed) {
|
||||
stats[displayName].models[modelName].failure++;
|
||||
} else {
|
||||
stats[displayName].models[modelName].success++;
|
||||
}
|
||||
stats[displayName].models[modelName].recentRequests.push({ failed: detail.failed, timestamp });
|
||||
if (timestamp > stats[displayName].models[modelName].lastTimestamp) {
|
||||
stats[displayName].models[modelName].lastTimestamp = timestamp;
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// 计算成功率并排序请求
|
||||
Object.values(stats).forEach((stat) => {
|
||||
Object.values(stat.models).forEach((model) => {
|
||||
model.successRate = model.total > 0
|
||||
? (model.success / model.total) * 100
|
||||
: 0;
|
||||
model.recentRequests.sort((a, b) => a.timestamp - b.timestamp);
|
||||
model.recentRequests = model.recentRequests.slice(-12);
|
||||
});
|
||||
});
|
||||
|
||||
return Object.values(stats)
|
||||
.filter((stat) => stat.failedCount > 0)
|
||||
.sort((a, b) => b.failedCount - a.failedCount)
|
||||
.slice(0, 10);
|
||||
}, [timeFilteredData, providerMap]);
|
||||
|
||||
// 获取所有渠道和模型列表
|
||||
const { channels, models } = useMemo(() => {
|
||||
const channelSet = new Set<string>();
|
||||
const modelSet = new Set<string>();
|
||||
|
||||
failureStats.forEach((stat) => {
|
||||
channelSet.add(stat.displayName);
|
||||
Object.keys(stat.models).forEach((model) => modelSet.add(model));
|
||||
});
|
||||
|
||||
return {
|
||||
channels: Array.from(channelSet).sort(),
|
||||
models: Array.from(modelSet).sort(),
|
||||
};
|
||||
}, [failureStats]);
|
||||
|
||||
// 过滤后的数据
|
||||
const filteredStats = useMemo(() => {
|
||||
return failureStats.filter((stat) => {
|
||||
if (filterChannel && stat.displayName !== filterChannel) return false;
|
||||
if (filterModel && !stat.models[filterModel]) return false;
|
||||
return true;
|
||||
});
|
||||
}, [failureStats, filterChannel, filterModel]);
|
||||
|
||||
// 切换展开状态
|
||||
const toggleExpand = (displayName: string) => {
|
||||
setExpandedChannel(expandedChannel === displayName ? null : displayName);
|
||||
};
|
||||
|
||||
// 获取主要失败模型(前2个,已禁用的排在后面)
|
||||
const getTopFailedModels = (source: string, modelsMap: Record<string, ModelFailureStat>) => {
|
||||
return Object.entries(modelsMap)
|
||||
.filter(([, stat]) => stat.failure > 0)
|
||||
.sort((a, b) => {
|
||||
const aDisabled = isModelDisabled(source, a[0]);
|
||||
const bDisabled = isModelDisabled(source, b[0]);
|
||||
// 已禁用的排在后面
|
||||
if (aDisabled !== bDisabled) {
|
||||
return aDisabled ? 1 : -1;
|
||||
}
|
||||
// 然后按失败数降序
|
||||
return b[1].failure - a[1].failure;
|
||||
})
|
||||
.slice(0, 2);
|
||||
};
|
||||
|
||||
// 开始禁用流程(阻止事件冒泡)
|
||||
const handleDisableClick = (source: string, model: string, e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
onDisableClick(source, model);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card
|
||||
title={t('monitor.failure.title')}
|
||||
subtitle={
|
||||
<span>
|
||||
{formatTimeRangeCaption(timeRange, customRange, t)} · {t('monitor.failure.subtitle')}
|
||||
<span style={{ color: 'var(--text-tertiary)' }}> · {t('monitor.failure.click_hint')}</span>
|
||||
</span>
|
||||
}
|
||||
extra={
|
||||
<TimeRangeSelector
|
||||
value={timeRange}
|
||||
onChange={handleTimeRangeChange}
|
||||
customRange={customRange}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{/* 筛选器 */}
|
||||
<div className={styles.logFilters}>
|
||||
<select
|
||||
className={styles.logSelect}
|
||||
value={filterChannel}
|
||||
onChange={(e) => setFilterChannel(e.target.value)}
|
||||
>
|
||||
<option value="">{t('monitor.channel.all_channels')}</option>
|
||||
{channels.map((channel) => (
|
||||
<option key={channel} value={channel}>{channel}</option>
|
||||
))}
|
||||
</select>
|
||||
<select
|
||||
className={styles.logSelect}
|
||||
value={filterModel}
|
||||
onChange={(e) => setFilterModel(e.target.value)}
|
||||
>
|
||||
<option value="">{t('monitor.channel.all_models')}</option>
|
||||
{models.map((model) => (
|
||||
<option key={model} value={model}>{model}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* 表格 */}
|
||||
<div className={styles.tableWrapper}>
|
||||
{loading ? (
|
||||
<div className={styles.emptyState}>{t('common.loading')}</div>
|
||||
) : filteredStats.length === 0 ? (
|
||||
<div className={styles.emptyState}>{t('monitor.failure.no_failures')}</div>
|
||||
) : (
|
||||
<table className={styles.table}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{t('monitor.failure.header_name')}</th>
|
||||
<th>{t('monitor.failure.header_count')}</th>
|
||||
<th>{t('monitor.failure.header_time')}</th>
|
||||
<th>{t('monitor.failure.header_models')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filteredStats.map((stat) => {
|
||||
const topModels = getTopFailedModels(stat.source, stat.models);
|
||||
const totalFailedModels = Object.values(stat.models).filter(m => m.failure > 0).length;
|
||||
|
||||
return (
|
||||
<Fragment key={stat.displayName}>
|
||||
<tr
|
||||
className={styles.expandable}
|
||||
onClick={() => toggleExpand(stat.displayName)}
|
||||
>
|
||||
<td>
|
||||
{stat.providerName ? (
|
||||
<>
|
||||
<span className={styles.channelName}>{stat.providerName}</span>
|
||||
<span className={styles.channelSecret}> ({stat.maskedKey})</span>
|
||||
</>
|
||||
) : (
|
||||
stat.maskedKey
|
||||
)}
|
||||
</td>
|
||||
<td className={styles.kpiFailure}>{stat.failedCount.toLocaleString()}</td>
|
||||
<td>{formatTimestamp(stat.lastFailTime)}</td>
|
||||
<td>
|
||||
{topModels.map(([model, modelStat]) => {
|
||||
const percent = ((modelStat.failure / stat.failedCount) * 100).toFixed(0);
|
||||
const shortModel = model.length > 16 ? model.slice(0, 13) + '...' : model;
|
||||
const disabled = isModelDisabled(stat.source, model);
|
||||
return (
|
||||
<span
|
||||
key={model}
|
||||
className={`${styles.failureModelTag} ${disabled ? styles.modelDisabled : ''}`}
|
||||
title={`${model}: ${modelStat.failure} (${percent}%)${disabled ? ` - ${t('monitor.logs.removed')}` : ''}`}
|
||||
>
|
||||
{shortModel}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
{totalFailedModels > 2 && (
|
||||
<span className={styles.moreModelsHint}>
|
||||
+{totalFailedModels - 2}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
{expandedChannel === stat.displayName && (
|
||||
<tr key={`${stat.displayName}-detail`}>
|
||||
<td colSpan={4} className={styles.expandDetail}>
|
||||
<div className={styles.expandTableWrapper}>
|
||||
<table className={styles.table}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{t('monitor.channel.model')}</th>
|
||||
<th>{t('monitor.channel.header_count')}</th>
|
||||
<th>{t('monitor.channel.header_rate')}</th>
|
||||
<th>{t('monitor.channel.success')}/{t('monitor.channel.failed')}</th>
|
||||
<th>{t('monitor.channel.header_recent')}</th>
|
||||
<th>{t('monitor.channel.header_time')}</th>
|
||||
<th>{t('monitor.logs.header_actions')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{Object.entries(stat.models)
|
||||
.filter(([, m]) => m.failure > 0)
|
||||
.sort((a, b) => {
|
||||
const aDisabled = isModelDisabled(stat.source, a[0]);
|
||||
const bDisabled = isModelDisabled(stat.source, b[0]);
|
||||
// 已禁用的排在后面
|
||||
if (aDisabled !== bDisabled) {
|
||||
return aDisabled ? 1 : -1;
|
||||
}
|
||||
// 然后按失败数降序
|
||||
return b[1].failure - a[1].failure;
|
||||
})
|
||||
.map(([modelName, modelStat]) => {
|
||||
const disabled = isModelDisabled(stat.source, modelName);
|
||||
return (
|
||||
<tr key={modelName} className={disabled ? styles.modelDisabled : ''}>
|
||||
<td>{modelName}</td>
|
||||
<td>{modelStat.total.toLocaleString()}</td>
|
||||
<td className={getRateClassName(modelStat.successRate, styles)}>
|
||||
{modelStat.successRate.toFixed(1)}%
|
||||
</td>
|
||||
<td>
|
||||
<span className={styles.kpiSuccess}>{modelStat.success}</span>
|
||||
{' / '}
|
||||
<span className={styles.kpiFailure}>{modelStat.failure}</span>
|
||||
</td>
|
||||
<td>
|
||||
<div className={styles.statusBars}>
|
||||
{modelStat.recentRequests.map((req, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={`${styles.statusBar} ${req.failed ? styles.failure : styles.success}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</td>
|
||||
<td>{formatTimestamp(modelStat.lastTimestamp)}</td>
|
||||
<td>
|
||||
{disabled ? (
|
||||
<span className={styles.disabledLabel}>{t('monitor.logs.removed')}</span>
|
||||
) : stat.source && stat.source !== '-' && stat.source !== 'unknown' ? (
|
||||
<button
|
||||
className={styles.disableBtn}
|
||||
onClick={(e) => handleDisableClick(stat.source, modelName, e)}
|
||||
>
|
||||
{t('monitor.logs.disable')}
|
||||
</button>
|
||||
) : '-'}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* 禁用确认弹窗 */}
|
||||
<DisableModelModal
|
||||
disableState={disableState}
|
||||
disabling={disabling}
|
||||
onConfirm={handleConfirmDisable}
|
||||
onCancel={handleCancelDisable}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
314
src/components/monitor/HourlyModelChart.tsx
Normal file
@@ -0,0 +1,314 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Chart } from 'react-chartjs-2';
|
||||
import type { UsageData } from '@/pages/MonitorPage';
|
||||
import styles from '@/pages/MonitorPage.module.scss';
|
||||
|
||||
interface HourlyModelChartProps {
|
||||
data: UsageData | null;
|
||||
loading: boolean;
|
||||
isDark: boolean;
|
||||
}
|
||||
|
||||
// 颜色调色板
|
||||
const COLORS = [
|
||||
'rgba(59, 130, 246, 0.7)', // 蓝色
|
||||
'rgba(34, 197, 94, 0.7)', // 绿色
|
||||
'rgba(249, 115, 22, 0.7)', // 橙色
|
||||
'rgba(139, 92, 246, 0.7)', // 紫色
|
||||
'rgba(236, 72, 153, 0.7)', // 粉色
|
||||
'rgba(6, 182, 212, 0.7)', // 青色
|
||||
];
|
||||
|
||||
type HourRange = 6 | 12 | 24;
|
||||
|
||||
export function HourlyModelChart({ data, loading, isDark }: HourlyModelChartProps) {
|
||||
const { t } = useTranslation();
|
||||
const [hourRange, setHourRange] = useState<HourRange>(12);
|
||||
|
||||
// 按小时聚合数据
|
||||
const hourlyData = useMemo(() => {
|
||||
if (!data?.apis) return { hours: [], models: [], modelData: {} as Record<string, number[]>, successRates: [] };
|
||||
|
||||
const now = new Date();
|
||||
let cutoffTime: Date;
|
||||
let hoursCount: number;
|
||||
|
||||
cutoffTime = new Date(now.getTime() - hourRange * 60 * 60 * 1000);
|
||||
cutoffTime.setMinutes(0, 0, 0);
|
||||
hoursCount = hourRange + 1;
|
||||
|
||||
// 生成所有小时的时间点
|
||||
const allHours: string[] = [];
|
||||
for (let i = 0; i < hoursCount; i++) {
|
||||
const hourTime = new Date(cutoffTime.getTime() + i * 60 * 60 * 1000);
|
||||
const hourKey = hourTime.toISOString().slice(0, 13); // YYYY-MM-DDTHH
|
||||
allHours.push(hourKey);
|
||||
}
|
||||
|
||||
// 收集每小时每个模型的请求数
|
||||
const hourlyStats: Record<string, Record<string, { success: number; failed: number }>> = {};
|
||||
const modelSet = new Set<string>();
|
||||
|
||||
// 初始化所有小时
|
||||
allHours.forEach((hour) => {
|
||||
hourlyStats[hour] = {};
|
||||
});
|
||||
|
||||
Object.values(data.apis).forEach((apiData) => {
|
||||
Object.entries(apiData.models).forEach(([modelName, modelData]) => {
|
||||
modelSet.add(modelName);
|
||||
modelData.details.forEach((detail) => {
|
||||
const timestamp = new Date(detail.timestamp);
|
||||
if (timestamp < cutoffTime) return;
|
||||
|
||||
const hourKey = timestamp.toISOString().slice(0, 13); // YYYY-MM-DDTHH
|
||||
if (!hourlyStats[hourKey]) {
|
||||
hourlyStats[hourKey] = {};
|
||||
}
|
||||
if (!hourlyStats[hourKey][modelName]) {
|
||||
hourlyStats[hourKey][modelName] = { success: 0, failed: 0 };
|
||||
}
|
||||
if (detail.failed) {
|
||||
hourlyStats[hourKey][modelName].failed++;
|
||||
} else {
|
||||
hourlyStats[hourKey][modelName].success++;
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// 获取排序后的小时列表
|
||||
const hours = allHours.sort();
|
||||
|
||||
// 计算每个模型的总请求数,取 Top 6
|
||||
const modelTotals: Record<string, number> = {};
|
||||
hours.forEach((hour) => {
|
||||
Object.entries(hourlyStats[hour]).forEach(([model, stats]) => {
|
||||
modelTotals[model] = (modelTotals[model] || 0) + stats.success + stats.failed;
|
||||
});
|
||||
});
|
||||
|
||||
const topModels = Object.entries(modelTotals)
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.slice(0, 6)
|
||||
.map(([name]) => name);
|
||||
|
||||
// 构建每个模型的数据数组
|
||||
const modelData: Record<string, number[]> = {};
|
||||
topModels.forEach((model) => {
|
||||
modelData[model] = hours.map((hour) => {
|
||||
const stats = hourlyStats[hour][model];
|
||||
return stats ? stats.success + stats.failed : 0;
|
||||
});
|
||||
});
|
||||
|
||||
// 计算每小时的成功率
|
||||
const successRates = hours.map((hour) => {
|
||||
let totalSuccess = 0;
|
||||
let totalRequests = 0;
|
||||
Object.values(hourlyStats[hour]).forEach((stats) => {
|
||||
totalSuccess += stats.success;
|
||||
totalRequests += stats.success + stats.failed;
|
||||
});
|
||||
return totalRequests > 0 ? (totalSuccess / totalRequests) * 100 : 0;
|
||||
});
|
||||
|
||||
return { hours, models: topModels, modelData, successRates };
|
||||
}, [data, hourRange]);
|
||||
|
||||
// 获取时间范围标签
|
||||
const hourRangeLabel = useMemo(() => {
|
||||
if (hourRange === 6) return t('monitor.hourly.last_6h');
|
||||
if (hourRange === 12) return t('monitor.hourly.last_12h');
|
||||
return t('monitor.hourly.last_24h');
|
||||
}, [hourRange, t]);
|
||||
|
||||
// 图表数据
|
||||
const chartData = useMemo(() => {
|
||||
const labels = hourlyData.hours.map((hour) => {
|
||||
const date = new Date(hour + ':00:00Z'); // 添加 Z 表示 UTC 时间,确保正确转换为本地时间显示
|
||||
return `${date.getHours()}:00`;
|
||||
});
|
||||
|
||||
// 成功率折线放在最前面
|
||||
const datasets: any[] = [{
|
||||
type: 'line' as const,
|
||||
label: t('monitor.hourly.success_rate'),
|
||||
data: hourlyData.successRates,
|
||||
borderColor: '#4ef0c3',
|
||||
backgroundColor: '#4ef0c3',
|
||||
borderWidth: 2.5,
|
||||
tension: 0.4,
|
||||
yAxisID: 'y1',
|
||||
stack: '',
|
||||
pointRadius: 3,
|
||||
pointBackgroundColor: '#4ef0c3',
|
||||
pointBorderColor: '#4ef0c3',
|
||||
}];
|
||||
|
||||
// 添加模型柱状图
|
||||
hourlyData.models.forEach((model, index) => {
|
||||
datasets.push({
|
||||
type: 'bar' as const,
|
||||
label: model,
|
||||
data: hourlyData.modelData[model],
|
||||
backgroundColor: COLORS[index % COLORS.length],
|
||||
borderColor: COLORS[index % COLORS.length],
|
||||
borderWidth: 1,
|
||||
borderRadius: 4,
|
||||
stack: 'models',
|
||||
yAxisID: 'y',
|
||||
});
|
||||
});
|
||||
|
||||
return { labels, datasets };
|
||||
}, [hourlyData, t]);
|
||||
|
||||
// 图表配置
|
||||
const chartOptions = useMemo(() => ({
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
interaction: {
|
||||
mode: 'index' as const,
|
||||
intersect: false,
|
||||
},
|
||||
plugins: {
|
||||
legend: {
|
||||
display: true,
|
||||
position: 'bottom' as const,
|
||||
labels: {
|
||||
color: isDark ? '#9ca3af' : '#6b7280',
|
||||
usePointStyle: true,
|
||||
padding: 12,
|
||||
font: {
|
||||
size: 11,
|
||||
},
|
||||
generateLabels: (chart: any) => {
|
||||
return chart.data.datasets.map((dataset: any, i: number) => {
|
||||
const isLine = dataset.type === 'line';
|
||||
return {
|
||||
text: dataset.label,
|
||||
fillStyle: dataset.backgroundColor,
|
||||
strokeStyle: dataset.borderColor,
|
||||
lineWidth: 0,
|
||||
hidden: !chart.isDatasetVisible(i),
|
||||
datasetIndex: i,
|
||||
pointStyle: isLine ? 'circle' : 'rect',
|
||||
};
|
||||
});
|
||||
},
|
||||
},
|
||||
},
|
||||
tooltip: {
|
||||
backgroundColor: isDark ? '#374151' : '#ffffff',
|
||||
titleColor: isDark ? '#f3f4f6' : '#111827',
|
||||
bodyColor: isDark ? '#d1d5db' : '#4b5563',
|
||||
borderColor: isDark ? '#4b5563' : '#e5e7eb',
|
||||
borderWidth: 1,
|
||||
padding: 12,
|
||||
},
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
stacked: true,
|
||||
grid: {
|
||||
color: isDark ? 'rgba(255, 255, 255, 0.06)' : 'rgba(0, 0, 0, 0.06)',
|
||||
},
|
||||
ticks: {
|
||||
color: isDark ? '#9ca3af' : '#6b7280',
|
||||
font: {
|
||||
size: 11,
|
||||
},
|
||||
},
|
||||
},
|
||||
y: {
|
||||
stacked: true,
|
||||
position: 'left' as const,
|
||||
grid: {
|
||||
color: isDark ? 'rgba(255, 255, 255, 0.06)' : 'rgba(0, 0, 0, 0.06)',
|
||||
},
|
||||
ticks: {
|
||||
color: isDark ? '#9ca3af' : '#6b7280',
|
||||
font: {
|
||||
size: 11,
|
||||
},
|
||||
},
|
||||
title: {
|
||||
display: true,
|
||||
text: t('monitor.hourly.requests'),
|
||||
color: isDark ? '#9ca3af' : '#6b7280',
|
||||
font: {
|
||||
size: 11,
|
||||
},
|
||||
},
|
||||
},
|
||||
y1: {
|
||||
position: 'right' as const,
|
||||
min: 0,
|
||||
max: 100,
|
||||
grid: {
|
||||
drawOnChartArea: false,
|
||||
},
|
||||
ticks: {
|
||||
color: isDark ? '#9ca3af' : '#6b7280',
|
||||
font: {
|
||||
size: 11,
|
||||
},
|
||||
callback: (value: string | number) => `${value}%`,
|
||||
},
|
||||
title: {
|
||||
display: true,
|
||||
text: t('monitor.hourly.success_rate'),
|
||||
color: isDark ? '#9ca3af' : '#6b7280',
|
||||
font: {
|
||||
size: 11,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}), [isDark, t]);
|
||||
|
||||
return (
|
||||
<div className={styles.chartCard}>
|
||||
<div className={styles.chartHeader}>
|
||||
<div>
|
||||
<h3 className={styles.chartTitle}>{t('monitor.hourly_model.title')}</h3>
|
||||
<p className={styles.chartSubtitle}>
|
||||
{hourRangeLabel}
|
||||
</p>
|
||||
</div>
|
||||
<div className={styles.chartControls}>
|
||||
<button
|
||||
className={`${styles.chartControlBtn} ${hourRange === 6 ? styles.active : ''}`}
|
||||
onClick={() => setHourRange(6)}
|
||||
>
|
||||
{t('monitor.hourly.last_6h')}
|
||||
</button>
|
||||
<button
|
||||
className={`${styles.chartControlBtn} ${hourRange === 12 ? styles.active : ''}`}
|
||||
onClick={() => setHourRange(12)}
|
||||
>
|
||||
{t('monitor.hourly.last_12h')}
|
||||
</button>
|
||||
<button
|
||||
className={`${styles.chartControlBtn} ${hourRange === 24 ? styles.active : ''}`}
|
||||
onClick={() => setHourRange(24)}
|
||||
>
|
||||
{t('monitor.hourly.last_24h')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.chartContent}>
|
||||
{loading || hourlyData.hours.length === 0 ? (
|
||||
<div className={styles.chartEmpty}>
|
||||
{loading ? t('common.loading') : t('monitor.no_data')}
|
||||
</div>
|
||||
) : (
|
||||
<Chart type="bar" data={chartData} options={chartOptions} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
274
src/components/monitor/HourlyTokenChart.tsx
Normal file
@@ -0,0 +1,274 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Chart } from 'react-chartjs-2';
|
||||
import type { UsageData } from '@/pages/MonitorPage';
|
||||
import styles from '@/pages/MonitorPage.module.scss';
|
||||
|
||||
interface HourlyTokenChartProps {
|
||||
data: UsageData | null;
|
||||
loading: boolean;
|
||||
isDark: boolean;
|
||||
}
|
||||
|
||||
type HourRange = 6 | 12 | 24;
|
||||
|
||||
export function HourlyTokenChart({ data, loading, isDark }: HourlyTokenChartProps) {
|
||||
const { t } = useTranslation();
|
||||
const [hourRange, setHourRange] = useState<HourRange>(12);
|
||||
|
||||
// 按小时聚合 Token 数据
|
||||
const hourlyData = useMemo(() => {
|
||||
if (!data?.apis) return { hours: [], totalTokens: [], inputTokens: [], outputTokens: [], reasoningTokens: [], cachedTokens: [] };
|
||||
|
||||
const now = new Date();
|
||||
let cutoffTime: Date;
|
||||
let hoursCount: number;
|
||||
|
||||
cutoffTime = new Date(now.getTime() - hourRange * 60 * 60 * 1000);
|
||||
cutoffTime.setMinutes(0, 0, 0);
|
||||
hoursCount = hourRange + 1;
|
||||
|
||||
// 生成所有小时的时间点
|
||||
const allHours: string[] = [];
|
||||
for (let i = 0; i < hoursCount; i++) {
|
||||
const hourTime = new Date(cutoffTime.getTime() + i * 60 * 60 * 1000);
|
||||
const hourKey = hourTime.toISOString().slice(0, 13); // YYYY-MM-DDTHH
|
||||
allHours.push(hourKey);
|
||||
}
|
||||
|
||||
// 初始化所有小时的数据为0
|
||||
const hourlyStats: Record<string, {
|
||||
total: number;
|
||||
input: number;
|
||||
output: number;
|
||||
reasoning: number;
|
||||
cached: number;
|
||||
}> = {};
|
||||
allHours.forEach((hour) => {
|
||||
hourlyStats[hour] = { total: 0, input: 0, output: 0, reasoning: 0, cached: 0 };
|
||||
});
|
||||
|
||||
// 收集每小时的 Token 数据(只统计成功请求)
|
||||
Object.values(data.apis).forEach((apiData) => {
|
||||
Object.values(apiData.models).forEach((modelData) => {
|
||||
modelData.details.forEach((detail) => {
|
||||
// 跳过失败请求,失败请求的 Token 数据不准确
|
||||
if (detail.failed) return;
|
||||
|
||||
const timestamp = new Date(detail.timestamp);
|
||||
if (timestamp < cutoffTime) return;
|
||||
|
||||
const hourKey = timestamp.toISOString().slice(0, 13); // YYYY-MM-DDTHH
|
||||
if (!hourlyStats[hourKey]) {
|
||||
hourlyStats[hourKey] = { total: 0, input: 0, output: 0, reasoning: 0, cached: 0 };
|
||||
}
|
||||
hourlyStats[hourKey].total += detail.tokens.total_tokens || 0;
|
||||
hourlyStats[hourKey].input += detail.tokens.input_tokens || 0;
|
||||
hourlyStats[hourKey].output += detail.tokens.output_tokens || 0;
|
||||
hourlyStats[hourKey].reasoning += detail.tokens.reasoning_tokens || 0;
|
||||
hourlyStats[hourKey].cached += detail.tokens.cached_tokens || 0;
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// 获取排序后的小时列表
|
||||
const hours = allHours.sort();
|
||||
|
||||
return {
|
||||
hours,
|
||||
totalTokens: hours.map((h) => (hourlyStats[h]?.total || 0) / 1000),
|
||||
inputTokens: hours.map((h) => (hourlyStats[h]?.input || 0) / 1000),
|
||||
outputTokens: hours.map((h) => (hourlyStats[h]?.output || 0) / 1000),
|
||||
reasoningTokens: hours.map((h) => (hourlyStats[h]?.reasoning || 0) / 1000),
|
||||
cachedTokens: hours.map((h) => (hourlyStats[h]?.cached || 0) / 1000),
|
||||
};
|
||||
}, [data, hourRange]);
|
||||
|
||||
// 获取时间范围标签
|
||||
const hourRangeLabel = useMemo(() => {
|
||||
if (hourRange === 6) return t('monitor.hourly.last_6h');
|
||||
if (hourRange === 12) return t('monitor.hourly.last_12h');
|
||||
return t('monitor.hourly.last_24h');
|
||||
}, [hourRange, t]);
|
||||
|
||||
// 图表数据
|
||||
const chartData = useMemo(() => {
|
||||
const labels = hourlyData.hours.map((hour) => {
|
||||
const date = new Date(hour + ':00:00Z'); // 添加 Z 表示 UTC 时间,确保正确转换为本地时间显示
|
||||
return `${date.getHours()}:00`;
|
||||
});
|
||||
|
||||
return {
|
||||
labels,
|
||||
datasets: [
|
||||
{
|
||||
type: 'line' as const,
|
||||
label: t('monitor.hourly_token.input'),
|
||||
data: hourlyData.inputTokens,
|
||||
borderColor: '#22c55e',
|
||||
backgroundColor: '#22c55e',
|
||||
borderWidth: 2,
|
||||
tension: 0.4,
|
||||
yAxisID: 'y',
|
||||
order: 0,
|
||||
pointRadius: 3,
|
||||
pointBackgroundColor: '#22c55e',
|
||||
},
|
||||
{
|
||||
type: 'line' as const,
|
||||
label: t('monitor.hourly_token.output'),
|
||||
data: hourlyData.outputTokens,
|
||||
borderColor: '#f97316',
|
||||
backgroundColor: '#f97316',
|
||||
borderWidth: 2,
|
||||
tension: 0.4,
|
||||
yAxisID: 'y',
|
||||
order: 0,
|
||||
pointRadius: 3,
|
||||
pointBackgroundColor: '#f97316',
|
||||
},
|
||||
{
|
||||
type: 'bar' as const,
|
||||
label: t('monitor.hourly_token.total'),
|
||||
data: hourlyData.totalTokens,
|
||||
backgroundColor: 'rgba(59, 130, 246, 0.6)',
|
||||
borderColor: 'rgba(59, 130, 246, 0.6)',
|
||||
borderWidth: 1,
|
||||
borderRadius: 4,
|
||||
yAxisID: 'y',
|
||||
order: 1,
|
||||
},
|
||||
],
|
||||
};
|
||||
}, [hourlyData, t]);
|
||||
|
||||
// 图表配置
|
||||
const chartOptions = useMemo(() => ({
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
interaction: {
|
||||
mode: 'index' as const,
|
||||
intersect: false,
|
||||
},
|
||||
plugins: {
|
||||
legend: {
|
||||
display: true,
|
||||
position: 'bottom' as const,
|
||||
labels: {
|
||||
color: isDark ? '#9ca3af' : '#6b7280',
|
||||
usePointStyle: true,
|
||||
padding: 12,
|
||||
font: {
|
||||
size: 11,
|
||||
},
|
||||
generateLabels: (chart: any) => {
|
||||
return chart.data.datasets.map((dataset: any, i: number) => {
|
||||
const isLine = dataset.type === 'line';
|
||||
return {
|
||||
text: dataset.label,
|
||||
fillStyle: dataset.backgroundColor,
|
||||
strokeStyle: dataset.borderColor,
|
||||
lineWidth: 0,
|
||||
hidden: !chart.isDatasetVisible(i),
|
||||
datasetIndex: i,
|
||||
pointStyle: isLine ? 'circle' : 'rect',
|
||||
};
|
||||
});
|
||||
},
|
||||
},
|
||||
},
|
||||
tooltip: {
|
||||
backgroundColor: isDark ? '#374151' : '#ffffff',
|
||||
titleColor: isDark ? '#f3f4f6' : '#111827',
|
||||
bodyColor: isDark ? '#d1d5db' : '#4b5563',
|
||||
borderColor: isDark ? '#4b5563' : '#e5e7eb',
|
||||
borderWidth: 1,
|
||||
padding: 12,
|
||||
callbacks: {
|
||||
label: (context: any) => {
|
||||
const label = context.dataset.label || '';
|
||||
const value = context.raw;
|
||||
return `${label}: ${value.toFixed(1)}K`;
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
grid: {
|
||||
color: isDark ? 'rgba(255, 255, 255, 0.06)' : 'rgba(0, 0, 0, 0.06)',
|
||||
},
|
||||
ticks: {
|
||||
color: isDark ? '#9ca3af' : '#6b7280',
|
||||
font: {
|
||||
size: 11,
|
||||
},
|
||||
},
|
||||
},
|
||||
y: {
|
||||
position: 'left' as const,
|
||||
grid: {
|
||||
color: isDark ? 'rgba(255, 255, 255, 0.06)' : 'rgba(0, 0, 0, 0.06)',
|
||||
},
|
||||
ticks: {
|
||||
color: isDark ? '#9ca3af' : '#6b7280',
|
||||
font: {
|
||||
size: 11,
|
||||
},
|
||||
callback: (value: string | number) => `${value}K`,
|
||||
},
|
||||
title: {
|
||||
display: true,
|
||||
text: 'Tokens (K)',
|
||||
color: isDark ? '#9ca3af' : '#6b7280',
|
||||
font: {
|
||||
size: 11,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}), [isDark]);
|
||||
|
||||
return (
|
||||
<div className={styles.chartCard}>
|
||||
<div className={styles.chartHeader}>
|
||||
<div>
|
||||
<h3 className={styles.chartTitle}>{t('monitor.hourly_token.title')}</h3>
|
||||
<p className={styles.chartSubtitle}>
|
||||
{hourRangeLabel}
|
||||
</p>
|
||||
</div>
|
||||
<div className={styles.chartControls}>
|
||||
<button
|
||||
className={`${styles.chartControlBtn} ${hourRange === 6 ? styles.active : ''}`}
|
||||
onClick={() => setHourRange(6)}
|
||||
>
|
||||
{t('monitor.hourly.last_6h')}
|
||||
</button>
|
||||
<button
|
||||
className={`${styles.chartControlBtn} ${hourRange === 12 ? styles.active : ''}`}
|
||||
onClick={() => setHourRange(12)}
|
||||
>
|
||||
{t('monitor.hourly.last_12h')}
|
||||
</button>
|
||||
<button
|
||||
className={`${styles.chartControlBtn} ${hourRange === 24 ? styles.active : ''}`}
|
||||
onClick={() => setHourRange(24)}
|
||||
>
|
||||
{t('monitor.hourly.last_24h')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.chartContent}>
|
||||
{loading || hourlyData.hours.length === 0 ? (
|
||||
<div className={styles.chartEmpty}>
|
||||
{loading ? t('common.loading') : t('monitor.no_data')}
|
||||
</div>
|
||||
) : (
|
||||
<Chart type="bar" data={chartData} options={chartOptions} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
201
src/components/monitor/KpiCards.tsx
Normal file
@@ -0,0 +1,201 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { UsageData } from '@/pages/MonitorPage';
|
||||
import styles from '@/pages/MonitorPage.module.scss';
|
||||
|
||||
interface KpiCardsProps {
|
||||
data: UsageData | null;
|
||||
loading: boolean;
|
||||
timeRange: number;
|
||||
}
|
||||
|
||||
// 格式化数字
|
||||
function formatNumber(num: number): string {
|
||||
if (num >= 1000000000) {
|
||||
return (num / 1000000000).toFixed(2) + 'B';
|
||||
}
|
||||
if (num >= 1000000) {
|
||||
return (num / 1000000).toFixed(2) + 'M';
|
||||
}
|
||||
if (num >= 1000) {
|
||||
return (num / 1000).toFixed(2) + 'K';
|
||||
}
|
||||
return num.toLocaleString();
|
||||
}
|
||||
|
||||
export function KpiCards({ data, loading, timeRange }: KpiCardsProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
// 计算统计数据
|
||||
const stats = useMemo(() => {
|
||||
if (!data?.apis) {
|
||||
return {
|
||||
totalRequests: 0,
|
||||
successRequests: 0,
|
||||
failedRequests: 0,
|
||||
successRate: 0,
|
||||
totalTokens: 0,
|
||||
inputTokens: 0,
|
||||
outputTokens: 0,
|
||||
reasoningTokens: 0,
|
||||
cachedTokens: 0,
|
||||
avgTpm: 0,
|
||||
avgRpm: 0,
|
||||
avgRpd: 0,
|
||||
};
|
||||
}
|
||||
|
||||
let totalRequests = 0;
|
||||
let successRequests = 0;
|
||||
let failedRequests = 0;
|
||||
let totalTokens = 0;
|
||||
let inputTokens = 0;
|
||||
let outputTokens = 0;
|
||||
let reasoningTokens = 0;
|
||||
let cachedTokens = 0;
|
||||
|
||||
// 收集所有时间戳用于计算 TPM/RPM
|
||||
const timestamps: number[] = [];
|
||||
|
||||
Object.values(data.apis).forEach((apiData) => {
|
||||
Object.values(apiData.models).forEach((modelData) => {
|
||||
modelData.details.forEach((detail) => {
|
||||
totalRequests++;
|
||||
if (detail.failed) {
|
||||
failedRequests++;
|
||||
} else {
|
||||
successRequests++;
|
||||
}
|
||||
|
||||
totalTokens += detail.tokens.total_tokens || 0;
|
||||
inputTokens += detail.tokens.input_tokens || 0;
|
||||
outputTokens += detail.tokens.output_tokens || 0;
|
||||
reasoningTokens += detail.tokens.reasoning_tokens || 0;
|
||||
cachedTokens += detail.tokens.cached_tokens || 0;
|
||||
|
||||
timestamps.push(new Date(detail.timestamp).getTime());
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
const successRate = totalRequests > 0 ? (successRequests / totalRequests) * 100 : 0;
|
||||
|
||||
// 计算 TPM 和 RPM(基于实际时间跨度)
|
||||
let avgTpm = 0;
|
||||
let avgRpm = 0;
|
||||
let avgRpd = 0;
|
||||
|
||||
if (timestamps.length > 0) {
|
||||
const minTime = Math.min(...timestamps);
|
||||
const maxTime = Math.max(...timestamps);
|
||||
const timeSpanMinutes = Math.max((maxTime - minTime) / (1000 * 60), 1);
|
||||
const timeSpanDays = Math.max(timeSpanMinutes / (60 * 24), 1);
|
||||
|
||||
avgTpm = Math.round(totalTokens / timeSpanMinutes);
|
||||
avgRpm = Math.round(totalRequests / timeSpanMinutes * 10) / 10;
|
||||
avgRpd = Math.round(totalRequests / timeSpanDays);
|
||||
}
|
||||
|
||||
return {
|
||||
totalRequests,
|
||||
successRequests,
|
||||
failedRequests,
|
||||
successRate,
|
||||
totalTokens,
|
||||
inputTokens,
|
||||
outputTokens,
|
||||
reasoningTokens,
|
||||
cachedTokens,
|
||||
avgTpm,
|
||||
avgRpm,
|
||||
avgRpd,
|
||||
};
|
||||
}, [data]);
|
||||
|
||||
const timeRangeLabel = timeRange === 1
|
||||
? t('monitor.today')
|
||||
: t('monitor.last_n_days', { n: timeRange });
|
||||
|
||||
return (
|
||||
<div className={styles.kpiGrid}>
|
||||
{/* 请求数 */}
|
||||
<div className={styles.kpiCard}>
|
||||
<div className={styles.kpiTitle}>
|
||||
<span className={styles.kpiLabel}>{t('monitor.kpi.requests')}</span>
|
||||
<span className={styles.kpiTag}>{timeRangeLabel}</span>
|
||||
</div>
|
||||
<div className={styles.kpiValue}>
|
||||
{loading ? '--' : formatNumber(stats.totalRequests)}
|
||||
</div>
|
||||
<div className={styles.kpiMeta}>
|
||||
<span className={styles.kpiSuccess}>
|
||||
{t('monitor.kpi.success')}: {loading ? '--' : stats.successRequests.toLocaleString()}
|
||||
</span>
|
||||
<span className={styles.kpiFailure}>
|
||||
{t('monitor.kpi.failed')}: {loading ? '--' : stats.failedRequests.toLocaleString()}
|
||||
</span>
|
||||
<span>
|
||||
{t('monitor.kpi.rate')}: {loading ? '--' : stats.successRate.toFixed(1)}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tokens */}
|
||||
<div className={`${styles.kpiCard} ${styles.green}`}>
|
||||
<div className={styles.kpiTitle}>
|
||||
<span className={styles.kpiLabel}>{t('monitor.kpi.tokens')}</span>
|
||||
<span className={styles.kpiTag}>{timeRangeLabel}</span>
|
||||
</div>
|
||||
<div className={styles.kpiValue}>
|
||||
{loading ? '--' : formatNumber(stats.totalTokens)}
|
||||
</div>
|
||||
<div className={styles.kpiMeta}>
|
||||
<span>{t('monitor.kpi.input')}: {loading ? '--' : formatNumber(stats.inputTokens)}</span>
|
||||
<span>{t('monitor.kpi.output')}: {loading ? '--' : formatNumber(stats.outputTokens)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 平均 TPM */}
|
||||
<div className={`${styles.kpiCard} ${styles.purple}`}>
|
||||
<div className={styles.kpiTitle}>
|
||||
<span className={styles.kpiLabel}>{t('monitor.kpi.avg_tpm')}</span>
|
||||
<span className={styles.kpiTag}>{timeRangeLabel}</span>
|
||||
</div>
|
||||
<div className={styles.kpiValue}>
|
||||
{loading ? '--' : formatNumber(stats.avgTpm)}
|
||||
</div>
|
||||
<div className={styles.kpiMeta}>
|
||||
<span>{t('monitor.kpi.tokens_per_minute')}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 平均 RPM */}
|
||||
<div className={`${styles.kpiCard} ${styles.orange}`}>
|
||||
<div className={styles.kpiTitle}>
|
||||
<span className={styles.kpiLabel}>{t('monitor.kpi.avg_rpm')}</span>
|
||||
<span className={styles.kpiTag}>{timeRangeLabel}</span>
|
||||
</div>
|
||||
<div className={styles.kpiValue}>
|
||||
{loading ? '--' : stats.avgRpm.toFixed(1)}
|
||||
</div>
|
||||
<div className={styles.kpiMeta}>
|
||||
<span>{t('monitor.kpi.requests_per_minute')}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 日均 RPD */}
|
||||
<div className={`${styles.kpiCard} ${styles.cyan}`}>
|
||||
<div className={styles.kpiTitle}>
|
||||
<span className={styles.kpiLabel}>{t('monitor.kpi.avg_rpd')}</span>
|
||||
<span className={styles.kpiTag}>{timeRangeLabel}</span>
|
||||
</div>
|
||||
<div className={styles.kpiValue}>
|
||||
{loading ? '--' : formatNumber(stats.avgRpd)}
|
||||
</div>
|
||||
<div className={styles.kpiMeta}>
|
||||
<span>{t('monitor.kpi.requests_per_day')}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
205
src/components/monitor/ModelDistributionChart.tsx
Normal file
@@ -0,0 +1,205 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Doughnut } from 'react-chartjs-2';
|
||||
import type { UsageData } from '@/pages/MonitorPage';
|
||||
import styles from '@/pages/MonitorPage.module.scss';
|
||||
|
||||
interface ModelDistributionChartProps {
|
||||
data: UsageData | null;
|
||||
loading: boolean;
|
||||
isDark: boolean;
|
||||
timeRange: number;
|
||||
}
|
||||
|
||||
// 颜色调色板
|
||||
const COLORS = [
|
||||
'#3b82f6', // 蓝色
|
||||
'#22c55e', // 绿色
|
||||
'#f97316', // 橙色
|
||||
'#8b5cf6', // 紫色
|
||||
'#ec4899', // 粉色
|
||||
'#06b6d4', // 青色
|
||||
'#eab308', // 黄色
|
||||
'#ef4444', // 红色
|
||||
'#14b8a6', // 青绿
|
||||
'#6366f1', // 靛蓝
|
||||
];
|
||||
|
||||
type ViewMode = 'request' | 'token';
|
||||
|
||||
export function ModelDistributionChart({ data, loading, isDark, timeRange }: ModelDistributionChartProps) {
|
||||
const { t } = useTranslation();
|
||||
const [viewMode, setViewMode] = useState<ViewMode>('request');
|
||||
|
||||
const timeRangeLabel = timeRange === 1
|
||||
? t('monitor.today')
|
||||
: t('monitor.last_n_days', { n: timeRange });
|
||||
|
||||
// 计算模型分布数据
|
||||
const distributionData = useMemo(() => {
|
||||
if (!data?.apis) return [];
|
||||
|
||||
const modelStats: Record<string, { requests: number; tokens: number }> = {};
|
||||
|
||||
Object.values(data.apis).forEach((apiData) => {
|
||||
Object.entries(apiData.models).forEach(([modelName, modelData]) => {
|
||||
if (!modelStats[modelName]) {
|
||||
modelStats[modelName] = { requests: 0, tokens: 0 };
|
||||
}
|
||||
modelData.details.forEach((detail) => {
|
||||
modelStats[modelName].requests++;
|
||||
modelStats[modelName].tokens += detail.tokens.total_tokens || 0;
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// 转换为数组并排序
|
||||
const sorted = Object.entries(modelStats)
|
||||
.map(([name, stats]) => ({
|
||||
name,
|
||||
requests: stats.requests,
|
||||
tokens: stats.tokens,
|
||||
}))
|
||||
.sort((a, b) => {
|
||||
if (viewMode === 'request') {
|
||||
return b.requests - a.requests;
|
||||
}
|
||||
return b.tokens - a.tokens;
|
||||
});
|
||||
|
||||
// 取 Top 10
|
||||
return sorted.slice(0, 10);
|
||||
}, [data, viewMode]);
|
||||
|
||||
// 计算总数
|
||||
const total = useMemo(() => {
|
||||
return distributionData.reduce((sum, item) => {
|
||||
return sum + (viewMode === 'request' ? item.requests : item.tokens);
|
||||
}, 0);
|
||||
}, [distributionData, viewMode]);
|
||||
|
||||
// 图表数据
|
||||
const chartData = useMemo(() => {
|
||||
return {
|
||||
labels: distributionData.map((item) => item.name),
|
||||
datasets: [
|
||||
{
|
||||
data: distributionData.map((item) =>
|
||||
viewMode === 'request' ? item.requests : item.tokens
|
||||
),
|
||||
backgroundColor: COLORS.slice(0, distributionData.length),
|
||||
borderColor: isDark ? '#1f2937' : '#ffffff',
|
||||
borderWidth: 2,
|
||||
},
|
||||
],
|
||||
};
|
||||
}, [distributionData, viewMode, isDark]);
|
||||
|
||||
// 图表配置
|
||||
const chartOptions = useMemo(() => ({
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
cutout: '65%',
|
||||
plugins: {
|
||||
legend: {
|
||||
display: false,
|
||||
},
|
||||
tooltip: {
|
||||
backgroundColor: isDark ? '#374151' : '#ffffff',
|
||||
titleColor: isDark ? '#f3f4f6' : '#111827',
|
||||
bodyColor: isDark ? '#d1d5db' : '#4b5563',
|
||||
borderColor: isDark ? '#4b5563' : '#e5e7eb',
|
||||
borderWidth: 1,
|
||||
padding: 12,
|
||||
callbacks: {
|
||||
label: (context: any) => {
|
||||
const value = context.raw;
|
||||
const percentage = total > 0 ? ((value / total) * 100).toFixed(1) : 0;
|
||||
if (viewMode === 'request') {
|
||||
return `${value.toLocaleString()} ${t('monitor.requests')} (${percentage}%)`;
|
||||
}
|
||||
return `${value.toLocaleString()} tokens (${percentage}%)`;
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}), [isDark, total, viewMode, t]);
|
||||
|
||||
// 格式化数值
|
||||
const formatValue = (value: number) => {
|
||||
if (value >= 1000000) {
|
||||
return (value / 1000000).toFixed(1) + 'M';
|
||||
}
|
||||
if (value >= 1000) {
|
||||
return (value / 1000).toFixed(1) + 'K';
|
||||
}
|
||||
return value.toString();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.chartCard}>
|
||||
<div className={styles.chartHeader}>
|
||||
<div>
|
||||
<h3 className={styles.chartTitle}>{t('monitor.distribution.title')}</h3>
|
||||
<p className={styles.chartSubtitle}>
|
||||
{timeRangeLabel} · {viewMode === 'request' ? t('monitor.distribution.by_requests') : t('monitor.distribution.by_tokens')}
|
||||
{' · Top 10'}
|
||||
</p>
|
||||
</div>
|
||||
<div className={styles.chartControls}>
|
||||
<button
|
||||
className={`${styles.chartControlBtn} ${viewMode === 'request' ? styles.active : ''}`}
|
||||
onClick={() => setViewMode('request')}
|
||||
>
|
||||
{t('monitor.distribution.requests')}
|
||||
</button>
|
||||
<button
|
||||
className={`${styles.chartControlBtn} ${viewMode === 'token' ? styles.active : ''}`}
|
||||
onClick={() => setViewMode('token')}
|
||||
>
|
||||
{t('monitor.distribution.tokens')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loading || distributionData.length === 0 ? (
|
||||
<div className={styles.chartContent}>
|
||||
<div className={styles.chartEmpty}>
|
||||
{loading ? t('common.loading') : t('monitor.no_data')}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className={styles.distributionContent}>
|
||||
<div className={styles.donutWrapper}>
|
||||
<Doughnut data={chartData} options={chartOptions} />
|
||||
<div className={styles.donutCenter}>
|
||||
<div className={styles.donutLabel}>
|
||||
{viewMode === 'request' ? t('monitor.distribution.request_share') : t('monitor.distribution.token_share')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.legendList}>
|
||||
{distributionData.map((item, index) => {
|
||||
const value = viewMode === 'request' ? item.requests : item.tokens;
|
||||
const percentage = total > 0 ? ((value / total) * 100).toFixed(1) : '0';
|
||||
return (
|
||||
<div key={item.name} className={styles.legendItem}>
|
||||
<span
|
||||
className={styles.legendDot}
|
||||
style={{ backgroundColor: COLORS[index] }}
|
||||
/>
|
||||
<span className={styles.legendName} title={item.name}>
|
||||
{item.name}
|
||||
</span>
|
||||
<span className={styles.legendValue}>
|
||||
{formatValue(value)} ({percentage}%)
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
710
src/components/monitor/RequestLogs.tsx
Normal file
@@ -0,0 +1,710 @@
|
||||
import { useMemo, useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useVirtualizer } from '@tanstack/react-virtual';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
import { usageApi, authFilesApi } from '@/services/api';
|
||||
import { useDisableModel } from '@/hooks';
|
||||
import { TimeRangeSelector, formatTimeRangeCaption, type TimeRange } from './TimeRangeSelector';
|
||||
import { DisableModelModal } from './DisableModelModal';
|
||||
import { UnsupportedDisableModal } from './UnsupportedDisableModal';
|
||||
import {
|
||||
maskSecret,
|
||||
formatProviderDisplay,
|
||||
formatTimestamp,
|
||||
getRateClassName,
|
||||
getProviderDisplayParts,
|
||||
type DateRange,
|
||||
} from '@/utils/monitor';
|
||||
import type { UsageData } from '@/pages/MonitorPage';
|
||||
import styles from '@/pages/MonitorPage.module.scss';
|
||||
|
||||
interface RequestLogsProps {
|
||||
data: UsageData | null;
|
||||
loading: boolean;
|
||||
providerMap: Record<string, string>;
|
||||
providerTypeMap: Record<string, string>;
|
||||
apiFilter: string;
|
||||
}
|
||||
|
||||
interface LogEntry {
|
||||
id: string;
|
||||
timestamp: string;
|
||||
timestampMs: number;
|
||||
apiKey: string;
|
||||
model: string;
|
||||
source: string;
|
||||
displayName: string;
|
||||
providerName: string | null;
|
||||
providerType: string;
|
||||
maskedKey: string;
|
||||
failed: boolean;
|
||||
inputTokens: number;
|
||||
outputTokens: number;
|
||||
totalTokens: number;
|
||||
authIndex: string;
|
||||
}
|
||||
|
||||
interface ChannelModelRequest {
|
||||
failed: boolean;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
// 预计算的统计数据缓存
|
||||
interface PrecomputedStats {
|
||||
recentRequests: ChannelModelRequest[];
|
||||
successRate: string;
|
||||
totalCount: number;
|
||||
}
|
||||
|
||||
// 虚拟滚动行高
|
||||
const ROW_HEIGHT = 40;
|
||||
|
||||
export function RequestLogs({ data, loading: parentLoading, providerMap, providerTypeMap, apiFilter }: RequestLogsProps) {
|
||||
const { t } = useTranslation();
|
||||
const [filterApi, setFilterApi] = useState('');
|
||||
const [filterModel, setFilterModel] = useState('');
|
||||
const [filterSource, setFilterSource] = useState('');
|
||||
const [filterStatus, setFilterStatus] = useState<'' | 'success' | 'failed'>('');
|
||||
const [filterProviderType, setFilterProviderType] = useState('');
|
||||
const [autoRefresh, setAutoRefresh] = useState(10);
|
||||
const [countdown, setCountdown] = useState(0);
|
||||
const countdownRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
// 用 ref 存储 fetchLogData,避免作为定时器 useEffect 的依赖
|
||||
const fetchLogDataRef = useRef<() => Promise<void>>(() => Promise.resolve());
|
||||
|
||||
// 虚拟滚动容器 ref
|
||||
const tableContainerRef = useRef<HTMLDivElement>(null);
|
||||
// 固定表头容器 ref
|
||||
const headerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// 同步表头和内容的水平滚动
|
||||
const handleScroll = useCallback(() => {
|
||||
if (tableContainerRef.current && headerRef.current) {
|
||||
headerRef.current.scrollLeft = tableContainerRef.current.scrollLeft;
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 时间范围状态
|
||||
const [timeRange, setTimeRange] = useState<TimeRange>(7);
|
||||
const [customRange, setCustomRange] = useState<DateRange | undefined>();
|
||||
|
||||
// 日志独立数据状态
|
||||
const [logData, setLogData] = useState<UsageData | null>(null);
|
||||
const [logLoading, setLogLoading] = useState(false);
|
||||
const [isFirstLoad, setIsFirstLoad] = useState(true);
|
||||
|
||||
// 认证文件索引到名称的映射
|
||||
const [authIndexMap, setAuthIndexMap] = useState<Record<string, string>>({});
|
||||
|
||||
// 使用禁用模型 Hook
|
||||
const {
|
||||
disableState,
|
||||
unsupportedState,
|
||||
disabling,
|
||||
isModelDisabled,
|
||||
handleDisableClick,
|
||||
handleConfirmDisable,
|
||||
handleCancelDisable,
|
||||
handleCloseUnsupported,
|
||||
} = useDisableModel({ providerMap, providerTypeMap });
|
||||
|
||||
// 处理时间范围变化
|
||||
const handleTimeRangeChange = useCallback((range: TimeRange, custom?: DateRange) => {
|
||||
setTimeRange(range);
|
||||
if (custom) {
|
||||
setCustomRange(custom);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 使用日志独立数据或父组件数据
|
||||
const effectiveData = logData || data;
|
||||
// 只在首次加载且没有数据时显示 loading 状态
|
||||
const showLoading = (parentLoading && isFirstLoad && !effectiveData) || (logLoading && !effectiveData);
|
||||
|
||||
// 当父组件数据加载完成时,标记首次加载完成
|
||||
useEffect(() => {
|
||||
if (!parentLoading && data) {
|
||||
setIsFirstLoad(false);
|
||||
}
|
||||
}, [parentLoading, data]);
|
||||
|
||||
// 加载认证文件映射(authIndex -> 文件名)
|
||||
const loadAuthIndexMap = useCallback(async () => {
|
||||
try {
|
||||
const response = await authFilesApi.list();
|
||||
const files = response?.files || [];
|
||||
const map: Record<string, string> = {};
|
||||
files.forEach((file) => {
|
||||
// 兼容 auth_index 和 authIndex 两种字段名(API 返回的是 auth_index)
|
||||
const rawAuthIndex = (file as Record<string, unknown>)['auth_index'] ?? file.authIndex;
|
||||
if (rawAuthIndex !== undefined && rawAuthIndex !== null) {
|
||||
const authIndexKey = String(rawAuthIndex).trim();
|
||||
if (authIndexKey) {
|
||||
map[authIndexKey] = file.name;
|
||||
}
|
||||
}
|
||||
});
|
||||
setAuthIndexMap(map);
|
||||
} catch (err) {
|
||||
console.warn('Failed to load auth files for index mapping:', err);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 初始加载认证文件映射
|
||||
useEffect(() => {
|
||||
loadAuthIndexMap();
|
||||
}, [loadAuthIndexMap]);
|
||||
|
||||
// 独立获取日志数据
|
||||
const fetchLogData = useCallback(async () => {
|
||||
setLogLoading(true);
|
||||
try {
|
||||
const response = await usageApi.getUsage();
|
||||
const usageData = (response?.usage ?? response) as Record<string, unknown>;
|
||||
|
||||
// 应用时间范围过滤
|
||||
if (usageData?.apis) {
|
||||
const apis = usageData.apis as UsageData['apis'];
|
||||
const now = new Date();
|
||||
let cutoffStart: Date;
|
||||
let cutoffEnd: Date = new Date(now.getTime());
|
||||
cutoffEnd.setHours(23, 59, 59, 999);
|
||||
|
||||
if (timeRange === 'custom' && customRange) {
|
||||
cutoffStart = customRange.start;
|
||||
cutoffEnd = customRange.end;
|
||||
} else if (typeof timeRange === 'number') {
|
||||
cutoffStart = new Date(now.getTime() - timeRange * 24 * 60 * 60 * 1000);
|
||||
cutoffStart.setHours(0, 0, 0, 0);
|
||||
} else {
|
||||
cutoffStart = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
|
||||
cutoffStart.setHours(0, 0, 0, 0);
|
||||
}
|
||||
|
||||
const filtered: UsageData = { apis: {} };
|
||||
|
||||
Object.entries(apis).forEach(([apiKey, apiData]) => {
|
||||
// 如果有 API 过滤器,检查是否匹配
|
||||
if (apiFilter && !apiKey.toLowerCase().includes(apiFilter.toLowerCase())) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!apiData?.models) return;
|
||||
|
||||
const filteredModels: Record<string, { details: UsageData['apis'][string]['models'][string]['details'] }> = {};
|
||||
|
||||
Object.entries(apiData.models).forEach(([modelName, modelData]) => {
|
||||
if (!modelData?.details || !Array.isArray(modelData.details)) return;
|
||||
|
||||
const filteredDetails = modelData.details.filter((detail) => {
|
||||
const timestamp = new Date(detail.timestamp);
|
||||
return timestamp >= cutoffStart && timestamp <= cutoffEnd;
|
||||
});
|
||||
|
||||
if (filteredDetails.length > 0) {
|
||||
filteredModels[modelName] = { details: filteredDetails };
|
||||
}
|
||||
});
|
||||
|
||||
if (Object.keys(filteredModels).length > 0) {
|
||||
filtered.apis[apiKey] = { models: filteredModels };
|
||||
}
|
||||
});
|
||||
|
||||
setLogData(filtered);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('日志刷新失败:', err);
|
||||
} finally {
|
||||
setLogLoading(false);
|
||||
}
|
||||
}, [timeRange, customRange, apiFilter]);
|
||||
|
||||
// 同步 fetchLogData 到 ref,确保定时器始终调用最新版本
|
||||
useEffect(() => {
|
||||
fetchLogDataRef.current = fetchLogData;
|
||||
}, [fetchLogData]);
|
||||
|
||||
// 统一的自动刷新定时器管理
|
||||
useEffect(() => {
|
||||
// 清理旧定时器
|
||||
if (countdownRef.current) {
|
||||
clearInterval(countdownRef.current);
|
||||
countdownRef.current = null;
|
||||
}
|
||||
|
||||
// 禁用自动刷新时
|
||||
if (autoRefresh <= 0) {
|
||||
setCountdown(0);
|
||||
return;
|
||||
}
|
||||
|
||||
// 设置初始倒计时
|
||||
setCountdown(autoRefresh);
|
||||
|
||||
// 创建新定时器
|
||||
countdownRef.current = setInterval(() => {
|
||||
setCountdown((prev) => {
|
||||
if (prev <= 1) {
|
||||
// 倒计时结束,触发刷新并重置倒计时
|
||||
fetchLogDataRef.current();
|
||||
return autoRefresh;
|
||||
}
|
||||
return prev - 1;
|
||||
});
|
||||
}, 1000);
|
||||
|
||||
// 组件卸载或 autoRefresh 变化时清理
|
||||
return () => {
|
||||
if (countdownRef.current) {
|
||||
clearInterval(countdownRef.current);
|
||||
countdownRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [autoRefresh]);
|
||||
|
||||
// 时间范围变化时立即刷新数据
|
||||
useEffect(() => {
|
||||
fetchLogData();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [timeRange, customRange]);
|
||||
|
||||
// 获取倒计时显示文本
|
||||
const getCountdownText = () => {
|
||||
if (logLoading) {
|
||||
return t('monitor.logs.refreshing');
|
||||
}
|
||||
if (autoRefresh === 0) {
|
||||
return t('monitor.logs.manual_refresh');
|
||||
}
|
||||
if (countdown > 0) {
|
||||
return t('monitor.logs.refresh_in_seconds', { seconds: countdown });
|
||||
}
|
||||
return t('monitor.logs.refreshing');
|
||||
};
|
||||
|
||||
// 将数据转换为日志条目
|
||||
const logEntries = useMemo(() => {
|
||||
if (!effectiveData?.apis) return [];
|
||||
|
||||
const entries: LogEntry[] = [];
|
||||
let idCounter = 0;
|
||||
|
||||
Object.entries(effectiveData.apis).forEach(([apiKey, apiData]) => {
|
||||
Object.entries(apiData.models).forEach(([modelName, modelData]) => {
|
||||
modelData.details.forEach((detail) => {
|
||||
const source = detail.source || 'unknown';
|
||||
const { provider, masked } = getProviderDisplayParts(source, providerMap);
|
||||
const displayName = provider ? `${provider} (${masked})` : masked;
|
||||
const timestampMs = detail.timestamp ? new Date(detail.timestamp).getTime() : 0;
|
||||
// 获取提供商类型
|
||||
const providerType = providerTypeMap[source] || '--';
|
||||
entries.push({
|
||||
id: `${idCounter++}`,
|
||||
timestamp: detail.timestamp,
|
||||
timestampMs,
|
||||
apiKey,
|
||||
model: modelName,
|
||||
source,
|
||||
displayName,
|
||||
providerName: provider,
|
||||
providerType,
|
||||
maskedKey: masked,
|
||||
failed: detail.failed,
|
||||
inputTokens: detail.tokens.input_tokens || 0,
|
||||
outputTokens: detail.tokens.output_tokens || 0,
|
||||
totalTokens: detail.tokens.total_tokens || 0,
|
||||
authIndex: detail.auth_index || '',
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// 按时间倒序排序
|
||||
return entries.sort((a, b) => b.timestampMs - a.timestampMs);
|
||||
}, [effectiveData, providerMap, providerTypeMap]);
|
||||
|
||||
// 预计算所有条目的统计数据(一次性计算,避免渲染时重复计算)
|
||||
const precomputedStats = useMemo(() => {
|
||||
const statsMap = new Map<string, PrecomputedStats>();
|
||||
|
||||
// 首先按渠道+模型分组,并按时间排序
|
||||
const channelModelGroups: Record<string, { entry: LogEntry; index: number }[]> = {};
|
||||
|
||||
logEntries.forEach((entry, index) => {
|
||||
const key = `${entry.source}|||${entry.model}`;
|
||||
if (!channelModelGroups[key]) {
|
||||
channelModelGroups[key] = [];
|
||||
}
|
||||
channelModelGroups[key].push({ entry, index });
|
||||
});
|
||||
|
||||
// 对每个分组按时间正序排序(用于计算累计统计)
|
||||
Object.values(channelModelGroups).forEach((group) => {
|
||||
group.sort((a, b) => a.entry.timestampMs - b.entry.timestampMs);
|
||||
});
|
||||
|
||||
// 计算每个条目的统计数据
|
||||
Object.entries(channelModelGroups).forEach(([, group]) => {
|
||||
let successCount = 0;
|
||||
let totalCount = 0;
|
||||
const recentRequests: ChannelModelRequest[] = [];
|
||||
|
||||
group.forEach(({ entry }) => {
|
||||
totalCount++;
|
||||
if (!entry.failed) {
|
||||
successCount++;
|
||||
}
|
||||
|
||||
// 维护最近 10 次请求
|
||||
recentRequests.push({ failed: entry.failed, timestamp: entry.timestampMs });
|
||||
if (recentRequests.length > 10) {
|
||||
recentRequests.shift();
|
||||
}
|
||||
|
||||
// 计算成功率
|
||||
const successRate = totalCount > 0 ? ((successCount / totalCount) * 100).toFixed(1) : '0.0';
|
||||
|
||||
// 存储该条目的统计数据
|
||||
statsMap.set(entry.id, {
|
||||
recentRequests: [...recentRequests],
|
||||
successRate,
|
||||
totalCount,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
return statsMap;
|
||||
}, [logEntries]);
|
||||
|
||||
// 获取筛选选项
|
||||
const { apis, models, sources, providerTypes } = useMemo(() => {
|
||||
const apiSet = new Set<string>();
|
||||
const modelSet = new Set<string>();
|
||||
const sourceSet = new Set<string>();
|
||||
const providerTypeSet = new Set<string>();
|
||||
|
||||
logEntries.forEach((entry) => {
|
||||
apiSet.add(entry.apiKey);
|
||||
modelSet.add(entry.model);
|
||||
sourceSet.add(entry.source);
|
||||
if (entry.providerType && entry.providerType !== '--') {
|
||||
providerTypeSet.add(entry.providerType);
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
apis: Array.from(apiSet).sort(),
|
||||
models: Array.from(modelSet).sort(),
|
||||
sources: Array.from(sourceSet).sort(),
|
||||
providerTypes: Array.from(providerTypeSet).sort(),
|
||||
};
|
||||
}, [logEntries]);
|
||||
|
||||
// 过滤后的数据
|
||||
const filteredEntries = useMemo(() => {
|
||||
return logEntries.filter((entry) => {
|
||||
if (filterApi && entry.apiKey !== filterApi) return false;
|
||||
if (filterModel && entry.model !== filterModel) return false;
|
||||
if (filterSource && entry.source !== filterSource) return false;
|
||||
if (filterStatus === 'success' && entry.failed) return false;
|
||||
if (filterStatus === 'failed' && !entry.failed) return false;
|
||||
if (filterProviderType && entry.providerType !== filterProviderType) return false;
|
||||
return true;
|
||||
});
|
||||
}, [logEntries, filterApi, filterModel, filterSource, filterStatus, filterProviderType]);
|
||||
|
||||
// 虚拟滚动配置
|
||||
const rowVirtualizer = useVirtualizer({
|
||||
count: filteredEntries.length,
|
||||
getScrollElement: () => tableContainerRef.current,
|
||||
estimateSize: () => ROW_HEIGHT,
|
||||
overscan: 10, // 预渲染上下各 10 行
|
||||
});
|
||||
|
||||
// 格式化数字
|
||||
const formatNumber = (num: number) => {
|
||||
return num.toLocaleString('zh-CN');
|
||||
};
|
||||
|
||||
// 获取预计算的统计数据
|
||||
const getStats = (entry: LogEntry): PrecomputedStats => {
|
||||
return precomputedStats.get(entry.id) || {
|
||||
recentRequests: [],
|
||||
successRate: '0.0',
|
||||
totalCount: 0,
|
||||
};
|
||||
};
|
||||
|
||||
// 渲染单行
|
||||
const renderRow = (entry: LogEntry) => {
|
||||
const stats = getStats(entry);
|
||||
const rateValue = parseFloat(stats.successRate);
|
||||
const disabled = isModelDisabled(entry.source, entry.model);
|
||||
// 将 authIndex 映射为文件名
|
||||
const authDisplayName = entry.authIndex ? (authIndexMap[entry.authIndex] || entry.authIndex) : '-';
|
||||
|
||||
return (
|
||||
<>
|
||||
<td title={authDisplayName}>
|
||||
{authDisplayName}
|
||||
</td>
|
||||
<td title={entry.apiKey}>
|
||||
{maskSecret(entry.apiKey)}
|
||||
</td>
|
||||
<td>{entry.providerType}</td>
|
||||
<td title={entry.model}>
|
||||
{entry.model}
|
||||
</td>
|
||||
<td title={entry.source}>
|
||||
{entry.providerName ? (
|
||||
<>
|
||||
<span className={styles.channelName}>{entry.providerName}</span>
|
||||
<span className={styles.channelSecret}> ({entry.maskedKey})</span>
|
||||
</>
|
||||
) : (
|
||||
entry.maskedKey
|
||||
)}
|
||||
</td>
|
||||
<td>
|
||||
<span className={`${styles.statusPill} ${entry.failed ? styles.failed : styles.success}`}>
|
||||
{entry.failed ? t('monitor.logs.failed') : t('monitor.logs.success')}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<div className={styles.statusBars}>
|
||||
{stats.recentRequests.map((req, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
className={`${styles.statusBar} ${req.failed ? styles.failure : styles.success}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</td>
|
||||
<td className={getRateClassName(rateValue, styles)}>
|
||||
{stats.successRate}%
|
||||
</td>
|
||||
<td>{formatNumber(stats.totalCount)}</td>
|
||||
<td>{formatNumber(entry.inputTokens)}</td>
|
||||
<td>{formatNumber(entry.outputTokens)}</td>
|
||||
<td>{formatNumber(entry.totalTokens)}</td>
|
||||
<td>{formatTimestamp(entry.timestamp)}</td>
|
||||
<td>
|
||||
{entry.source && entry.source !== '-' && entry.source !== 'unknown' ? (
|
||||
disabled ? (
|
||||
<span className={styles.disabledLabel}>
|
||||
{t('monitor.logs.disabled')}
|
||||
</span>
|
||||
) : (
|
||||
<button
|
||||
className={styles.disableBtn}
|
||||
title={t('monitor.logs.disable_model')}
|
||||
onClick={() => handleDisableClick(entry.source, entry.model)}
|
||||
>
|
||||
{t('monitor.logs.disable')}
|
||||
</button>
|
||||
)
|
||||
) : (
|
||||
'-'
|
||||
)}
|
||||
</td>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card
|
||||
title={t('monitor.logs.title')}
|
||||
subtitle={
|
||||
<span>
|
||||
{formatTimeRangeCaption(timeRange, customRange, t)} · {t('monitor.logs.total_count', { count: logEntries.length })}
|
||||
<span style={{ color: 'var(--text-tertiary)' }}> · {t('monitor.logs.scroll_hint')}</span>
|
||||
</span>
|
||||
}
|
||||
extra={
|
||||
<TimeRangeSelector
|
||||
value={timeRange}
|
||||
onChange={handleTimeRangeChange}
|
||||
customRange={customRange}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{/* 筛选器 */}
|
||||
<div className={styles.logFilters}>
|
||||
<select
|
||||
className={styles.logSelect}
|
||||
value={filterApi}
|
||||
onChange={(e) => setFilterApi(e.target.value)}
|
||||
>
|
||||
<option value="">{t('monitor.logs.all_apis')}</option>
|
||||
{apis.map((api) => (
|
||||
<option key={api} value={api}>
|
||||
{maskSecret(api)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<select
|
||||
className={styles.logSelect}
|
||||
value={filterProviderType}
|
||||
onChange={(e) => setFilterProviderType(e.target.value)}
|
||||
>
|
||||
<option value="">{t('monitor.logs.all_provider_types')}</option>
|
||||
{providerTypes.map((type) => (
|
||||
<option key={type} value={type}>{type}</option>
|
||||
))}
|
||||
</select>
|
||||
<select
|
||||
className={styles.logSelect}
|
||||
value={filterModel}
|
||||
onChange={(e) => setFilterModel(e.target.value)}
|
||||
>
|
||||
<option value="">{t('monitor.logs.all_models')}</option>
|
||||
{models.map((model) => (
|
||||
<option key={model} value={model}>{model}</option>
|
||||
))}
|
||||
</select>
|
||||
<select
|
||||
className={styles.logSelect}
|
||||
value={filterSource}
|
||||
onChange={(e) => setFilterSource(e.target.value)}
|
||||
>
|
||||
<option value="">{t('monitor.logs.all_sources')}</option>
|
||||
{sources.map((source) => (
|
||||
<option key={source} value={source}>
|
||||
{formatProviderDisplay(source, providerMap)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<select
|
||||
className={styles.logSelect}
|
||||
value={filterStatus}
|
||||
onChange={(e) => setFilterStatus(e.target.value as '' | 'success' | 'failed')}
|
||||
>
|
||||
<option value="">{t('monitor.logs.all_status')}</option>
|
||||
<option value="success">{t('monitor.logs.success')}</option>
|
||||
<option value="failed">{t('monitor.logs.failed')}</option>
|
||||
</select>
|
||||
|
||||
<span className={styles.logLastUpdate}>
|
||||
{getCountdownText()}
|
||||
</span>
|
||||
|
||||
<select
|
||||
className={styles.logSelect}
|
||||
value={autoRefresh}
|
||||
onChange={(e) => setAutoRefresh(Number(e.target.value))}
|
||||
>
|
||||
<option value="0">{t('monitor.logs.manual_refresh')}</option>
|
||||
<option value="5">{t('monitor.logs.refresh_5s')}</option>
|
||||
<option value="10">{t('monitor.logs.refresh_10s')}</option>
|
||||
<option value="15">{t('monitor.logs.refresh_15s')}</option>
|
||||
<option value="30">{t('monitor.logs.refresh_30s')}</option>
|
||||
<option value="60">{t('monitor.logs.refresh_60s')}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* 虚拟滚动表格 */}
|
||||
<div className={styles.tableWrapper}>
|
||||
{showLoading ? (
|
||||
<div className={styles.emptyState}>{t('common.loading')}</div>
|
||||
) : filteredEntries.length === 0 ? (
|
||||
<div className={styles.emptyState}>{t('monitor.no_data')}</div>
|
||||
) : (
|
||||
<>
|
||||
{/* 固定表头 */}
|
||||
<div ref={headerRef} className={styles.stickyHeader}>
|
||||
<table className={`${styles.table} ${styles.virtualTable}`}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{t('monitor.logs.header_auth')}</th>
|
||||
<th>{t('monitor.logs.header_api')}</th>
|
||||
<th>{t('monitor.logs.header_request_type')}</th>
|
||||
<th>{t('monitor.logs.header_model')}</th>
|
||||
<th>{t('monitor.logs.header_source')}</th>
|
||||
<th>{t('monitor.logs.header_status')}</th>
|
||||
<th>{t('monitor.logs.header_recent')}</th>
|
||||
<th>{t('monitor.logs.header_rate')}</th>
|
||||
<th>{t('monitor.logs.header_count')}</th>
|
||||
<th>{t('monitor.logs.header_input')}</th>
|
||||
<th>{t('monitor.logs.header_output')}</th>
|
||||
<th>{t('monitor.logs.header_total')}</th>
|
||||
<th>{t('monitor.logs.header_time')}</th>
|
||||
<th>{t('monitor.logs.header_actions')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* 虚拟滚动容器 */}
|
||||
<div
|
||||
ref={tableContainerRef}
|
||||
className={styles.virtualScrollContainer}
|
||||
style={{
|
||||
height: 'calc(100vh - 420px)',
|
||||
minHeight: '360px',
|
||||
overflow: 'auto',
|
||||
}}
|
||||
onScroll={handleScroll}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
height: `${rowVirtualizer.getTotalSize()}px`,
|
||||
width: '100%',
|
||||
position: 'relative',
|
||||
}}
|
||||
>
|
||||
<table className={`${styles.table} ${styles.virtualTable}`}>
|
||||
<tbody>
|
||||
{rowVirtualizer.getVirtualItems().map((virtualRow) => {
|
||||
const entry = filteredEntries[virtualRow.index];
|
||||
return (
|
||||
<tr
|
||||
key={entry.id}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: '100%',
|
||||
height: `${virtualRow.size}px`,
|
||||
transform: `translateY(${virtualRow.start}px)`,
|
||||
display: 'table',
|
||||
tableLayout: 'fixed',
|
||||
}}
|
||||
>
|
||||
{renderRow(entry)}
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 统计信息 */}
|
||||
{filteredEntries.length > 0 && (
|
||||
<div style={{ textAlign: 'center', fontSize: 12, color: 'var(--text-tertiary)', marginTop: 8 }}>
|
||||
{t('monitor.logs.total_count', { count: filteredEntries.length })}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* 禁用确认弹窗 */}
|
||||
<DisableModelModal
|
||||
disableState={disableState}
|
||||
disabling={disabling}
|
||||
onConfirm={handleConfirmDisable}
|
||||
onCancel={handleCancelDisable}
|
||||
/>
|
||||
|
||||
{/* 不支持自动禁用提示弹窗 */}
|
||||
<UnsupportedDisableModal
|
||||
state={unsupportedState}
|
||||
onClose={handleCloseUnsupported}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
158
src/components/monitor/TimeRangeSelector.tsx
Normal file
@@ -0,0 +1,158 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import styles from '@/pages/MonitorPage.module.scss';
|
||||
|
||||
export type TimeRange = 1 | 7 | 14 | 30 | 'custom';
|
||||
|
||||
interface DateRange {
|
||||
start: Date;
|
||||
end: Date;
|
||||
}
|
||||
|
||||
interface TimeRangeSelectorProps {
|
||||
value: TimeRange;
|
||||
onChange: (range: TimeRange, customRange?: DateRange) => void;
|
||||
customRange?: DateRange;
|
||||
}
|
||||
|
||||
export function TimeRangeSelector({ value, onChange, customRange }: TimeRangeSelectorProps) {
|
||||
const { t } = useTranslation();
|
||||
const [showCustom, setShowCustom] = useState(value === 'custom');
|
||||
const [startDate, setStartDate] = useState(() => {
|
||||
if (customRange?.start) {
|
||||
return formatDateForInput(customRange.start);
|
||||
}
|
||||
const date = new Date();
|
||||
date.setDate(date.getDate() - 7);
|
||||
return formatDateForInput(date);
|
||||
});
|
||||
const [endDate, setEndDate] = useState(() => {
|
||||
if (customRange?.end) {
|
||||
return formatDateForInput(customRange.end);
|
||||
}
|
||||
return formatDateForInput(new Date());
|
||||
});
|
||||
|
||||
const handleTimeClick = useCallback((range: TimeRange) => {
|
||||
if (range === 'custom') {
|
||||
setShowCustom(true);
|
||||
onChange(range);
|
||||
} else {
|
||||
setShowCustom(false);
|
||||
onChange(range);
|
||||
}
|
||||
}, [onChange]);
|
||||
|
||||
const handleApplyCustom = useCallback(() => {
|
||||
if (startDate && endDate) {
|
||||
const start = new Date(startDate);
|
||||
start.setHours(0, 0, 0, 0);
|
||||
const end = new Date(endDate);
|
||||
end.setHours(23, 59, 59, 999);
|
||||
|
||||
if (start <= end) {
|
||||
onChange('custom', { start, end });
|
||||
}
|
||||
}
|
||||
}, [startDate, endDate, onChange]);
|
||||
|
||||
return (
|
||||
<div className={styles.timeRangeSelector}>
|
||||
<div className={styles.timeButtons}>
|
||||
{([1, 7, 14, 30, 'custom'] as TimeRange[]).map((range) => (
|
||||
<button
|
||||
key={range}
|
||||
className={`${styles.timeButton} ${value === range ? styles.active : ''}`}
|
||||
onClick={() => handleTimeClick(range)}
|
||||
>
|
||||
{range === 1
|
||||
? t('monitor.time.today')
|
||||
: range === 'custom'
|
||||
? t('monitor.time.custom')
|
||||
: t('monitor.time.last_n_days', { n: range })}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{showCustom && (
|
||||
<div className={styles.customDatePicker}>
|
||||
<input
|
||||
type="date"
|
||||
className={styles.dateInput}
|
||||
value={startDate}
|
||||
onChange={(e) => setStartDate(e.target.value)}
|
||||
/>
|
||||
<span className={styles.dateSeparator}>{t('monitor.time.to')}</span>
|
||||
<input
|
||||
type="date"
|
||||
className={styles.dateInput}
|
||||
value={endDate}
|
||||
onChange={(e) => setEndDate(e.target.value)}
|
||||
/>
|
||||
<button className={styles.dateApplyBtn} onClick={handleApplyCustom}>
|
||||
{t('monitor.time.apply')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function formatDateForInput(date: Date): string {
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(date.getDate()).padStart(2, '0');
|
||||
return `${year}-${month}-${day}`;
|
||||
}
|
||||
|
||||
// 根据时间范围过滤数据的工具函数
|
||||
export function filterByTimeRange<T extends { timestamp?: string }>(
|
||||
items: T[],
|
||||
range: TimeRange,
|
||||
customRange?: DateRange
|
||||
): T[] {
|
||||
const now = new Date();
|
||||
let cutoffStart: Date;
|
||||
let cutoffEnd: Date = new Date(now.getTime());
|
||||
cutoffEnd.setHours(23, 59, 59, 999);
|
||||
|
||||
if (range === 'custom' && customRange) {
|
||||
cutoffStart = customRange.start;
|
||||
cutoffEnd = customRange.end;
|
||||
} else if (typeof range === 'number') {
|
||||
cutoffStart = new Date(now.getTime() - range * 24 * 60 * 60 * 1000);
|
||||
cutoffStart.setHours(0, 0, 0, 0);
|
||||
} else {
|
||||
// 默认7天
|
||||
cutoffStart = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
|
||||
cutoffStart.setHours(0, 0, 0, 0);
|
||||
}
|
||||
|
||||
return items.filter((item) => {
|
||||
if (!item.timestamp) return false;
|
||||
const timestamp = new Date(item.timestamp);
|
||||
return timestamp >= cutoffStart && timestamp <= cutoffEnd;
|
||||
});
|
||||
}
|
||||
|
||||
// 格式化时间范围显示
|
||||
export function formatTimeRangeCaption(
|
||||
range: TimeRange,
|
||||
customRange?: DateRange,
|
||||
t?: (key: string, options?: any) => string
|
||||
): string {
|
||||
if (range === 'custom' && customRange) {
|
||||
const startStr = formatDateForDisplay(customRange.start);
|
||||
const endStr = formatDateForDisplay(customRange.end);
|
||||
return `${startStr} - ${endStr}`;
|
||||
}
|
||||
if (range === 1) {
|
||||
return t ? t('monitor.time.today') : '今天';
|
||||
}
|
||||
return t ? t('monitor.time.last_n_days', { n: range }) : `最近 ${range} 天`;
|
||||
}
|
||||
|
||||
function formatDateForDisplay(date: Date): string {
|
||||
const month = date.getMonth() + 1;
|
||||
const day = date.getDate();
|
||||
return `${month}/${day}`;
|
||||
}
|
||||
82
src/components/monitor/UnsupportedDisableModal.tsx
Normal file
@@ -0,0 +1,82 @@
|
||||
/**
|
||||
* 不支持自动禁用提示弹窗组件
|
||||
* 显示手动操作指南
|
||||
*/
|
||||
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Modal } from '@/components/ui/Modal';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import type { UnsupportedDisableState } from '@/hooks/useDisableModel';
|
||||
|
||||
interface UnsupportedDisableModalProps {
|
||||
/** 不支持禁用的状态 */
|
||||
state: UnsupportedDisableState | null;
|
||||
/** 关闭回调 */
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function UnsupportedDisableModal({
|
||||
state,
|
||||
onClose,
|
||||
}: UnsupportedDisableModalProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (!state) return null;
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={!!state}
|
||||
onClose={onClose}
|
||||
title={t('monitor.logs.disable_unsupported_title')}
|
||||
width={450}
|
||||
>
|
||||
<div style={{ padding: '16px 0' }}>
|
||||
{/* 提示信息 */}
|
||||
<p style={{
|
||||
marginBottom: 16,
|
||||
lineHeight: 1.6,
|
||||
color: 'var(--warning-color, #f59e0b)',
|
||||
fontWeight: 500,
|
||||
}}>
|
||||
⚠️ {t('monitor.logs.disable_unsupported_desc', { providerType: state.providerType })}
|
||||
</p>
|
||||
|
||||
{/* 手动操作指南 */}
|
||||
<div style={{
|
||||
padding: '12px 16px',
|
||||
background: 'var(--bg-tertiary)',
|
||||
borderRadius: '8px',
|
||||
marginBottom: 16,
|
||||
}}>
|
||||
<p style={{
|
||||
fontWeight: 600,
|
||||
marginBottom: 8,
|
||||
color: 'var(--text-primary)',
|
||||
}}>
|
||||
{t('monitor.logs.disable_unsupported_guide_title')}
|
||||
</p>
|
||||
<ul style={{
|
||||
margin: 0,
|
||||
padding: 0,
|
||||
listStyle: 'none',
|
||||
fontSize: 13,
|
||||
lineHeight: 1.8,
|
||||
color: 'var(--text-secondary)',
|
||||
}}>
|
||||
<li>{t('monitor.logs.disable_unsupported_guide_step1')}</li>
|
||||
<li>{t('monitor.logs.disable_unsupported_guide_step2', { providerType: state.providerType })}</li>
|
||||
<li>{t('monitor.logs.disable_unsupported_guide_step3', { model: state.model })}</li>
|
||||
<li>{t('monitor.logs.disable_unsupported_guide_step4')}</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* 关闭按钮 */}
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end' }}>
|
||||
<Button variant="primary" onClick={onClose}>
|
||||
{t('monitor.logs.disable_unsupported_close')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
8
src/components/monitor/index.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export { KpiCards } from './KpiCards';
|
||||
export { ModelDistributionChart } from './ModelDistributionChart';
|
||||
export { DailyTrendChart } from './DailyTrendChart';
|
||||
export { HourlyModelChart } from './HourlyModelChart';
|
||||
export { HourlyTokenChart } from './HourlyTokenChart';
|
||||
export { ChannelStats } from './ChannelStats';
|
||||
export { FailureAnalysis } from './FailureAnalysis';
|
||||
export { RequestLogs } from './RequestLogs';
|
||||
94
src/components/providers/AmpcodeSection/AmpcodeSection.tsx
Normal file
@@ -0,0 +1,94 @@
|
||||
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';
|
||||
|
||||
interface AmpcodeSectionProps {
|
||||
config: AmpcodeConfig | null | undefined;
|
||||
loading: boolean;
|
||||
disableControls: boolean;
|
||||
isSwitching: boolean;
|
||||
onEdit: () => void;
|
||||
}
|
||||
|
||||
export function AmpcodeSection({
|
||||
config,
|
||||
loading,
|
||||
disableControls,
|
||||
isSwitching,
|
||||
onEdit,
|
||||
}: AmpcodeSectionProps) {
|
||||
const { t } = useTranslation();
|
||||
const showLoadingPlaceholder = loading && !config;
|
||||
|
||||
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={onEdit}
|
||||
disabled={disableControls || loading || isSwitching}
|
||||
>
|
||||
{t('common.edit')}
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
{showLoadingPlaceholder ? (
|
||||
<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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
1
src/components/providers/AmpcodeSection/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { AmpcodeSection } from './AmpcodeSection';
|
||||
191
src/components/providers/ClaudeSection/ClaudeSection.tsx
Normal file
@@ -0,0 +1,191 @@
|
||||
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 {
|
||||
buildCandidateUsageSourceIds,
|
||||
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';
|
||||
|
||||
interface ClaudeSectionProps {
|
||||
configs: ProviderKeyConfig[];
|
||||
keyStats: KeyStats;
|
||||
usageDetails: UsageDetail[];
|
||||
loading: boolean;
|
||||
disableControls: boolean;
|
||||
isSwitching: boolean;
|
||||
onAdd: () => void;
|
||||
onEdit: (index: number) => void;
|
||||
onDelete: (index: number) => void;
|
||||
onToggle: (index: number, enabled: boolean) => void;
|
||||
}
|
||||
|
||||
export function ClaudeSection({
|
||||
configs,
|
||||
keyStats,
|
||||
usageDetails,
|
||||
loading,
|
||||
disableControls,
|
||||
isSwitching,
|
||||
onAdd,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onToggle,
|
||||
}: ClaudeSectionProps) {
|
||||
const { t } = useTranslation();
|
||||
const actionsDisabled = disableControls || loading || isSwitching;
|
||||
const toggleDisabled = disableControls || loading || isSwitching;
|
||||
|
||||
const statusBarCache = useMemo(() => {
|
||||
const cache = new Map<string, ReturnType<typeof calculateStatusBarData>>();
|
||||
|
||||
configs.forEach((config) => {
|
||||
if (!config.apiKey) return;
|
||||
const candidates = buildCandidateUsageSourceIds({
|
||||
apiKey: config.apiKey,
|
||||
prefix: config.prefix,
|
||||
});
|
||||
if (!candidates.length) return;
|
||||
const candidateSet = new Set(candidates);
|
||||
const filteredDetails = usageDetails.filter((detail) => candidateSet.has(detail.source));
|
||||
cache.set(config.apiKey, calculateStatusBarData(filteredDetails));
|
||||
});
|
||||
|
||||
return cache;
|
||||
}, [configs, usageDetails]);
|
||||
|
||||
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, item.prefix);
|
||||
const headerEntries = Object.entries(item.headers || {});
|
||||
const configDisabled = hasDisableAllModelsRule(item.excludedModels);
|
||||
const excludedModels = item.excludedModels ?? [];
|
||||
const statusData = statusBarCache.get(item.apiKey) || calculateStatusBarData([]);
|
||||
|
||||
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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
1
src/components/providers/ClaudeSection/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { ClaudeSection } from './ClaudeSection';
|
||||
183
src/components/providers/CodexSection/CodexSection.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 iconCodexLight from '@/assets/icons/codex_light.svg';
|
||||
import iconCodexDark from '@/assets/icons/codex_drak.svg';
|
||||
import type { ProviderKeyConfig } from '@/types';
|
||||
import { maskApiKey } from '@/utils/format';
|
||||
import {
|
||||
buildCandidateUsageSourceIds,
|
||||
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';
|
||||
|
||||
interface CodexSectionProps {
|
||||
configs: ProviderKeyConfig[];
|
||||
keyStats: KeyStats;
|
||||
usageDetails: UsageDetail[];
|
||||
loading: boolean;
|
||||
disableControls: boolean;
|
||||
isSwitching: boolean;
|
||||
resolvedTheme: string;
|
||||
onAdd: () => void;
|
||||
onEdit: (index: number) => void;
|
||||
onDelete: (index: number) => void;
|
||||
onToggle: (index: number, enabled: boolean) => void;
|
||||
}
|
||||
|
||||
export function CodexSection({
|
||||
configs,
|
||||
keyStats,
|
||||
usageDetails,
|
||||
loading,
|
||||
disableControls,
|
||||
isSwitching,
|
||||
resolvedTheme,
|
||||
onAdd,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onToggle,
|
||||
}: CodexSectionProps) {
|
||||
const { t } = useTranslation();
|
||||
const actionsDisabled = disableControls || loading || isSwitching;
|
||||
const toggleDisabled = disableControls || loading || isSwitching;
|
||||
|
||||
const statusBarCache = useMemo(() => {
|
||||
const cache = new Map<string, ReturnType<typeof calculateStatusBarData>>();
|
||||
|
||||
configs.forEach((config) => {
|
||||
if (!config.apiKey) return;
|
||||
const candidates = buildCandidateUsageSourceIds({
|
||||
apiKey: config.apiKey,
|
||||
prefix: config.prefix,
|
||||
});
|
||||
if (!candidates.length) return;
|
||||
const candidateSet = new Set(candidates);
|
||||
const filteredDetails = usageDetails.filter((detail) => candidateSet.has(detail.source));
|
||||
cache.set(config.apiKey, calculateStatusBarData(filteredDetails));
|
||||
});
|
||||
|
||||
return cache;
|
||||
}, [configs, usageDetails]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card
|
||||
title={
|
||||
<span className={styles.cardTitle}>
|
||||
<img
|
||||
src={resolvedTheme === 'dark' ? iconCodexDark : iconCodexLight}
|
||||
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, item.prefix);
|
||||
const headerEntries = Object.entries(item.headers || {});
|
||||
const configDisabled = hasDisableAllModelsRule(item.excludedModels);
|
||||
const excludedModels = item.excludedModels ?? [];
|
||||
const statusData = statusBarCache.get(item.apiKey) || calculateStatusBarData([]);
|
||||
|
||||
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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
1
src/components/providers/CodexSection/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { CodexSection } from './CodexSection';
|
||||
172
src/components/providers/GeminiSection/GeminiSection.tsx
Normal file
@@ -0,0 +1,172 @@
|
||||
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 {
|
||||
buildCandidateUsageSourceIds,
|
||||
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';
|
||||
|
||||
interface GeminiSectionProps {
|
||||
configs: GeminiKeyConfig[];
|
||||
keyStats: KeyStats;
|
||||
usageDetails: UsageDetail[];
|
||||
loading: boolean;
|
||||
disableControls: boolean;
|
||||
isSwitching: boolean;
|
||||
onAdd: () => void;
|
||||
onEdit: (index: number) => void;
|
||||
onDelete: (index: number) => void;
|
||||
onToggle: (index: number, enabled: boolean) => void;
|
||||
}
|
||||
|
||||
export function GeminiSection({
|
||||
configs,
|
||||
keyStats,
|
||||
usageDetails,
|
||||
loading,
|
||||
disableControls,
|
||||
isSwitching,
|
||||
onAdd,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onToggle,
|
||||
}: GeminiSectionProps) {
|
||||
const { t } = useTranslation();
|
||||
const actionsDisabled = disableControls || loading || isSwitching;
|
||||
const toggleDisabled = disableControls || loading || isSwitching;
|
||||
|
||||
const statusBarCache = useMemo(() => {
|
||||
const cache = new Map<string, ReturnType<typeof calculateStatusBarData>>();
|
||||
|
||||
configs.forEach((config) => {
|
||||
if (!config.apiKey) return;
|
||||
const candidates = buildCandidateUsageSourceIds({
|
||||
apiKey: config.apiKey,
|
||||
prefix: config.prefix,
|
||||
});
|
||||
if (!candidates.length) return;
|
||||
const candidateSet = new Set(candidates);
|
||||
const filteredDetails = usageDetails.filter((detail) => candidateSet.has(detail.source));
|
||||
cache.set(config.apiKey, calculateStatusBarData(filteredDetails));
|
||||
});
|
||||
|
||||
return cache;
|
||||
}, [configs, usageDetails]);
|
||||
|
||||
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, item.prefix);
|
||||
const headerEntries = Object.entries(item.headers || {});
|
||||
const configDisabled = hasDisableAllModelsRule(item.excludedModels);
|
||||
const excludedModels = item.excludedModels ?? [];
|
||||
const statusData = statusBarCache.get(item.apiKey) || calculateStatusBarData([]);
|
||||
|
||||
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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
1
src/components/providers/GeminiSection/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { GeminiSection } from './GeminiSection';
|
||||
195
src/components/providers/OpenAISection/OpenAISection.tsx
Normal file
@@ -0,0 +1,195 @@
|
||||
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 {
|
||||
buildCandidateUsageSourceIds,
|
||||
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';
|
||||
|
||||
interface OpenAISectionProps {
|
||||
configs: OpenAIProviderConfig[];
|
||||
keyStats: KeyStats;
|
||||
usageDetails: UsageDetail[];
|
||||
loading: boolean;
|
||||
disableControls: boolean;
|
||||
isSwitching: boolean;
|
||||
resolvedTheme: string;
|
||||
onAdd: () => void;
|
||||
onEdit: (index: number) => void;
|
||||
onDelete: (index: number) => void;
|
||||
}
|
||||
|
||||
export function OpenAISection({
|
||||
configs,
|
||||
keyStats,
|
||||
usageDetails,
|
||||
loading,
|
||||
disableControls,
|
||||
isSwitching,
|
||||
resolvedTheme,
|
||||
onAdd,
|
||||
onEdit,
|
||||
onDelete,
|
||||
}: OpenAISectionProps) {
|
||||
const { t } = useTranslation();
|
||||
const actionsDisabled = disableControls || loading || isSwitching;
|
||||
|
||||
const statusBarCache = useMemo(() => {
|
||||
const cache = new Map<string, ReturnType<typeof calculateStatusBarData>>();
|
||||
|
||||
configs.forEach((provider) => {
|
||||
const sourceIds = new Set<string>();
|
||||
buildCandidateUsageSourceIds({ prefix: provider.prefix }).forEach((id) => sourceIds.add(id));
|
||||
(provider.apiKeyEntries || []).forEach((entry) => {
|
||||
buildCandidateUsageSourceIds({ apiKey: entry.apiKey }).forEach((id) => sourceIds.add(id));
|
||||
});
|
||||
|
||||
const filteredDetails = sourceIds.size
|
||||
? usageDetails.filter((detail) => sourceIds.has(detail.source))
|
||||
: [];
|
||||
cache.set(provider.name, calculateStatusBarData(filteredDetails));
|
||||
});
|
||||
|
||||
return cache;
|
||||
}, [configs, usageDetails]);
|
||||
|
||||
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, item.prefix);
|
||||
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);
|
||||
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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
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 && items.length === 0) {
|
||||
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>
|
||||
);
|
||||
}
|
||||
153
src/components/providers/ProviderNav/ProviderNav.module.scss
Normal file
@@ -0,0 +1,153 @@
|
||||
@use '../../../styles/variables' as *;
|
||||
|
||||
.navContainer {
|
||||
position: fixed;
|
||||
left: var(--content-center-x, 50%);
|
||||
bottom: calc(12px + env(safe-area-inset-bottom));
|
||||
transform: translateX(-50%);
|
||||
z-index: 50;
|
||||
pointer-events: auto;
|
||||
width: fit-content;
|
||||
max-width: calc(100vw - 24px);
|
||||
}
|
||||
|
||||
.navList {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
flex-direction: row;
|
||||
gap: 6px;
|
||||
padding: 10px 12px;
|
||||
background: rgba(255, 255, 255, 0.7);
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.3);
|
||||
border-radius: 999px;
|
||||
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.1);
|
||||
overflow-x: auto;
|
||||
scrollbar-width: none;
|
||||
max-width: inherit;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.indicator {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
pointer-events: none;
|
||||
opacity: 0;
|
||||
border-radius: 999px;
|
||||
background: rgba(59, 130, 246, 0.15);
|
||||
box-shadow: inset 0 0 0 2px var(--primary-color);
|
||||
transition: transform 220ms cubic-bezier(0.22, 1, 0.36, 1),
|
||||
width 220ms cubic-bezier(0.22, 1, 0.36, 1),
|
||||
height 220ms cubic-bezier(0.22, 1, 0.36, 1),
|
||||
opacity 120ms ease;
|
||||
will-change: transform, width, height;
|
||||
}
|
||||
|
||||
.indicatorVisible {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.indicatorNoTransition {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.navItem {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: transparent;
|
||||
border-radius: 999px;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s ease, transform 0.15s ease;
|
||||
flex: 0 0 auto;
|
||||
|
||||
&:hover {
|
||||
background: rgba(0, 0, 0, 0.06);
|
||||
transform: scale(1.08);
|
||||
}
|
||||
|
||||
&:active {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
}
|
||||
|
||||
.navItem.active {
|
||||
&:hover {
|
||||
background: transparent;
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
|
||||
.icon {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.active {
|
||||
// Active highlight is rendered by the sliding indicator.
|
||||
background: transparent;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
// 暗色主题适配
|
||||
:global([data-theme='dark']) {
|
||||
.navList {
|
||||
background: rgba(30, 30, 30, 0.7);
|
||||
border-color: rgba(255, 255, 255, 0.1);
|
||||
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
.navItem {
|
||||
&:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
.indicator {
|
||||
background: rgba(59, 130, 246, 0.25);
|
||||
}
|
||||
}
|
||||
|
||||
// 小屏幕进一步收紧尺寸
|
||||
@media (max-width: 1200px) {
|
||||
.navContainer {
|
||||
max-width: calc(100vw - 16px);
|
||||
}
|
||||
|
||||
.navList {
|
||||
gap: 6px;
|
||||
padding: 8px 10px;
|
||||
}
|
||||
|
||||
.navItem {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
}
|
||||
|
||||
.icon {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.indicator {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.navItem {
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
}
|
||||
287
src/components/providers/ProviderNav/ProviderNav.tsx
Normal file
@@ -0,0 +1,287 @@
|
||||
import { CSSProperties, useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { usePageTransitionLayer } from '@/components/common/PageTransitionLayer';
|
||||
import { useThemeStore } from '@/stores';
|
||||
import iconGemini from '@/assets/icons/gemini.svg';
|
||||
import iconOpenaiLight from '@/assets/icons/openai-light.svg';
|
||||
import iconOpenaiDark from '@/assets/icons/openai-dark.svg';
|
||||
import iconCodexLight from '@/assets/icons/codex_light.svg';
|
||||
import iconCodexDark from '@/assets/icons/codex_drak.svg';
|
||||
import iconClaude from '@/assets/icons/claude.svg';
|
||||
import iconVertex from '@/assets/icons/vertex.svg';
|
||||
import iconAmp from '@/assets/icons/amp.svg';
|
||||
import styles from './ProviderNav.module.scss';
|
||||
|
||||
export type ProviderId = 'gemini' | 'codex' | 'claude' | 'vertex' | 'ampcode' | 'openai';
|
||||
|
||||
interface ProviderNavItem {
|
||||
id: ProviderId;
|
||||
label: string;
|
||||
getIcon: (theme: string) => string;
|
||||
}
|
||||
|
||||
const PROVIDERS: ProviderNavItem[] = [
|
||||
{ id: 'gemini', label: 'Gemini', getIcon: () => iconGemini },
|
||||
{ id: 'codex', label: 'Codex', getIcon: (theme) => (theme === 'dark' ? iconCodexDark : iconCodexLight) },
|
||||
{ id: 'claude', label: 'Claude', getIcon: () => iconClaude },
|
||||
{ id: 'vertex', label: 'Vertex', getIcon: () => iconVertex },
|
||||
{ id: 'ampcode', label: 'Ampcode', getIcon: () => iconAmp },
|
||||
{ id: 'openai', label: 'OpenAI', getIcon: (theme) => (theme === 'dark' ? iconOpenaiDark : iconOpenaiLight) },
|
||||
];
|
||||
|
||||
const HEADER_OFFSET = 24;
|
||||
type ScrollContainer = HTMLElement | (Window & typeof globalThis);
|
||||
|
||||
export function ProviderNav() {
|
||||
const location = useLocation();
|
||||
const pageTransitionLayer = usePageTransitionLayer();
|
||||
const isCurrentLayer = pageTransitionLayer ? pageTransitionLayer.status === 'current' : true;
|
||||
const resolvedTheme = useThemeStore((state) => state.resolvedTheme);
|
||||
const [activeProvider, setActiveProvider] = useState<ProviderId | null>(null);
|
||||
const contentScrollerRef = useRef<HTMLElement | null>(null);
|
||||
const navListRef = useRef<HTMLDivElement | null>(null);
|
||||
const navContainerRef = useRef<HTMLDivElement | null>(null);
|
||||
const itemRefs = useRef<Record<ProviderId, HTMLButtonElement | null>>({
|
||||
gemini: null,
|
||||
codex: null,
|
||||
claude: null,
|
||||
vertex: null,
|
||||
ampcode: null,
|
||||
openai: null,
|
||||
});
|
||||
const [indicatorRect, setIndicatorRect] = useState<{
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
} | null>(null);
|
||||
const [indicatorTransitionsEnabled, setIndicatorTransitionsEnabled] = useState(false);
|
||||
const indicatorHasEnabledTransitionsRef = useRef(false);
|
||||
|
||||
// Only show this quick-switch overlay on the AI Providers list page.
|
||||
// Note: The app uses iOS-style stacked page transitions inside `/ai-providers/*`,
|
||||
// so this component can stay mounted while the user is on an edit route.
|
||||
const normalizedPathname =
|
||||
location.pathname.length > 1 && location.pathname.endsWith('/')
|
||||
? location.pathname.slice(0, -1)
|
||||
: location.pathname;
|
||||
const shouldShow = isCurrentLayer && normalizedPathname === '/ai-providers';
|
||||
|
||||
const getHeaderHeight = useCallback(() => {
|
||||
const header = document.querySelector('.main-header') as HTMLElement | null;
|
||||
if (header) return header.getBoundingClientRect().height;
|
||||
|
||||
const raw = getComputedStyle(document.documentElement).getPropertyValue('--header-height');
|
||||
const value = Number.parseFloat(raw);
|
||||
return Number.isFinite(value) ? value : 0;
|
||||
}, []);
|
||||
|
||||
const getContentScroller = useCallback(() => {
|
||||
if (contentScrollerRef.current && document.contains(contentScrollerRef.current)) {
|
||||
return contentScrollerRef.current;
|
||||
}
|
||||
|
||||
const container = document.querySelector('.content') as HTMLElement | null;
|
||||
contentScrollerRef.current = container;
|
||||
return container;
|
||||
}, []);
|
||||
|
||||
const getScrollContainer = useCallback((): ScrollContainer => {
|
||||
// Mobile layout uses document scroll (layout switches at 768px); desktop uses the `.content` scroller.
|
||||
const isMobile = window.matchMedia('(max-width: 768px)').matches;
|
||||
if (isMobile) return window;
|
||||
return getContentScroller() ?? window;
|
||||
}, [getContentScroller]);
|
||||
|
||||
const handleScroll = useCallback(() => {
|
||||
const container = getScrollContainer();
|
||||
if (!container) return;
|
||||
|
||||
const isElementScroller = container instanceof HTMLElement;
|
||||
const headerHeight = isElementScroller ? 0 : getHeaderHeight();
|
||||
const containerTop = isElementScroller ? container.getBoundingClientRect().top : 0;
|
||||
const activationLine = containerTop + headerHeight + HEADER_OFFSET + 1;
|
||||
let currentActive: ProviderId | null = null;
|
||||
|
||||
for (const provider of PROVIDERS) {
|
||||
const element = document.getElementById(`provider-${provider.id}`);
|
||||
if (!element) continue;
|
||||
|
||||
const rect = element.getBoundingClientRect();
|
||||
if (rect.top <= activationLine) {
|
||||
currentActive = provider.id;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (currentActive) break;
|
||||
}
|
||||
|
||||
if (!currentActive) {
|
||||
const firstVisible = PROVIDERS.find((provider) =>
|
||||
document.getElementById(`provider-${provider.id}`)
|
||||
);
|
||||
currentActive = firstVisible?.id ?? null;
|
||||
}
|
||||
|
||||
setActiveProvider(currentActive);
|
||||
}, [getHeaderHeight, getScrollContainer]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!shouldShow) return;
|
||||
const contentScroller = getContentScroller();
|
||||
|
||||
// Listen to both: desktop scroll happens on `.content`; mobile uses `window`.
|
||||
window.addEventListener('scroll', handleScroll, { passive: true });
|
||||
contentScroller?.addEventListener('scroll', handleScroll, { passive: true });
|
||||
window.addEventListener('resize', handleScroll);
|
||||
const raf = requestAnimationFrame(handleScroll);
|
||||
return () => {
|
||||
cancelAnimationFrame(raf);
|
||||
window.removeEventListener('scroll', handleScroll);
|
||||
window.removeEventListener('resize', handleScroll);
|
||||
contentScroller?.removeEventListener('scroll', handleScroll);
|
||||
};
|
||||
}, [getContentScroller, handleScroll, shouldShow]);
|
||||
|
||||
const updateIndicator = useCallback((providerId: ProviderId | null) => {
|
||||
if (!providerId) {
|
||||
setIndicatorRect(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const itemEl = itemRefs.current[providerId];
|
||||
if (!itemEl) return;
|
||||
|
||||
setIndicatorRect({
|
||||
x: itemEl.offsetLeft,
|
||||
y: itemEl.offsetTop,
|
||||
width: itemEl.offsetWidth,
|
||||
height: itemEl.offsetHeight,
|
||||
});
|
||||
|
||||
// Avoid animating from an initial (0,0) state on first paint.
|
||||
if (!indicatorHasEnabledTransitionsRef.current) {
|
||||
indicatorHasEnabledTransitionsRef.current = true;
|
||||
requestAnimationFrame(() => setIndicatorTransitionsEnabled(true));
|
||||
}
|
||||
}, []);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!shouldShow) return;
|
||||
const raf = requestAnimationFrame(() => updateIndicator(activeProvider));
|
||||
return () => cancelAnimationFrame(raf);
|
||||
}, [activeProvider, shouldShow, updateIndicator]);
|
||||
|
||||
// Expose overlay height to the page, so it can reserve bottom padding and avoid being covered.
|
||||
useLayoutEffect(() => {
|
||||
if (!shouldShow) return;
|
||||
|
||||
const el = navContainerRef.current;
|
||||
if (!el) return;
|
||||
|
||||
const updateHeight = () => {
|
||||
const height = el.getBoundingClientRect().height;
|
||||
document.documentElement.style.setProperty('--provider-nav-height', `${height}px`);
|
||||
};
|
||||
|
||||
updateHeight();
|
||||
window.addEventListener('resize', updateHeight);
|
||||
|
||||
const ro = typeof ResizeObserver === 'undefined' ? null : new ResizeObserver(updateHeight);
|
||||
ro?.observe(el);
|
||||
|
||||
return () => {
|
||||
ro?.disconnect();
|
||||
window.removeEventListener('resize', updateHeight);
|
||||
document.documentElement.style.removeProperty('--provider-nav-height');
|
||||
};
|
||||
}, [shouldShow]);
|
||||
|
||||
const scrollToProvider = (providerId: ProviderId) => {
|
||||
const container = getScrollContainer();
|
||||
const element = document.getElementById(`provider-${providerId}`);
|
||||
if (!element || !container) return;
|
||||
|
||||
setActiveProvider(providerId);
|
||||
updateIndicator(providerId);
|
||||
|
||||
// Mobile: scroll the document (header is fixed, so offset by header height).
|
||||
if (!(container instanceof HTMLElement)) {
|
||||
const headerHeight = getHeaderHeight();
|
||||
const elementTop = element.getBoundingClientRect().top + window.scrollY;
|
||||
const target = Math.max(0, elementTop - headerHeight - HEADER_OFFSET);
|
||||
window.scrollTo({ top: target, behavior: 'smooth' });
|
||||
return;
|
||||
}
|
||||
|
||||
const containerRect = container.getBoundingClientRect();
|
||||
const elementRect = element.getBoundingClientRect();
|
||||
const scrollTop = container.scrollTop + (elementRect.top - containerRect.top) - HEADER_OFFSET;
|
||||
|
||||
container.scrollTo({ top: scrollTop, behavior: 'smooth' });
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!shouldShow) return;
|
||||
const handleResize = () => updateIndicator(activeProvider);
|
||||
window.addEventListener('resize', handleResize);
|
||||
return () => {
|
||||
window.removeEventListener('resize', handleResize);
|
||||
};
|
||||
}, [activeProvider, shouldShow, updateIndicator]);
|
||||
|
||||
const navContent = (
|
||||
<div className={styles.navContainer} ref={navContainerRef}>
|
||||
<div className={styles.navList} ref={navListRef}>
|
||||
<div
|
||||
className={[
|
||||
styles.indicator,
|
||||
indicatorRect ? styles.indicatorVisible : '',
|
||||
indicatorTransitionsEnabled ? '' : styles.indicatorNoTransition,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
style={
|
||||
(indicatorRect
|
||||
? ({
|
||||
transform: `translate3d(${indicatorRect.x}px, ${indicatorRect.y}px, 0)`,
|
||||
width: indicatorRect.width,
|
||||
height: indicatorRect.height,
|
||||
} satisfies CSSProperties)
|
||||
: undefined) as CSSProperties | undefined
|
||||
}
|
||||
/>
|
||||
{PROVIDERS.map((provider) => {
|
||||
const isActive = activeProvider === provider.id;
|
||||
return (
|
||||
<button
|
||||
key={provider.id}
|
||||
className={`${styles.navItem} ${isActive ? styles.active : ''}`}
|
||||
ref={(node) => {
|
||||
itemRefs.current[provider.id] = node;
|
||||
}}
|
||||
onClick={() => scrollToProvider(provider.id)}
|
||||
title={provider.label}
|
||||
type="button"
|
||||
aria-label={provider.label}
|
||||
aria-pressed={isActive}
|
||||
>
|
||||
<img
|
||||
src={provider.getIcon(resolvedTheme)}
|
||||
alt={provider.label}
|
||||
className={styles.icon}
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
if (typeof document === 'undefined') return null;
|
||||
|
||||
if (!shouldShow) return null;
|
||||
|
||||
return createPortal(navContent, document.body);
|
||||
}
|
||||
2
src/components/providers/ProviderNav/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { ProviderNav } from './ProviderNav';
|
||||
export type { ProviderId } from './ProviderNav';
|
||||
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>
|
||||
);
|
||||
}
|
||||
159
src/components/providers/VertexSection/VertexSection.tsx
Normal file
@@ -0,0 +1,159 @@
|
||||
import { Fragment, useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
import iconVertex from '@/assets/icons/vertex.svg';
|
||||
import type { ProviderKeyConfig } from '@/types';
|
||||
import { maskApiKey } from '@/utils/format';
|
||||
import {
|
||||
buildCandidateUsageSourceIds,
|
||||
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 } from '../utils';
|
||||
|
||||
interface VertexSectionProps {
|
||||
configs: ProviderKeyConfig[];
|
||||
keyStats: KeyStats;
|
||||
usageDetails: UsageDetail[];
|
||||
loading: boolean;
|
||||
disableControls: boolean;
|
||||
isSwitching: boolean;
|
||||
onAdd: () => void;
|
||||
onEdit: (index: number) => void;
|
||||
onDelete: (index: number) => void;
|
||||
}
|
||||
|
||||
export function VertexSection({
|
||||
configs,
|
||||
keyStats,
|
||||
usageDetails,
|
||||
loading,
|
||||
disableControls,
|
||||
isSwitching,
|
||||
onAdd,
|
||||
onEdit,
|
||||
onDelete,
|
||||
}: VertexSectionProps) {
|
||||
const { t } = useTranslation();
|
||||
const actionsDisabled = disableControls || loading || isSwitching;
|
||||
|
||||
const statusBarCache = useMemo(() => {
|
||||
const cache = new Map<string, ReturnType<typeof calculateStatusBarData>>();
|
||||
|
||||
configs.forEach((config) => {
|
||||
if (!config.apiKey) return;
|
||||
const candidates = buildCandidateUsageSourceIds({
|
||||
apiKey: config.apiKey,
|
||||
prefix: config.prefix,
|
||||
});
|
||||
if (!candidates.length) return;
|
||||
const candidateSet = new Set(candidates);
|
||||
const filteredDetails = usageDetails.filter((detail) => candidateSet.has(detail.source));
|
||||
cache.set(config.apiKey, calculateStatusBarData(filteredDetails));
|
||||
});
|
||||
|
||||
return cache;
|
||||
}, [configs, usageDetails]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card
|
||||
title={
|
||||
<span className={styles.cardTitle}>
|
||||
<img src={iconVertex} alt="" className={styles.cardTitleIcon} />
|
||||
{t('ai_providers.vertex_title')}
|
||||
</span>
|
||||
}
|
||||
extra={
|
||||
<Button size="sm" onClick={onAdd} disabled={actionsDisabled}>
|
||||
{t('ai_providers.vertex_add_button')}
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<ProviderList<ProviderKeyConfig>
|
||||
items={configs}
|
||||
loading={loading}
|
||||
keyField={(item) => item.apiKey}
|
||||
emptyTitle={t('ai_providers.vertex_empty_title')}
|
||||
emptyDescription={t('ai_providers.vertex_empty_desc')}
|
||||
onEdit={onEdit}
|
||||
onDelete={onDelete}
|
||||
actionsDisabled={actionsDisabled}
|
||||
renderContent={(item, index) => {
|
||||
const stats = getStatsBySource(item.apiKey, keyStats, item.prefix);
|
||||
const headerEntries = Object.entries(item.headers || {});
|
||||
const statusData = statusBarCache.get(item.apiKey) || calculateStatusBarData([]);
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
<div className="item-title">
|
||||
{t('ai_providers.vertex_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>
|
||||
)}
|
||||
{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>
|
||||
)}
|
||||
{item.models?.length ? (
|
||||
<div className={styles.modelTagList}>
|
||||
<span className={styles.modelCountLabel}>
|
||||
{t('ai_providers.vertex_models_count')}: {item.models.length}
|
||||
</span>
|
||||
{item.models.map((model) => (
|
||||
<span key={`${model.name}-${model.alias || 'default'}`} className={styles.modelTag}>
|
||||
<span className={styles.modelName}>{model.name}</span>
|
||||
{model.alias && (
|
||||
<span className={styles.modelAlias}>{model.alias}</span>
|
||||
)}
|
||||
</span>
|
||||
))}
|
||||
</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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
1
src/components/providers/VertexSection/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { VertexSection } from './VertexSection';
|
||||
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 };
|
||||
};
|
||||
12
src/components/providers/index.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
export { AmpcodeSection } from './AmpcodeSection';
|
||||
export { ClaudeSection } from './ClaudeSection';
|
||||
export { CodexSection } from './CodexSection';
|
||||
export { GeminiSection } from './GeminiSection';
|
||||
export { OpenAISection } from './OpenAISection';
|
||||
export { VertexSection } from './VertexSection';
|
||||
export { ProviderList } from './ProviderList';
|
||||
export { ProviderStatusBar } from './ProviderStatusBar';
|
||||
export { ProviderNav } from './ProviderNav';
|
||||
export * from './hooks/useProviderStats';
|
||||
export * from './types';
|
||||
export * from './utils';
|
||||
52
src/components/providers/types.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import type { ApiKeyEntry, GeminiKeyConfig, ProviderKeyConfig } from '@/types';
|
||||
import type { HeaderEntry } from '@/utils/headers';
|
||||
import type { KeyStats, UsageDetail } from '@/utils/usage';
|
||||
|
||||
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 = Omit<GeminiKeyConfig, 'headers'> & {
|
||||
headers: HeaderEntry[];
|
||||
excludedText: string;
|
||||
};
|
||||
|
||||
export type ProviderFormState = Omit<ProviderKeyConfig, 'headers'> & {
|
||||
headers: HeaderEntry[];
|
||||
modelEntries: ModelEntry[];
|
||||
excludedText: string;
|
||||
};
|
||||
|
||||
export type VertexFormState = Omit<ProviderKeyConfig, 'headers' | 'excludedModels'> & {
|
||||
headers: HeaderEntry[];
|
||||
modelEntries: ModelEntry[];
|
||||
};
|
||||
|
||||
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;
|
||||
}
|
||||
149
src/components/providers/utils.ts
Normal file
@@ -0,0 +1,149 @@
|
||||
import type { AmpcodeConfig, AmpcodeModelMapping, ApiKeyEntry } from '@/types';
|
||||
import { buildCandidateUsageSourceIds, type KeyStatBucket, type 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}/models`;
|
||||
};
|
||||
|
||||
export const buildOpenAIChatCompletionsEndpoint = (baseUrl: string): string => {
|
||||
const trimmed = normalizeOpenAIBaseUrl(baseUrl);
|
||||
if (!trimmed) return '';
|
||||
if (trimmed.endsWith('/chat/completions')) {
|
||||
return trimmed;
|
||||
}
|
||||
return `${trimmed}/chat/completions`;
|
||||
};
|
||||
|
||||
// 根据 source (apiKey) 获取统计数据 - 与旧版逻辑一致
|
||||
export const getStatsBySource = (
|
||||
apiKey: string,
|
||||
keyStats: KeyStats,
|
||||
prefix?: string
|
||||
): KeyStatBucket => {
|
||||
const bySource = keyStats.bySource ?? {};
|
||||
const candidates = buildCandidateUsageSourceIds({ apiKey, prefix });
|
||||
if (!candidates.length) {
|
||||
return { success: 0, failure: 0 };
|
||||
}
|
||||
|
||||
let success = 0;
|
||||
let failure = 0;
|
||||
candidates.forEach((candidate) => {
|
||||
const stats = bySource[candidate];
|
||||
if (!stats) return;
|
||||
success += stats.success;
|
||||
failure += stats.failure;
|
||||
});
|
||||
|
||||
return { success, failure };
|
||||
};
|
||||
|
||||
// 对于 OpenAI 提供商,汇总所有 apiKeyEntries 的统计 - 与旧版逻辑一致
|
||||
export const getOpenAIProviderStats = (
|
||||
apiKeyEntries: ApiKeyEntry[] | undefined,
|
||||
keyStats: KeyStats,
|
||||
providerPrefix?: string
|
||||
): KeyStatBucket => {
|
||||
const bySource = keyStats.bySource ?? {};
|
||||
|
||||
const sourceIds = new Set<string>();
|
||||
buildCandidateUsageSourceIds({ prefix: providerPrefix }).forEach((id) => sourceIds.add(id));
|
||||
(apiKeyEntries || []).forEach((entry) => {
|
||||
buildCandidateUsageSourceIds({ apiKey: entry?.apiKey }).forEach((id) => sourceIds.add(id));
|
||||
});
|
||||
|
||||
let success = 0;
|
||||
let failure = 0;
|
||||
sourceIds.forEach((id) => {
|
||||
const stats = bySource[id];
|
||||
if (!stats) return;
|
||||
success += stats.success;
|
||||
failure += stats.failure;
|
||||
});
|
||||
|
||||
return { success, failure };
|
||||
};
|
||||
|
||||
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;
|
||||
};
|
||||
321
src/components/quota/QuotaSection.tsx
Normal file
@@ -0,0 +1,321 @@
|
||||
/**
|
||||
* Generic quota section component.
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { EmptyState } from '@/components/ui/EmptyState';
|
||||
import { triggerHeaderRefresh } from '@/hooks/useHeaderRefresh';
|
||||
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 { useGridColumns } from './useGridColumns';
|
||||
import { IconRefreshCw } from '@/components/ui/icons';
|
||||
import styles from '@/pages/QuotaPage.module.scss';
|
||||
|
||||
type QuotaUpdater<T> = T | ((prev: T) => T);
|
||||
|
||||
type QuotaSetter<T> = (updater: QuotaUpdater<T>) => void;
|
||||
|
||||
type ViewMode = 'paged' | 'all';
|
||||
|
||||
const MAX_ITEMS_PER_PAGE = 14;
|
||||
const MAX_SHOW_ALL_THRESHOLD = 30;
|
||||
|
||||
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(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(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>
|
||||
>;
|
||||
|
||||
/* Removed useRef */
|
||||
const [columns, gridRef] = useGridColumns(380); // Min card width 380px matches SCSS
|
||||
const [viewMode, setViewMode] = useState<ViewMode>('paged');
|
||||
const [showTooManyWarning, setShowTooManyWarning] = useState(false);
|
||||
|
||||
const filteredFiles = useMemo(() => files.filter((file) => config.filterFn(file)), [
|
||||
files,
|
||||
config
|
||||
]);
|
||||
const showAllAllowed = filteredFiles.length <= MAX_SHOW_ALL_THRESHOLD;
|
||||
const effectiveViewMode: ViewMode = viewMode === 'all' && !showAllAllowed ? 'paged' : viewMode;
|
||||
|
||||
const {
|
||||
pageSize,
|
||||
totalPages,
|
||||
currentPage,
|
||||
pageItems,
|
||||
setPageSize,
|
||||
goToPrev,
|
||||
goToNext,
|
||||
loading: sectionLoading,
|
||||
setLoading
|
||||
} = useQuotaPagination(filteredFiles);
|
||||
|
||||
useEffect(() => {
|
||||
if (showAllAllowed) return;
|
||||
if (viewMode !== 'all') return;
|
||||
|
||||
let cancelled = false;
|
||||
queueMicrotask(() => {
|
||||
if (cancelled) return;
|
||||
setViewMode('paged');
|
||||
setShowTooManyWarning(true);
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [showAllAllowed, viewMode]);
|
||||
|
||||
// Update page size based on view mode and columns
|
||||
useEffect(() => {
|
||||
if (effectiveViewMode === 'all') {
|
||||
setPageSize(Math.max(1, filteredFiles.length));
|
||||
} else {
|
||||
// Paged mode: 3 rows * columns, capped to avoid oversized pages.
|
||||
setPageSize(Math.min(columns * 3, MAX_ITEMS_PER_PAGE));
|
||||
}
|
||||
}, [effectiveViewMode, columns, filteredFiles.length, setPageSize]);
|
||||
|
||||
const { quota, loadQuota } = useQuotaLoader(config);
|
||||
|
||||
const pendingQuotaRefreshRef = useRef(false);
|
||||
const prevFilesLoadingRef = useRef(loading);
|
||||
|
||||
const handleRefresh = useCallback(() => {
|
||||
pendingQuotaRefreshRef.current = true;
|
||||
void triggerHeaderRefresh();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const wasLoading = prevFilesLoadingRef.current;
|
||||
prevFilesLoadingRef.current = loading;
|
||||
|
||||
if (!pendingQuotaRefreshRef.current) return;
|
||||
if (loading) return;
|
||||
if (!wasLoading) return;
|
||||
|
||||
pendingQuotaRefreshRef.current = false;
|
||||
const scope = effectiveViewMode === 'all' ? 'all' : 'page';
|
||||
const targets = effectiveViewMode === 'all' ? filteredFiles : pageItems;
|
||||
if (targets.length === 0) return;
|
||||
loadQuota(targets, scope, setLoading);
|
||||
}, [loading, effectiveViewMode, filteredFiles, pageItems, loadQuota, 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]);
|
||||
|
||||
const titleNode = (
|
||||
<div className={styles.titleWrapper}>
|
||||
<span>{t(`${config.i18nPrefix}.title`)}</span>
|
||||
{filteredFiles.length > 0 && (
|
||||
<span className={styles.countBadge}>
|
||||
{filteredFiles.length}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
const isRefreshing = sectionLoading || loading;
|
||||
|
||||
return (
|
||||
<Card
|
||||
title={titleNode}
|
||||
extra={
|
||||
<div className={styles.headerActions}>
|
||||
<div className={styles.viewModeToggle}>
|
||||
<Button
|
||||
variant={effectiveViewMode === 'paged' ? 'primary' : 'secondary'}
|
||||
size="sm"
|
||||
onClick={() => setViewMode('paged')}
|
||||
>
|
||||
{t('auth_files.view_mode_paged')}
|
||||
</Button>
|
||||
<Button
|
||||
variant={effectiveViewMode === 'all' ? 'primary' : 'secondary'}
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
if (filteredFiles.length > MAX_SHOW_ALL_THRESHOLD) {
|
||||
setShowTooManyWarning(true);
|
||||
} else {
|
||||
setViewMode('all');
|
||||
}
|
||||
}}
|
||||
>
|
||||
{t('auth_files.view_mode_all')}
|
||||
</Button>
|
||||
</div>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={handleRefresh}
|
||||
disabled={disabled || isRefreshing}
|
||||
loading={isRefreshing}
|
||||
title={t('quota_management.refresh_files_and_quota')}
|
||||
aria-label={t('quota_management.refresh_files_and_quota')}
|
||||
>
|
||||
{!isRefreshing && <IconRefreshCw size={16} />}
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{filteredFiles.length === 0 ? (
|
||||
<EmptyState
|
||||
title={t(`${config.i18nPrefix}.empty_title`)}
|
||||
description={t(`${config.i18nPrefix}.empty_desc`)}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<div ref={gridRef} 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 && effectiveViewMode === 'paged' && (
|
||||
<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>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{showTooManyWarning && (
|
||||
<div className={styles.warningOverlay} onClick={() => setShowTooManyWarning(false)}>
|
||||
<div className={styles.warningModal} onClick={(e) => e.stopPropagation()}>
|
||||
<p>{t('auth_files.too_many_files_warning')}</p>
|
||||
<Button variant="primary" size="sm" onClick={() => setShowTooManyWarning(false)}>
|
||||
{t('common.confirm')}
|
||||
</Button>
|
||||
</div>
|
||||
</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, KIRO_CONFIG } from './quotaConfigs';
|
||||
export type { QuotaConfig } from './quotaConfigs';
|
||||
928
src/components/quota/quotaConfigs.ts
Normal file
@@ -0,0 +1,928 @@
|
||||
/**
|
||||
* Quota configuration definitions.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import type { ReactNode } from 'react';
|
||||
import type { TFunction } from 'i18next';
|
||||
import type {
|
||||
AntigravityQuotaGroup,
|
||||
AntigravityModelsPayload,
|
||||
AntigravityQuotaState,
|
||||
AuthFileItem,
|
||||
CodexRateLimitInfo,
|
||||
CodexQuotaState,
|
||||
CodexUsageWindow,
|
||||
CodexQuotaWindow,
|
||||
CodexUsagePayload,
|
||||
GeminiCliParsedBucket,
|
||||
GeminiCliQuotaBucketState,
|
||||
GeminiCliQuotaState,
|
||||
KiroQuotaState,
|
||||
} from '@/types';
|
||||
import { apiCallApi, authFilesApi, 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,
|
||||
KIRO_QUOTA_URL,
|
||||
KIRO_REQUEST_HEADERS,
|
||||
normalizeAuthIndexValue,
|
||||
normalizeGeminiCliModelId,
|
||||
normalizeNumberValue,
|
||||
normalizePlanType,
|
||||
normalizeQuotaFraction,
|
||||
normalizeStringValue,
|
||||
parseAntigravityPayload,
|
||||
parseCodexUsagePayload,
|
||||
parseGeminiCliQuotaPayload,
|
||||
parseKiroQuotaPayload,
|
||||
resolveCodexChatgptAccountId,
|
||||
resolveCodexPlanType,
|
||||
resolveGeminiCliProjectId,
|
||||
formatCodexResetLabel,
|
||||
formatQuotaResetTime,
|
||||
buildAntigravityQuotaGroups,
|
||||
buildGeminiCliQuotaBuckets,
|
||||
createStatusError,
|
||||
getStatusFromError,
|
||||
isAntigravityFile,
|
||||
isCodexFile,
|
||||
isDisabledAuthFile,
|
||||
isGeminiCliFile,
|
||||
isKiroFile,
|
||||
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' | 'kiro';
|
||||
|
||||
const DEFAULT_ANTIGRAVITY_PROJECT_ID = 'bamboo-precept-lgxtn';
|
||||
|
||||
export interface QuotaStore {
|
||||
antigravityQuota: Record<string, AntigravityQuotaState>;
|
||||
codexQuota: Record<string, CodexQuotaState>;
|
||||
geminiCliQuota: Record<string, GeminiCliQuotaState>;
|
||||
kiroQuota: Record<string, KiroQuotaState>;
|
||||
setAntigravityQuota: (updater: QuotaUpdater<Record<string, AntigravityQuotaState>>) => void;
|
||||
setCodexQuota: (updater: QuotaUpdater<Record<string, CodexQuotaState>>) => void;
|
||||
setGeminiCliQuota: (updater: QuotaUpdater<Record<string, GeminiCliQuotaState>>) => void;
|
||||
setKiroQuota: (updater: QuotaUpdater<Record<string, KiroQuotaState>>) => 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 resolveAntigravityProjectId = async (file: AuthFileItem): Promise<string> => {
|
||||
try {
|
||||
const text = await authFilesApi.downloadText(file.name);
|
||||
const trimmed = text.trim();
|
||||
if (!trimmed) return DEFAULT_ANTIGRAVITY_PROJECT_ID;
|
||||
|
||||
const parsed = JSON.parse(trimmed) as Record<string, unknown>;
|
||||
const topLevel = normalizeStringValue(parsed.project_id ?? parsed.projectId);
|
||||
if (topLevel) return topLevel;
|
||||
|
||||
const installed =
|
||||
parsed.installed && typeof parsed.installed === 'object' && parsed.installed !== null
|
||||
? (parsed.installed as Record<string, unknown>)
|
||||
: null;
|
||||
const installedProjectId = installed
|
||||
? normalizeStringValue(installed.project_id ?? installed.projectId)
|
||||
: null;
|
||||
if (installedProjectId) return installedProjectId;
|
||||
|
||||
const web =
|
||||
parsed.web && typeof parsed.web === 'object' && parsed.web !== null
|
||||
? (parsed.web as Record<string, unknown>)
|
||||
: null;
|
||||
const webProjectId = web ? normalizeStringValue(web.project_id ?? web.projectId) : null;
|
||||
if (webProjectId) return webProjectId;
|
||||
} catch {
|
||||
return DEFAULT_ANTIGRAVITY_PROJECT_ID;
|
||||
}
|
||||
|
||||
return DEFAULT_ANTIGRAVITY_PROJECT_ID;
|
||||
};
|
||||
|
||||
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'));
|
||||
}
|
||||
|
||||
const projectId = await resolveAntigravityProjectId(file);
|
||||
const requestBody = JSON.stringify({ project: projectId });
|
||||
|
||||
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: requestBody,
|
||||
});
|
||||
|
||||
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 FIVE_HOUR_SECONDS = 18000;
|
||||
const WEEK_SECONDS = 604800;
|
||||
const WINDOW_META = {
|
||||
codeFiveHour: { id: 'five-hour', labelKey: 'codex_quota.primary_window' },
|
||||
codeWeekly: { id: 'weekly', labelKey: 'codex_quota.secondary_window' },
|
||||
codeReviewFiveHour: { id: 'code-review-five-hour', labelKey: 'codex_quota.code_review_primary_window' },
|
||||
codeReviewWeekly: { id: 'code-review-weekly', labelKey: 'codex_quota.code_review_secondary_window' },
|
||||
} as const;
|
||||
|
||||
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,
|
||||
});
|
||||
};
|
||||
|
||||
const getWindowSeconds = (window?: CodexUsageWindow | null): number | null => {
|
||||
if (!window) return null;
|
||||
return normalizeNumberValue(window.limit_window_seconds ?? window.limitWindowSeconds);
|
||||
};
|
||||
|
||||
const rawLimitReached = rateLimit?.limit_reached ?? rateLimit?.limitReached;
|
||||
const rawAllowed = rateLimit?.allowed;
|
||||
|
||||
const pickClassifiedWindows = (
|
||||
limitInfo?: CodexRateLimitInfo | null
|
||||
): { fiveHourWindow: CodexUsageWindow | null; weeklyWindow: CodexUsageWindow | null } => {
|
||||
const rawWindows = [
|
||||
limitInfo?.primary_window ?? limitInfo?.primaryWindow ?? null,
|
||||
limitInfo?.secondary_window ?? limitInfo?.secondaryWindow ?? null,
|
||||
];
|
||||
|
||||
let fiveHourWindow: CodexUsageWindow | null = null;
|
||||
let weeklyWindow: CodexUsageWindow | null = null;
|
||||
|
||||
for (const window of rawWindows) {
|
||||
if (!window) continue;
|
||||
const seconds = getWindowSeconds(window);
|
||||
if (seconds === FIVE_HOUR_SECONDS && !fiveHourWindow) {
|
||||
fiveHourWindow = window;
|
||||
} else if (seconds === WEEK_SECONDS && !weeklyWindow) {
|
||||
weeklyWindow = window;
|
||||
}
|
||||
}
|
||||
|
||||
return { fiveHourWindow, weeklyWindow };
|
||||
};
|
||||
|
||||
const rateWindows = pickClassifiedWindows(rateLimit);
|
||||
addWindow(
|
||||
WINDOW_META.codeFiveHour.id,
|
||||
WINDOW_META.codeFiveHour.labelKey,
|
||||
rateWindows.fiveHourWindow,
|
||||
rawLimitReached,
|
||||
rawAllowed
|
||||
);
|
||||
addWindow(
|
||||
WINDOW_META.codeWeekly.id,
|
||||
WINDOW_META.codeWeekly.labelKey,
|
||||
rateWindows.weeklyWindow,
|
||||
rawLimitReached,
|
||||
rawAllowed
|
||||
);
|
||||
|
||||
const codeReviewWindows = pickClassifiedWindows(codeReviewLimit);
|
||||
const codeReviewLimitReached = codeReviewLimit?.limit_reached ?? codeReviewLimit?.limitReached;
|
||||
const codeReviewAllowed = codeReviewLimit?.allowed;
|
||||
addWindow(
|
||||
WINDOW_META.codeReviewFiveHour.id,
|
||||
WINDOW_META.codeReviewFiveHour.labelKey,
|
||||
codeReviewWindows.fiveHourWindow,
|
||||
codeReviewLimitReached,
|
||||
codeReviewAllowed
|
||||
);
|
||||
addWindow(
|
||||
WINDOW_META.codeReviewWeekly.id,
|
||||
WINDOW_META.codeReviewWeekly.labelKey,
|
||||
codeReviewWindows.weeklyWindow,
|
||||
codeReviewLimitReached,
|
||||
codeReviewAllowed
|
||||
);
|
||||
|
||||
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 = normalizeGeminiCliModelId(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 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 (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) && !isDisabledAuthFile(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) && !isDisabledAuthFile(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) && !isDisabledAuthFile(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,
|
||||
};
|
||||
|
||||
// Kiro quota data structure from API
|
||||
interface KiroQuotaData {
|
||||
// Base quota (原本额度)
|
||||
baseUsage: number | null;
|
||||
baseLimit: number | null;
|
||||
baseRemaining: number | null;
|
||||
// Free trial/bonus quota (赠送额度)
|
||||
bonusUsage: number | null;
|
||||
bonusLimit: number | null;
|
||||
bonusRemaining: number | null;
|
||||
bonusStatus?: string;
|
||||
// Total (合计)
|
||||
currentUsage: number | null;
|
||||
usageLimit: number | null;
|
||||
remainingCredits: number | null;
|
||||
nextReset?: string;
|
||||
subscriptionType?: string;
|
||||
}
|
||||
|
||||
const fetchKiroQuota = async (
|
||||
file: AuthFileItem,
|
||||
t: TFunction
|
||||
): Promise<KiroQuotaData> => {
|
||||
const rawAuthIndex = file['auth_index'] ?? file.authIndex;
|
||||
const authIndex = normalizeAuthIndexValue(rawAuthIndex);
|
||||
if (!authIndex) {
|
||||
throw new Error(t('kiro_quota.missing_auth_index'));
|
||||
}
|
||||
|
||||
const result = await apiCallApi.request({
|
||||
authIndex,
|
||||
method: 'GET',
|
||||
url: KIRO_QUOTA_URL,
|
||||
header: { ...KIRO_REQUEST_HEADERS }
|
||||
});
|
||||
|
||||
if (result.statusCode < 200 || result.statusCode >= 300) {
|
||||
throw createStatusError(getApiCallErrorMessage(result), result.statusCode);
|
||||
}
|
||||
|
||||
const payload = parseKiroQuotaPayload(result.body ?? result.bodyText);
|
||||
if (!payload) {
|
||||
throw new Error(t('kiro_quota.empty_data'));
|
||||
}
|
||||
|
||||
// Extract usage data from usageBreakdownList (separating base and bonus)
|
||||
const breakdownList = payload.usageBreakdownList ?? [];
|
||||
let baseLimit = 0;
|
||||
let baseUsage = 0;
|
||||
let bonusLimit = 0;
|
||||
let bonusUsage = 0;
|
||||
let bonusStatus: string | undefined;
|
||||
|
||||
for (const breakdown of breakdownList) {
|
||||
// Add base quota
|
||||
const limit = normalizeNumberValue(breakdown.usageLimitWithPrecision ?? breakdown.usageLimit);
|
||||
const usage = normalizeNumberValue(breakdown.currentUsageWithPrecision ?? breakdown.currentUsage);
|
||||
if (limit !== null) baseLimit += limit;
|
||||
if (usage !== null) baseUsage += usage;
|
||||
|
||||
// Add free trial quota if available (e.g., 500 bonus credits)
|
||||
const freeTrialInfo = breakdown.freeTrialInfo;
|
||||
if (freeTrialInfo) {
|
||||
const freeLimit = normalizeNumberValue(freeTrialInfo.usageLimitWithPrecision ?? freeTrialInfo.usageLimit);
|
||||
const freeUsage = normalizeNumberValue(freeTrialInfo.currentUsageWithPrecision ?? freeTrialInfo.currentUsage);
|
||||
if (freeLimit !== null) bonusLimit += freeLimit;
|
||||
if (freeUsage !== null) bonusUsage += freeUsage;
|
||||
if (freeTrialInfo.freeTrialStatus) {
|
||||
bonusStatus = freeTrialInfo.freeTrialStatus;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const totalLimit = baseLimit + bonusLimit;
|
||||
const totalUsage = baseUsage + bonusUsage;
|
||||
|
||||
// Calculate next reset time
|
||||
// Note: nextDateReset from Kiro API is in SECONDS (e.g., 1.769904E9 = 1769904000)
|
||||
// JavaScript Date() requires milliseconds, so multiply by 1000
|
||||
let nextReset: string | undefined;
|
||||
if (payload.nextDateReset) {
|
||||
// API returns seconds timestamp (scientific notation like 1.769904E9)
|
||||
const timestampSeconds = payload.nextDateReset;
|
||||
const resetDate = new Date(timestampSeconds * 1000);
|
||||
if (!isNaN(resetDate.getTime())) {
|
||||
nextReset = resetDate.toISOString();
|
||||
}
|
||||
}
|
||||
|
||||
// Get subscription type
|
||||
const subscriptionType = payload.subscriptionInfo?.subscriptionTitle ?? payload.subscriptionInfo?.type;
|
||||
|
||||
return {
|
||||
baseUsage,
|
||||
baseLimit,
|
||||
baseRemaining: baseLimit > 0 ? Math.max(0, baseLimit - baseUsage) : null,
|
||||
bonusUsage,
|
||||
bonusLimit,
|
||||
bonusRemaining: bonusLimit > 0 ? Math.max(0, bonusLimit - bonusUsage) : null,
|
||||
bonusStatus,
|
||||
currentUsage: totalUsage,
|
||||
usageLimit: totalLimit,
|
||||
remainingCredits: totalLimit > 0 ? Math.max(0, totalLimit - totalUsage) : null,
|
||||
nextReset,
|
||||
subscriptionType
|
||||
};
|
||||
};
|
||||
|
||||
const renderKiroItems = (
|
||||
quota: KiroQuotaState,
|
||||
t: TFunction,
|
||||
helpers: QuotaRenderHelpers
|
||||
): ReactNode => {
|
||||
const { styles: styleMap, QuotaProgressBar } = helpers;
|
||||
const { createElement: h, Fragment } = React;
|
||||
|
||||
const nodes: ReactNode[] = [];
|
||||
|
||||
// Show subscription type if available
|
||||
if (quota.subscriptionType) {
|
||||
nodes.push(
|
||||
h(
|
||||
'div',
|
||||
{ key: 'subscription', className: styleMap.codexPlan },
|
||||
h('span', { className: styleMap.codexPlanLabel }, t('kiro_quota.subscription_label')),
|
||||
h('span', { className: styleMap.codexPlanValue }, quota.subscriptionType)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const usageLimit = quota.usageLimit;
|
||||
|
||||
if (usageLimit === null || usageLimit === 0) {
|
||||
nodes.push(
|
||||
h('div', { key: 'empty', className: styleMap.quotaMessage }, t('kiro_quota.empty_data'))
|
||||
);
|
||||
return h(Fragment, null, ...nodes);
|
||||
}
|
||||
|
||||
const resetLabel = formatQuotaResetTime(quota.nextReset);
|
||||
|
||||
// Base quota display (原本额度)
|
||||
const baseLimit = quota.baseLimit;
|
||||
const baseRemaining = quota.baseRemaining;
|
||||
if (baseLimit !== null && baseLimit > 0) {
|
||||
const baseRemainingPercent = baseRemaining !== null && baseLimit > 0
|
||||
? Math.round((baseRemaining / baseLimit) * 100)
|
||||
: 0;
|
||||
|
||||
nodes.push(
|
||||
h(
|
||||
'div',
|
||||
{ key: 'base-credits', className: styleMap.quotaRow },
|
||||
h(
|
||||
'div',
|
||||
{ className: styleMap.quotaRowHeader },
|
||||
h('span', { className: styleMap.quotaModel }, t('kiro_quota.base_credits_label')),
|
||||
h(
|
||||
'div',
|
||||
{ className: styleMap.quotaMeta },
|
||||
h('span', { className: styleMap.quotaPercent }, `${baseRemainingPercent}%`),
|
||||
baseRemaining !== null
|
||||
? h('span', { className: styleMap.quotaAmount }, t('kiro_quota.remaining_credits', { count: Math.round(baseRemaining) }))
|
||||
: null,
|
||||
h('span', { className: styleMap.quotaReset }, resetLabel)
|
||||
)
|
||||
),
|
||||
h(QuotaProgressBar, { percent: baseRemainingPercent, highThreshold: 60, mediumThreshold: 20 })
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// Bonus quota display (赠送额度)
|
||||
const bonusLimit = quota.bonusLimit;
|
||||
const bonusRemaining = quota.bonusRemaining;
|
||||
if (bonusLimit !== null && bonusLimit > 0) {
|
||||
const bonusRemainingPercent = bonusRemaining !== null && bonusLimit > 0
|
||||
? Math.round((bonusRemaining / bonusLimit) * 100)
|
||||
: 0;
|
||||
|
||||
nodes.push(
|
||||
h(
|
||||
'div',
|
||||
{ key: 'bonus-credits', className: styleMap.quotaRow },
|
||||
h(
|
||||
'div',
|
||||
{ className: styleMap.quotaRowHeader },
|
||||
h('span', { className: styleMap.quotaModel }, t('kiro_quota.bonus_credits_label')),
|
||||
h(
|
||||
'div',
|
||||
{ className: styleMap.quotaMeta },
|
||||
h('span', { className: styleMap.quotaPercent }, `${bonusRemainingPercent}%`),
|
||||
bonusRemaining !== null
|
||||
? h('span', { className: styleMap.quotaAmount }, t('kiro_quota.remaining_credits', { count: Math.round(bonusRemaining) }))
|
||||
: null
|
||||
)
|
||||
),
|
||||
h(QuotaProgressBar, { percent: bonusRemainingPercent, highThreshold: 60, mediumThreshold: 20 })
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// Total credits display (合计)
|
||||
const currentUsage = quota.currentUsage;
|
||||
const remainingCredits = quota.remainingCredits;
|
||||
const totalRemainingPercent = currentUsage !== null && usageLimit > 0
|
||||
? Math.max(0, 100 - Math.round((currentUsage / usageLimit) * 100))
|
||||
: 0;
|
||||
|
||||
nodes.push(
|
||||
h(
|
||||
'div',
|
||||
{ key: 'total-credits', className: styleMap.quotaRow },
|
||||
h(
|
||||
'div',
|
||||
{ className: styleMap.quotaRowHeader },
|
||||
h('span', { className: styleMap.quotaModel }, t('kiro_quota.total_credits_label')),
|
||||
h(
|
||||
'div',
|
||||
{ className: styleMap.quotaMeta },
|
||||
h('span', { className: styleMap.quotaPercent }, `${totalRemainingPercent}%`),
|
||||
remainingCredits !== null
|
||||
? h('span', { className: styleMap.quotaAmount }, t('kiro_quota.remaining_credits', { count: Math.round(remainingCredits) }))
|
||||
: null
|
||||
)
|
||||
),
|
||||
h(QuotaProgressBar, { percent: totalRemainingPercent, highThreshold: 60, mediumThreshold: 20 })
|
||||
)
|
||||
);
|
||||
|
||||
return h(Fragment, null, ...nodes);
|
||||
};
|
||||
|
||||
export const KIRO_CONFIG: QuotaConfig<KiroQuotaState, KiroQuotaData> = {
|
||||
type: 'kiro',
|
||||
i18nPrefix: 'kiro_quota',
|
||||
filterFn: (file) => isKiroFile(file),
|
||||
fetchQuota: fetchKiroQuota,
|
||||
storeSelector: (state) => state.kiroQuota,
|
||||
storeSetter: 'setKiroQuota',
|
||||
buildLoadingState: () => ({
|
||||
status: 'loading',
|
||||
baseUsage: null,
|
||||
baseLimit: null,
|
||||
baseRemaining: null,
|
||||
bonusUsage: null,
|
||||
bonusLimit: null,
|
||||
bonusRemaining: null,
|
||||
currentUsage: null,
|
||||
usageLimit: null,
|
||||
remainingCredits: null
|
||||
}),
|
||||
buildSuccessState: (data) => ({
|
||||
status: 'success',
|
||||
baseUsage: data.baseUsage,
|
||||
baseLimit: data.baseLimit,
|
||||
baseRemaining: data.baseRemaining,
|
||||
bonusUsage: data.bonusUsage,
|
||||
bonusLimit: data.bonusLimit,
|
||||
bonusRemaining: data.bonusRemaining,
|
||||
bonusStatus: data.bonusStatus,
|
||||
currentUsage: data.currentUsage,
|
||||
usageLimit: data.usageLimit,
|
||||
remainingCredits: data.remainingCredits,
|
||||
nextReset: data.nextReset,
|
||||
subscriptionType: data.subscriptionType
|
||||
}),
|
||||
buildErrorState: (message, status) => ({
|
||||
status: 'error',
|
||||
baseUsage: null,
|
||||
baseLimit: null,
|
||||
baseRemaining: null,
|
||||
bonusUsage: null,
|
||||
bonusLimit: null,
|
||||
bonusRemaining: null,
|
||||
currentUsage: null,
|
||||
usageLimit: null,
|
||||
remainingCredits: null,
|
||||
error: message,
|
||||
errorStatus: status
|
||||
}),
|
||||
cardClassName: styles.kiroCard,
|
||||
controlsClassName: styles.kiroControls,
|
||||
controlClassName: styles.kiroControl,
|
||||
gridClassName: styles.kiroGrid,
|
||||
renderQuotaItems: renderKiroItems
|
||||
};
|
||||
40
src/components/quota/useGridColumns.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
|
||||
/**
|
||||
* Hook to calculate the number of grid columns based on container width and item min-width.
|
||||
* Returns [columns, refCallback].
|
||||
*/
|
||||
export function useGridColumns(
|
||||
itemMinWidth: number,
|
||||
gap: number = 16
|
||||
): [number, (node: HTMLDivElement | null) => void] {
|
||||
const [columns, setColumns] = useState(1);
|
||||
const [element, setElement] = useState<HTMLDivElement | null>(null);
|
||||
|
||||
const refCallback = useCallback((node: HTMLDivElement | null) => {
|
||||
setElement(node);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!element) return;
|
||||
|
||||
const updateColumns = () => {
|
||||
const containerWidth = element.clientWidth;
|
||||
const effectiveItemWidth = itemMinWidth + gap;
|
||||
const count = Math.floor((containerWidth + gap) / effectiveItemWidth);
|
||||
setColumns(Math.max(1, count));
|
||||
};
|
||||
|
||||
updateColumns();
|
||||
|
||||
const observer = new ResizeObserver(() => {
|
||||
updateColumns();
|
||||
});
|
||||
|
||||
observer.observe(element);
|
||||
|
||||
return () => observer.disconnect();
|
||||
}, [element, itemMinWidth, gap]);
|
||||
|
||||
return [columns, refCallback];
|
||||
}
|
||||
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 };
|
||||
}
|
||||